diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 70d3394c..15e28115 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -28,20 +28,20 @@ pub use logisheets_controller::api::{ pub use logisheets_controller::controller::display::{ BlockCellInfo, BlockDataRow, BlockDisplayInfo, BlockField, BlockInfo, BlockSchema, BlockSchemaRandomEntry, BlockSchemaType, CellCoordinate, CellCoordinateWithSheet, - CellImageInfo, CellPosition, ChartInfo, ChartSeriesInfo, ColInfo, DisplayWindow, - DisplayWindowRequest, DisplayWindowWithStartPoint, LinkInfo, RowInfo, ShadowCellInfo, - SheetInfo, TempCellChange, TempStatusDiff, + CellImageInfo, CellPosition, ChartAxisScaleInfo, ChartDataLabelsInfo, ChartInfo, + ChartOfPieSplitInfo, ChartSeriesInfo, ColInfo, DisplayWindow, DisplayWindowRequest, DisplayWindowWithStartPoint, + LinkInfo, RowInfo, ShadowCellInfo, SheetInfo, TempCellChange, TempStatusDiff, }; // Re-export edit actions pub use logisheets_controller::edit_action::{ - ActionEffect, Alignment, AsyncFuncResult, BindFormSchema, BindRandomSchema, BlockInput, - BlockLineNameFieldUpdate, BlockLineStyleUpdate, CellClear, CellFormatBrush, CellInput, - CellStyleUpdate, CreateAppendix, CreateBlock, CreateDiyCell, CreateSheet, DeleteCellImage, - DeleteCols, DeleteColsInBlock, DeleteRows, DeleteRowsInBlock, DeleteSheet, EditAction, - EditPayload, EphemeralCellInput, HorizontalAlignment, InsertCols, InsertColsInBlock, - InsertRows, InsertRowsInBlock, LineFormatBrush, LineStyleUpdate, MergeCells, MoveBlock, - PayloadsAction, RemoveBlock, ReproduceCells, ResizeBlock, SetCellImage, SetColWidth, + ActionEffect, Alignment, AsyncFuncResult, AxisScaleUpdate, BindFormSchema, BindRandomSchema, + BlockInput, BlockLineNameFieldUpdate, BlockLineStyleUpdate, CellClear, CellFormatBrush, + CellInput, CellStyleUpdate, CreateAppendix, CreateBlock, CreateDiyCell, CreateSheet, + DeleteCellImage, DeleteCols, DeleteColsInBlock, DeleteRows, DeleteRowsInBlock, DeleteSheet, + EditAction, EditPayload, EphemeralCellInput, HorizontalAlignment, InsertCols, + InsertColsInBlock, InsertRows, InsertRowsInBlock, LineFormatBrush, LineStyleUpdate, MergeCells, + MoveBlock, OfPieSplitUpdate, PayloadsAction, RemoveBlock, ReproduceCells, ResizeBlock, SetCellImage, SetColWidth, SetRowHeight, SetSheetColor, SetSheetVisible, SheetCellId, SheetRename, SplitMergedCells, StatusCode, StyleUpdateType, UpsertFieldRenderInfo, VerticalAlignment, }; diff --git a/crates/controller/src/api/test.rs b/crates/controller/src/api/test.rs index a57d3383..0fea83a6 100644 --- a/crates/controller/src/api/test.rs +++ b/crates/controller/src/api/test.rs @@ -49,6 +49,7 @@ fn update_chart_changes_type_and_title() { chart_id: chart_id.clone(), chart_type: Some("line".to_string()), title: Some("My Title".to_string()), + ..Default::default() })], undoable: true, init: false, @@ -115,10 +116,16 @@ fn create_chart_from_scratch() { CreateChartSeries { name: Some("Row1".to_string()), value_ref: "Sheet1!$B$1:$C$1".to_string(), + color: None, + size_ref: None, + series_type: None, }, CreateChartSeries { name: Some("Row2".to_string()), value_ref: "Sheet1!$B$2:$C$2".to_string(), + color: None, + size_ref: None, + series_type: None, }, ], })], @@ -1042,11 +1049,7 @@ fn table_converts_to_block_on_load() { let blocks2 = ws2.get_all_blocks(); assert_eq!(blocks2.len(), 1, "block survives save/reload"); assert_eq!( - blocks2[0] - .schema - .as_ref() - .expect("still has a schema") - .name, + blocks2[0].schema.as_ref().expect("still has a schema").name, schema.name, "the ref name should not change across a save" ); @@ -3524,8 +3527,8 @@ fn conditional_formatting_resyncs_after_edits() { fn conditional_format_reaches_cell_info_merged() { use crate::edit_action::CellInput; use logisheets_workbook::prelude::{ - CtColor, CtDxf, CtDxfs, CtFill, CtFont, CtPatternFill, PlainTextString, - StPatternType, Wb, write, + CtColor, CtDxf, CtDxfs, CtFill, CtFont, CtPatternFill, PlainTextString, StPatternType, Wb, + write, }; fn red(rgb: &str) -> CtColor { @@ -4364,3 +4367,1039 @@ fn range_straddling_a_block_boundary_does_not_panic() { .expect("reloading must not panic on the rejected reference"); assert_eq!(reloaded.get_sheet_count(), 1); } + +#[test] +fn update_chart_changes_every_setting() { + // Everything the chart editor can change must land in the chart and + // survive a save/reload — the chart XML is regenerated on each edit, so a + // setting that is not written back is silently lost. + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let (chart_id, original_color) = { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let charts = ws.get_charts(); + ( + charts[0].chart_id.clone(), + charts[0].series[0].color.clone(), + ) + }; + assert!(original_color.is_some(), "fixture series has a theme color"); + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + chart_type: Some("bar".to_string()), + stacked: Some(true), + legend_pos: Some("right".to_string()), + cat_axis_title: Some("Quarter".to_string()), + val_axis_title: Some("Amount".to_string()), + show_data_labels: Some(true), + data_label_position: Some("ctr".to_string()), + num_fmt: Some("#,##0.00".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + + let check = |c: &crate::controller::display::ChartInfo| { + assert_eq!(c.chart_type, "bar"); + assert!(c.stacked, "stacked"); + assert_eq!(c.legend_pos.as_deref(), Some("right")); + assert_eq!(c.cat_axis_title.as_deref(), Some("Quarter")); + assert_eq!(c.val_axis_title.as_deref(), Some("Amount")); + assert!(c.data_labels.show_value, "data labels on"); + assert_eq!(c.data_labels.position.as_deref(), Some("ctr")); + assert_eq!(c.data_labels.num_fmt.as_deref(), Some("#,##0.00")); + assert_eq!(c.val_axis_num_fmt.as_deref(), Some("#,##0.00")); + // Regenerating the XML must not drop the series' colors. + assert_eq!(c.series.len(), 3); + assert!(c.series[0].color.is_some(), "series color preserved"); + }; + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + check(&ws.get_charts()[0]); + } + + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + let ws2 = wb2.get_sheet_by_idx(0).unwrap(); + check(&ws2.get_charts()[0]); + assert_eq!( + ws2.get_charts()[0].series[0].color, + original_color, + "the theme color is the same one it was loaded with" + ); +} + +#[test] +fn update_chart_repoints_series_and_keeps_colors() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let (chart_id, color0) = { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = ws.get_charts(); + (c[0].chart_id.clone(), c[0].series[0].color.clone()) + }; + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id, + // Two series instead of three; the first keeps its slot (and its + // color), the second names an explicit one. + series: Some(vec![ + CreateChartSeries { + name: Some("First".to_string()), + value_ref: "Sheet1!$B$2:$E$2".to_string(), + color: None, + size_ref: None, + series_type: None, + }, + CreateChartSeries { + name: Some("Second".to_string()), + value_ref: "Sheet1!$B$3:$E$3".to_string(), + color: Some("FF0000".to_string()), + size_ref: None, + series_type: None, + }, + ]), + categories_ref: Some("Sheet1!$B$1:$E$1".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.series.len(), 2); + assert_eq!(c.series[0].name.as_deref(), Some("First")); + assert_eq!(c.series[0].color, color0, "kept its position's color"); + assert_eq!(c.series[1].color.as_deref(), Some("FF0000")); + assert_eq!(c.series[0].val_ref.as_deref(), Some("Sheet1!$B$2:$E$2")); + assert_eq!(c.cat_ref.as_deref(), Some("Sheet1!$B$1:$E$1")); + assert_eq!( + c.series[0].values, + vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)] + ); +} + +#[test] +fn chart_categories_and_formats_are_live() { + // Category labels follow the source cells (formatted the way the sheet + // shows them), and the series' number format is read from those cells so + // labels/axis can render like the data does. + let mut wb = Workbook::default(); + let mut payloads: Vec = vec![ + ("A1", 0usize, 0usize, "Jan"), + ("A2", 1, 0, "Feb"), + ("B1", 0, 1, "1234.5"), + ("B2", 1, 1, "6789"), + ] + .into_iter() + .map(|(_, r, c, v)| { + EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: r, + col: c, + content: v.to_string(), + }) + }) + .collect(); + // Format the values as currency-ish thousands. + payloads.push(EditPayload::CellStyleUpdate( + crate::edit_action::CellStyleUpdate { + sheet_idx: 0, + row: 0, + col: 1, + ty: StyleUpdateType { + set_num_fmt: Some("#,##0.00".to_string()), + ..Default::default() + }, + }, + )); + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads, + undoable: true, + init: false, + })); + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: "chartLive".to_string(), + chart_type: "col".to_string(), + from_row: 4, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 18, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: Some("Sheet1!$A$1:$A$2".to_string()), + series: vec![CreateChartSeries { + name: Some("Values".to_string()), + value_ref: "Sheet1!$B$1:$B$2".to_string(), + color: None, + size_ref: None, + series_type: None, + }], + })], + undoable: true, + init: false, + })); + + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.categories, vec!["Jan".to_string(), "Feb".to_string()]); + assert_eq!( + c.series[0].num_fmt.as_deref(), + Some("#,##0.00"), + "series format comes from the source cells" + ); + // The label strings are rendered core-side; the host cannot evaluate + // Excel format codes. + assert_eq!( + c.series[0].formatted_values, + vec![Some("1,234.50".to_string()), Some("6,789.00".to_string())], + "values are pre-formatted for data labels" + ); + } + + // Renaming a category cell updates the chart's labels. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: 1, + col: 0, + content: "March".to_string(), + })], + undoable: true, + init: false, + })); + let ws = wb.get_sheet_by_idx(0).unwrap(); + assert_eq!( + ws.get_charts()[0].categories, + vec!["Jan".to_string(), "March".to_string()], + "category labels are live" + ); +} + +#[test] +fn editing_a_chart_keeps_its_styling_and_satellite_parts() { + // A chart authored in Excel carries styling this engine does not model + // (fonts, fills, gridline colors) plus sibling parts (style1/colors1). + // Editing it regenerates the chart XML, so both have to survive that — + // and survive a save/reload afterwards. + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let chart_id = { + let ws = wb.get_sheet_by_idx(0).unwrap(); + ws.get_charts()[0].chart_id.clone() + }; + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + chart_type: Some("line".to_string()), + title: Some("Edited".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + + let bytes = wb.save().unwrap(); + let parts = chart_parts(&bytes); + let chart_xml = parts + .iter() + .find(|(p, _)| p.ends_with("chart1.xml")) + .map(|(_, d)| String::from_utf8_lossy(d).to_string()) + .expect("chart part written"); + + assert!( + chart_xml.contains(r#""), + "styled gridlines survived the edit" + ); + assert!( + chart_xml.contains(r#""#), + "chart-area fill survived the edit" + ); + assert!(chart_xml.contains(""), "the edit applied"); + + // Excel's own style/colors parts ride along untouched. + assert!( + parts.iter().any(|(p, _)| p.ends_with("style1.xml")), + "style part kept, got {:?}", + parts.iter().map(|(p, _)| p).collect::>() + ); + assert!( + parts.iter().any(|(p, _)| p.ends_with("colors1.xml")), + "colors part kept" + ); + + // And the reloaded workbook still shows the edited chart. + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + let ws2 = wb2.get_sheet_by_idx(0).unwrap(); + let c = &ws2.get_charts()[0]; + assert_eq!(c.chart_type, "line"); + assert_eq!(c.title.as_deref(), Some("Edited")); + assert!(c.series[0].color.is_some(), "series colors still resolve"); +} + +/// Every chart part in a saved workbook, as (path, bytes). +fn chart_parts(xlsx: &[u8]) -> Vec<(String, Vec)> { + let wb = logisheets_workbook::workbook::Wb::from_file(xlsx).unwrap(); + wb.xl + .worksheets + .values() + .filter_map(|w| w.drawing.as_ref()) + .flat_map(|d| d.chart_parts.iter()) + .map(|p| (p.path.clone(), p.data.clone())) + .collect() +} + +#[test] +fn update_chart_sets_the_axis_scale() { + use crate::edit_action::AxisScaleUpdate; + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let chart_id = { + let ws = wb.get_sheet_by_idx(0).unwrap(); + assert_eq!( + ws.get_charts()[0].val_axis_scale.min, + None, + "starts automatic" + ); + ws.get_charts()[0].chart_id.clone() + }; + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + val_axis_scale: Some(AxisScaleUpdate { + min: Some(0.0), + max: Some(80.0), + major_unit: Some(20.0), + // Out of Excel's 2..=1000 range, so it must be ignored rather + // than written into a file Excel would refuse. + log_base: Some(1.0), + ..Default::default() + }), + ..Default::default() + })], + undoable: true, + init: false, + })); + + let check = |c: &crate::controller::display::ChartInfo| { + assert_eq!(c.val_axis_scale.min, Some(0.0)); + assert_eq!(c.val_axis_scale.max, Some(80.0)); + assert_eq!(c.val_axis_scale.major_unit, Some(20.0)); + assert_eq!(c.val_axis_scale.log_base, None, "invalid log base dropped"); + assert!(!c.val_axis_scale.reversed); + }; + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + check(&ws.get_charts()[0]); + } + + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + check(&wb2.get_sheet_by_idx(0).unwrap().get_charts()[0]); + + // Sending the scale again with everything cleared returns it to automatic. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id, + val_axis_scale: Some(AxisScaleUpdate::default()), + ..Default::default() + })], + undoable: true, + init: false, + })); + let ws = wb.get_sheet_by_idx(0).unwrap(); + assert_eq!(ws.get_charts()[0].val_axis_scale.max, None, "back to auto"); +} + +/// Charts live in `Status`, which is what the undo stack snapshots — so every +/// chart payload has to be undoable like any cell edit. This pins that down for +/// all four of them, since nothing else would catch a chart edit quietly +/// falling outside the history. +#[test] +fn chart_edits_are_undoable() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let chart_id = { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = ws.get_charts(); + assert_eq!(c[0].chart_type, "col"); + assert_eq!(c[0].title, None); + c[0].chart_id.clone() + }; + let charts = |wb: &Workbook| wb.get_sheet_by_idx(0).unwrap().get_charts(); + + // --- reconfigure ------------------------------------------------- + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + chart_type: Some("line".to_string()), + title: Some("Edited".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + assert_eq!(charts(&wb)[0].chart_type, "line"); + + wb.handle_action(EditAction::Undo); + let c = charts(&wb); + assert_eq!(c[0].chart_type, "col", "undo restores the chart type"); + assert_eq!(c[0].title, None, "undo restores the title"); + + wb.handle_action(EditAction::Redo); + let c = charts(&wb); + assert_eq!(c[0].chart_type, "line", "redo re-applies it"); + assert_eq!(c[0].title.as_deref(), Some("Edited")); + + // --- move -------------------------------------------------------- + let (from_row, from_col) = (charts(&wb)[0].from_row, charts(&wb)[0].from_col); + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::MoveChart(MoveChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + from_row: from_row + 5, + from_col: from_col + 2, + from_col_off: 0, + from_row_off: 0, + to_row: from_row + 20, + to_col: from_col + 10, + to_col_off: 0, + to_row_off: 0, + })], + undoable: true, + init: false, + })); + assert_eq!(charts(&wb)[0].from_row, from_row + 5); + wb.handle_action(EditAction::Undo); + assert_eq!( + (charts(&wb)[0].from_row, charts(&wb)[0].from_col), + (from_row, from_col), + "undo restores the anchor" + ); + + // --- delete ------------------------------------------------------ + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::DeleteChart(DeleteChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + })], + undoable: true, + init: false, + })); + assert!(charts(&wb).is_empty()); + wb.handle_action(EditAction::Undo); + let c = charts(&wb); + assert_eq!(c.len(), 1, "undo brings the chart back"); + assert_eq!(c[0].chart_id, chart_id); + assert_eq!(c[0].series.len(), 3, "with its data intact"); + assert!(c[0].series[0].color.is_some(), "and its styling"); + + // --- create ------------------------------------------------------ + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: "chartUndo".to_string(), + chart_type: "pie".to_string(), + from_row: 20, + from_col: 1, + from_col_off: 0, + from_row_off: 0, + to_row: 30, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: None, + series: vec![CreateChartSeries { + name: None, + value_ref: "Sheet1!$B$2:$E$2".to_string(), + color: None, + size_ref: None, + series_type: None, + }], + })], + undoable: true, + init: false, + })); + assert_eq!(charts(&wb).len(), 2); + wb.handle_action(EditAction::Undo); + assert_eq!(charts(&wb).len(), 1, "undo removes the created chart"); + wb.handle_action(EditAction::Redo); + assert_eq!(charts(&wb).len(), 2, "redo brings it back"); +} + +#[test] +fn create_bubble_chart_with_live_sizes() { + // A bubble chart's third dimension goes through the same live-value path + // as the Y values: editing a size cell must move the bubble. + let mut wb = Workbook::default(); + let cells = [ + (0usize, 0usize, "10"), // x + (1, 0, "20"), + (0, 1, "5"), // y + (1, 1, "8"), + (0, 2, "100"), // size + (1, 2, "400"), + ]; + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: cells + .iter() + .map(|(r, c, v)| { + EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: *r, + col: *c, + content: v.to_string(), + }) + }) + .collect(), + undoable: true, + init: false, + })); + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: "bubble1".to_string(), + chart_type: "bubble".to_string(), + from_row: 4, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 18, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: Some("Bubbles".to_string()), + categories_ref: Some("Sheet1!$A$1:$A$2".to_string()), + series: vec![CreateChartSeries { + name: Some("Products".to_string()), + value_ref: "Sheet1!$B$1:$B$2".to_string(), + color: None, + size_ref: Some("Sheet1!$C$1:$C$2".to_string()), + series_type: None, + }], + })], + undoable: true, + init: false, + })); + + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.chart_type, "bubble"); + assert_eq!(c.series[0].values, vec![Some(5.0), Some(8.0)]); + assert_eq!(c.series[0].sizes, vec![Some(100.0), Some(400.0)]); + assert_eq!(c.series[0].size_ref.as_deref(), Some("Sheet1!$C$1:$C$2")); + } + + // Sizes are live. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: 0, + col: 2, + content: "900".to_string(), + })], + undoable: true, + init: false, + })); + assert_eq!( + wb.get_sheet_by_idx(0).unwrap().get_charts()[0].series[0].sizes[0], + Some(900.0) + ); + + // And they survive save/reload. + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "r".to_string()).unwrap(); + let c = &wb2.get_sheet_by_idx(0).unwrap().get_charts()[0]; + assert_eq!(c.chart_type, "bubble"); + assert_eq!(c.series[0].sizes, vec![Some(900.0), Some(400.0)]); +} + +#[test] +fn switch_a_chart_to_radar_and_bubble() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let mut wb = Workbook::from_file(&buf, "graph".to_string()).unwrap(); + let chart_id = wb.get_sheet_by_idx(0).unwrap().get_charts()[0] + .chart_id + .clone(); + + let switch = |wb: &mut Workbook, ty: &str| { + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: chart_id.clone(), + chart_type: Some(ty.to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + }; + + switch(&mut wb, "radar"); + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.chart_type, "radar"); + assert_eq!(c.series.len(), 3, "series survive the switch"); + assert_eq!( + c.series[0].values, + vec![Some(11.0), Some(13.0), Some(15.0), Some(24.0)] + ); + assert!(c.series[0].color.is_some(), "colors survive"); + } + + switch(&mut wb, "bubble"); + { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.chart_type, "bubble"); + // No size reference was ever set, so bubbles have no third dimension — + // the chart is still valid, the renderer just uses a default size. + assert!(c.series[0].sizes.is_empty()); + } + + // Both kinds survive a save/reload. + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "r".to_string()).unwrap(); + assert_eq!( + wb2.get_sheet_by_idx(0).unwrap().get_charts()[0].chart_type, + "bubble" + ); +} + +#[test] +fn create_stock_of_pie_and_surface_charts() { + // The three kinds differ in shape, not just in name: stock's series are + // the price components, of-pie carries a split, and a surface needs three + // axes. Each has to survive creation, a save and a reload. + let mut wb = Workbook::default(); + let mut payloads: Vec = vec![]; + for row in 0..4usize { + for col in 0..4usize { + payloads.push(EditPayload::CellInput(CellInput { + sheet_idx: 0, + row, + col, + content: ((row + 1) * 10 + col).to_string(), + })); + } + } + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads, + undoable: true, + init: false, + })); + + let make = |wb: &mut Workbook, id: &str, ty: &str, series: Vec| { + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: id.to_string(), + chart_type: ty.to_string(), + from_row: 6, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 20, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: Some("Sheet1!$A$1:$A$4".to_string()), + series, + })], + undoable: true, + init: false, + })); + }; + let ser = |name: &str, col: char| CreateChartSeries { + name: Some(name.to_string()), + value_ref: format!("Sheet1!${}$1:${}$4", col, col), + color: None, + size_ref: None, + series_type: None, + }; + + make( + &mut wb, + "stock1", + "stock", + vec![ + ser("Open", 'A'), + ser("High", 'B'), + ser("Low", 'C'), + ser("Close", 'D'), + ], + ); + make(&mut wb, "ofpie1", "ofPie", vec![ser("Share", 'B')]); + make( + &mut wb, + "surf1", + "surface", + vec![ser("r1", 'B'), ser("r2", 'C'), ser("r3", 'D')], + ); + + let check = |wb: &Workbook| { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let charts = ws.get_charts(); + let by = |id: &str| { + charts + .iter() + .find(|c| c.chart_id == id) + .unwrap_or_else(|| { + panic!( + "{} missing, have {:?}", + id, + charts.iter().map(|c| &c.chart_id).collect::>() + ) + }) + .clone() + }; + let stock = by("stock1"); + assert_eq!(stock.chart_type, "stock"); + assert_eq!(stock.series.len(), 4, "all four price series"); + assert_eq!(stock.series[3].name.as_deref(), Some("Close")); + // Values are live like any other chart's. + assert_eq!(stock.series[0].values[0], Some(10.0)); + + let of_pie = by("ofpie1"); + assert_eq!(of_pie.chart_type, "ofPie"); + assert_eq!(of_pie.series.len(), 1, "of-pie plots one series"); + + let surface = by("surf1"); + assert_eq!(surface.chart_type, "surface"); + assert_eq!(surface.series.len(), 3, "one series per grid row"); + }; + check(&wb); + + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + check(&wb2); +} + +#[test] +fn update_chart_sets_the_of_pie_split() { + use crate::edit_action::OfPieSplitUpdate; + let mut wb = Workbook::default(); + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: (0..6usize) + .map(|r| { + EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: r, + col: 0, + content: (10 - r).to_string(), + }) + }) + .collect(), + undoable: true, + init: false, + })); + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: "op".to_string(), + chart_type: "ofPie".to_string(), + from_row: 8, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 20, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: None, + series: vec![CreateChartSeries { + name: None, + value_ref: "Sheet1!$A$1:$A$6".to_string(), + color: None, + size_ref: None, + series_type: None, + }], + })], + undoable: true, + init: false, + })); + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: "op".to_string(), + of_pie_split: Some(OfPieSplitUpdate { + by: Some("pos".to_string()), + pos: Some(2.0), + // Out of Excel's 5..=200 range, so it must be dropped. + second_size: Some(500.0), + }), + ..Default::default() + })], + undoable: true, + init: false, + })); + + let check = |wb: &Workbook| { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.of_pie_split.by.as_deref(), Some("pos")); + assert_eq!(c.of_pie_split.pos, Some(2.0)); + assert_eq!( + c.of_pie_split.second_size, None, + "out-of-range size dropped" + ); + }; + check(&wb); + + let bytes = wb.save().unwrap(); + check(&Workbook::from_file(&bytes, "r".to_string()).unwrap()); + + // And switching to a kind with no split leaves the chart valid. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: "op".to_string(), + chart_type: Some("surface".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + let ws = wb.get_sheet_by_idx(0).unwrap(); + assert_eq!(ws.get_charts()[0].chart_type, "surface"); +} + +#[test] +fn create_a_combo_chart_and_keep_it_through_edits() { + // A combo chart is one whose series disagree about their kind. The whole + // point is that an edit elsewhere must not collapse it back to one kind. + let mut wb = Workbook::default(); + let mut payloads: Vec = vec![]; + for row in 0..4usize { + for (col, base) in [(0usize, 100), (1, 20), (2, 3)] { + payloads.push(EditPayload::CellInput(CellInput { + sheet_idx: 0, + row, + col, + content: (base + row).to_string(), + })); + } + } + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads, + undoable: true, + init: false, + })); + + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: "combo".to_string(), + chart_type: "col".to_string(), + from_row: 6, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 20, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: None, + series: vec![ + CreateChartSeries { + name: Some("Revenue".to_string()), + value_ref: "Sheet1!$A$1:$A$4".to_string(), + color: None, + size_ref: None, + series_type: None, + }, + CreateChartSeries { + name: Some("Margin".to_string()), + value_ref: "Sheet1!$B$1:$B$4".to_string(), + color: None, + size_ref: None, + series_type: Some("line".to_string()), + }, + CreateChartSeries { + name: Some("Churn".to_string()), + value_ref: "Sheet1!$C$1:$C$4".to_string(), + color: None, + size_ref: None, + series_type: Some("area".to_string()), + }, + ], + })], + undoable: true, + init: false, + })); + + let check = |wb: &Workbook, note: &str| { + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.chart_type, "col", "{}", note); + assert_eq!(c.series.len(), 3, "{}", note); + assert_eq!(c.series[0].series_type, None, "{}: follows the chart", note); + assert_eq!( + c.series[1].series_type.as_deref(), + Some("line"), + "{}: line override", + note + ); + assert_eq!( + c.series[2].series_type.as_deref(), + Some("area"), + "{}: area override", + note + ); + // Values stay live in every group. + assert_eq!(c.series[1].values[0], Some(20.0), "{}", note); + }; + check(&wb, "after create"); + + // An unrelated edit regenerates the XML — the overrides must survive it. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: "combo".to_string(), + title: Some("Combo".to_string()), + ..Default::default() + })], + undoable: true, + init: false, + })); + check(&wb, "after an unrelated edit"); + + // So must a save/reload. + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + check(&wb2, "after reload"); + + // Re-pointing a series without restating its kind keeps the override. + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::UpdateChart(UpdateChart { + sheet_idx: 0, + chart_id: "combo".to_string(), + series: Some(vec![ + CreateChartSeries { + name: Some("Revenue".to_string()), + value_ref: "Sheet1!$A$1:$A$4".to_string(), + color: None, + size_ref: None, + series_type: None, + }, + CreateChartSeries { + name: Some("Margin".to_string()), + // A different range, same kind. + value_ref: "Sheet1!$B$2:$B$4".to_string(), + color: None, + size_ref: None, + series_type: None, + }, + ]), + ..Default::default() + })], + undoable: true, + init: false, + })); + let ws = wb.get_sheet_by_idx(0).unwrap(); + let c = &ws.get_charts()[0]; + assert_eq!(c.series.len(), 2); + assert_eq!( + c.series[1].series_type.as_deref(), + Some("line"), + "the slot's kind is kept when the caller does not restate it" + ); + assert_eq!(c.series[1].val_ref.as_deref(), Some("Sheet1!$B$2:$B$4")); +} + +#[test] +fn three_d_chart_types_round_trip_through_the_api() { + let mut wb = Workbook::default(); + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: (0..4usize) + .map(|r| { + EditPayload::CellInput(CellInput { + sheet_idx: 0, + row: r, + col: 0, + content: ((r + 1) * 5).to_string(), + }) + }) + .collect(), + undoable: true, + init: false, + })); + + for (i, ty) in ["col3d", "bar3d", "line3d", "area3d", "pie3d"] + .iter() + .enumerate() + { + wb.handle_action(EditAction::Payloads(PayloadsAction { + payloads: vec![EditPayload::CreateChart(CreateChart { + sheet_idx: 0, + chart_id: format!("c3d{}", i), + chart_type: ty.to_string(), + from_row: 6 + i * 2, + from_col: 0, + from_col_off: 0, + from_row_off: 0, + to_row: 20 + i * 2, + to_col: 8, + to_col_off: 0, + to_row_off: 0, + title: None, + categories_ref: None, + series: vec![CreateChartSeries { + name: Some(ty.to_string()), + value_ref: "Sheet1!$A$1:$A$4".to_string(), + color: None, + size_ref: None, + series_type: None, + }], + })], + undoable: true, + init: false, + })); + } + + let bytes = wb.save().unwrap(); + let wb2 = Workbook::from_file(&bytes, "reloaded".to_string()).unwrap(); + let ws = wb2.get_sheet_by_idx(0).unwrap(); + let charts = ws.get_charts(); + assert_eq!(charts.len(), 5); + let mut kinds: Vec<&str> = charts.iter().map(|c| c.chart_type.as_str()).collect(); + kinds.sort_unstable(); + assert_eq!(kinds, ["area3d", "bar3d", "col3d", "line3d", "pie3d"]); + // Values are live in the 3-D forms too. + assert_eq!( + charts[0].series[0].values, + vec![Some(5.0), Some(10.0), Some(15.0), Some(20.0)] + ); +} diff --git a/crates/controller/src/api/worksheet.rs b/crates/controller/src/api/worksheet.rs index e06281fc..5fa46001 100644 --- a/crates/controller/src/api/worksheet.rs +++ b/crates/controller/src/api/worksheet.rs @@ -8,7 +8,8 @@ use crate::controller::display::BlockSchemaRandomEntry; use crate::controller::display::BlockSchemaType; use crate::controller::display::{ BlockCellInfo, BlockDisplayInfo, BlockInfo, CellCoordinate, CellImageInfo, CellPosition, - ChartInfo, ChartSeriesInfo, DisplayWindow, DisplayWindowWithStartPoint, LinkInfo, + ChartDataLabelsInfo, ChartInfo, ChartOfPieSplitInfo, ChartSeriesInfo, DisplayWindow, + DisplayWindowWithStartPoint, LinkInfo, }; use crate::errors::Result; use crate::exclusive::AppendixWithCell; @@ -574,6 +575,106 @@ impl<'a> Worksheet<'a> { Some(out) } + /// Resolve a category reference to the labels the sheet displays — numbers + /// go through their cell's number format, so a date axis reads as dates. + /// Returns `None` when the reference is unusable or resolves to nothing but + /// blanks, so the caller can keep the labels cached in the file. + fn resolve_category_labels(&self, cat_ref: &str) -> Option> { + let (sheet_name, sr, sc, er, ec) = Self::parse_a1_range(cat_ref)?; + let sheet_id = self.ref_sheet_id(sheet_name.as_deref())?; + let mut out = Vec::new(); + for row in sr..=er { + for col in sc..=ec { + out.push(self.cell_label(sheet_id, row, col)); + } + } + if out.iter().all(|l| l.is_empty()) { + return None; + } + Some(out) + } + + /// The number format of a reference's first cell. This is Excel's "linked + /// to source" behavior for value labels and axis ticks. `None` for General + /// or an unresolvable reference. + fn resolve_ref_num_fmt(&self, val_ref: &str) -> Option { + let (sheet_name, sr, sc, _, _) = Self::parse_a1_range(val_ref)?; + let sheet_id = self.ref_sheet_id(sheet_name.as_deref())?; + let cell_id = self + .controller + .status + .navigator + .fetch_cell_id(&sheet_id, sr, sc) + .ok()?; + let cell = self + .controller + .status + .container + .get_cell(sheet_id, &cell_id)?; + let fmt = self + .controller + .status + .style_manager + .get_style(cell.style) + .formatter; + if fmt.is_empty() || fmt.eq_ignore_ascii_case("general") { + None + } else { + Some(fmt) + } + } + + /// The sheet a chart reference points at: the named one, or this sheet + /// when the reference carries no sheet name. + fn ref_sheet_id(&self, sheet_name: Option<&str>) -> Option { + match sheet_name { + Some(name) => self + .controller + .status + .sheet_id_manager + .get_id(name) + .copied(), + None => Some(self.sheet_id), + } + } + + /// A cell rendered as chart-label text: numbers through their own number + /// format, everything else as its plain text. Missing cells are empty. + fn cell_label(&self, sheet_id: SheetId, row: usize, col: usize) -> String { + let cell = self + .controller + .status + .navigator + .fetch_cell_id(&sheet_id, row, col) + .ok() + .and_then(|cid| self.controller.status.container.get_cell(sheet_id, &cid)); + let Some(cell) = cell else { + return String::new(); + }; + match &cell.value { + logisheets_base::CellValue::Blank => String::new(), + logisheets_base::CellValue::Boolean(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + logisheets_base::CellValue::Error(e) => e.to_string(), + logisheets_base::CellValue::String(t) => self + .controller + .status + .text_id_manager + .get_string(t) + .unwrap_or_default(), + logisheets_base::CellValue::InlineStr(rst) => rst.plain_text(), + logisheets_base::CellValue::FormulaStr(t) => t.clone(), + logisheets_base::CellValue::Number(n) => { + let fmt = self + .controller + .status + .style_manager + .get_style(cell.style) + .formatter; + format_with(Some(fmt.as_str()), *n) + } + } + } + /// Parse an A1 reference `[Sheet!]$C$R[:$C$R]` into /// `(sheet, start_row, start_col, end_row, end_col)` (0-based, normalized). fn parse_a1_range(s: &str) -> Option<(Option, usize, usize, usize, usize)> { @@ -632,6 +733,18 @@ impl<'a> Worksheet<'a> { ChartType::Pie => "pie", ChartType::Doughnut => "doughnut", ChartType::Scatter => "scatter", + ChartType::Radar => "radar", + ChartType::Bubble => "bubble", + ChartType::Stock => "stock", + ChartType::OfPie => "ofPie", + ChartType::BarOfPie => "barOfPie", + ChartType::Surface => "surface", + ChartType::Surface3d => "surface3d", + ChartType::Col3d => "col3d", + ChartType::Bar3d => "bar3d", + ChartType::Line3d => "line3d", + ChartType::Area3d => "area3d", + ChartType::Pie3d => "pie3d", } .to_string() }; @@ -650,23 +763,21 @@ impl<'a> Worksheet<'a> { .status .chart_manager .charts_of_sheet(self.sheet_id) - .into_iter() .filter_map(|chart| { let (from_row, from_col) = nav.fetch_cell_idx(&self.sheet_id, &chart.from.cell).ok()?; // A size-anchored chart has no second cell; report the `from` // cell again and hand the size over separately rather than // inventing a corner. - let (to_row, to_col, to_col_off, to_row_off, ext_cx, ext_cy) = - match &chart.extent { - crate::chart_manager::ChartExtent::ToCell(m) => { - let (r, c) = nav.fetch_cell_idx(&self.sheet_id, &m.cell).ok()?; - (r, c, m.col_off, m.row_off, None, None) - } - crate::chart_manager::ChartExtent::Size { cx, cy } => { - (from_row, from_col, 0, 0, Some(*cx), Some(*cy)) - } - }; + let (to_row, to_col, to_col_off, to_row_off, ext_cx, ext_cy) = match &chart.extent { + crate::chart_manager::ChartExtent::ToCell(m) => { + let (r, c) = nav.fetch_cell_idx(&self.sheet_id, &m.cell).ok()?; + (r, c, m.col_off, m.row_off, None, None) + } + crate::chart_manager::ChartExtent::Size { cx, cy } => { + (from_row, from_col, 0, 0, Some(*cx), Some(*cy)) + } + }; let d = &chart.data; Some(ChartInfo { chart_id: chart.id.clone(), @@ -684,25 +795,84 @@ impl<'a> Worksheet<'a> { stacked: d.stacked, title: d.title.clone(), legend_pos: d.legend_pos.as_ref().map(legend_pos_str), - categories: d.categories.clone(), + // Labels follow the sheet like values do; the file's cache + // is the fallback. + categories: d + .cat_ref + .as_deref() + .and_then(|r| self.resolve_category_labels(r)) + .unwrap_or_else(|| d.categories.clone()), + cat_ref: d.cat_ref.clone(), series: d .series .iter() - .map(|s| ChartSeriesInfo { - name: s.name.clone(), + .map(|s| { // Live values re-read from the source range so the // chart reflects edits; fall back to the OOXML cache // if the reference can't be resolved. - values: s + let values = s .val_ref .as_deref() .and_then(|r| self.resolve_series_values(r)) - .unwrap_or_else(|| s.cached_values.clone()), - color: s.color.as_ref().and_then(|c| self.resolve_series_color(c)), + .unwrap_or_else(|| s.cached_values.clone()); + // The source cells' format wins over the cached + // one, so reformatting the data reformats the + // chart. An explicit label format beats both. + let num_fmt = s + .val_ref + .as_deref() + .and_then(|r| self.resolve_ref_num_fmt(r)) + .or_else(|| s.format_code.clone()); + let label_fmt = d.data_labels.num_fmt.as_ref().or(num_fmt.as_ref()); + let formatted_values = values + .iter() + .map(|v| v.map(|n| format_with(label_fmt.map(|f| f.as_str()), n))) + .collect(); + ChartSeriesInfo { + name: s.name.clone(), + values, + formatted_values, + // A bubble chart's third dimension, live like + // the values are. + sizes: s + .size_ref + .as_deref() + .and_then(|r| self.resolve_series_values(r)) + .unwrap_or_else(|| s.cached_sizes.clone()), + size_ref: s.size_ref.clone(), + color: s.color.as_ref().and_then(|c| self.resolve_series_color(c)), + val_ref: s.val_ref.clone(), + // Set only on a combo chart's overridden + // series; `None` means it follows the chart. + series_type: s.series_type.as_ref().map(chart_type_str), + num_fmt, + } }) .collect(), cat_axis_title: d.cat_axis_title.clone(), val_axis_title: d.val_axis_title.clone(), + data_labels: ChartDataLabelsInfo { + show_value: d.data_labels.show_value, + show_category: d.data_labels.show_category, + show_series: d.data_labels.show_series, + show_percent: d.data_labels.show_percent, + show_legend_key: d.data_labels.show_legend_key, + position: d.data_labels.position.clone(), + num_fmt: d.data_labels.num_fmt.clone(), + }, + of_pie_split: ChartOfPieSplitInfo { + by: d.of_pie_split.by.clone(), + pos: d.of_pie_split.pos, + second_size: d.of_pie_split.second_size, + }, + val_axis_scale: axis_scale_info(&d.val_axis_scale), + cat_axis_scale: axis_scale_info(&d.cat_axis_scale), + val_axis_num_fmt: d.val_axis_num_fmt.clone().or_else(|| { + d.series + .first() + .and_then(|s| s.val_ref.as_deref()) + .and_then(|r| self.resolve_ref_num_fmt(r)) + }), }) }) .collect() @@ -2101,7 +2271,10 @@ impl<'a> Worksheet<'a> { Vertex::Block(s, b, _) | Vertex::BlockKey(s, b) | Vertex::BlockAll(s, b) => (*s, *b), _ => return None, }; - let bp = status.navigator.get_block_place(&sheet_id, &block_id).ok()?; + let bp = status + .navigator + .get_block_place(&sheet_id, &block_id) + .ok()?; let (r0, c0) = status .navigator .fetch_normal_cell_idx(&sheet_id, &bp.master) @@ -2121,12 +2294,7 @@ impl<'a> Worksheet<'a> { (Vertex::Block(_, _, field_id), Some(key)) => status .block_schema_manager .partially_resolve_by_field_id(sheet_id, block_id, key, *field_id) - .and_then(|cell| { - status - .navigator - .fetch_block_cell_idx(&sheet_id, &cell) - .ok() - }) + .and_then(|cell| status.navigator.fetch_block_cell_idx(&sheet_id, &cell).ok()) .map(|(_, c)| c), (Vertex::BlockKey(_, _), Some(key)) => status .navigator @@ -2806,3 +2974,29 @@ mod boundary_tests { assert_eq!(boundary_1d(0, &occ(&[]), &blocks, true), Some(9)); } } + +/// Render a number with an Excel number-format code, falling back to the plain +/// JavaScript-style representation for General / unsupported codes. Shared by +/// chart category labels and data labels so both read like the sheet. +fn format_with(fmt: Option<&str>, n: f64) -> String { + match fmt { + Some(f) if !f.is_empty() && !f.eq_ignore_ascii_case("general") => { + ssf_rs::format(f, &ssf_rs::Value::Num(n), false) + .unwrap_or_else(|_| ssf_rs::jsnum::to_string_js(n)) + } + _ => ssf_rs::jsnum::to_string_js(n), + } +} + +fn axis_scale_info( + s: &logisheets_workbook::prelude::AxisScale, +) -> crate::controller::display::ChartAxisScaleInfo { + crate::controller::display::ChartAxisScaleInfo { + min: s.min, + max: s.max, + log_base: s.log_base, + reversed: s.reversed, + major_unit: s.major_unit, + minor_unit: s.minor_unit, + } +} diff --git a/crates/controller/src/chart_manager/executor.rs b/crates/controller/src/chart_manager/executor.rs index bbecdc18..af27ec9a 100644 --- a/crates/controller/src/chart_manager/executor.rs +++ b/crates/controller/src/chart_manager/executor.rs @@ -5,7 +5,8 @@ use logisheets_base::{ id_fetcher::{IdFetcherTrait, SheetIdFetcherByIdxTrait}, }; use logisheets_workbook::prelude::{ - ChartType, NewChartSeries, PassthroughPart, build_chart_xml, parse_chart, + AxisScale, ChartData, ChartSeries, ChartType, LegendPos, OfPieSplit, PassthroughPart, + SeriesColor, build_chart_xml, parse_chart, }; use crate::{Error, edit_action::EditPayload}; @@ -67,21 +68,14 @@ impl ChartExecutor { let from_cell = ctx.fetch_cell_id(&sheet_id, p.from_row, p.from_col)?; let to_cell = ctx.fetch_cell_id(&sheet_id, p.to_row, p.to_col)?; let chart_type = chart_type_from_str(&p.chart_type); - let series: Vec = p - .series - .iter() - .map(|s| NewChartSeries { - name: s.name.clone(), - value_ref: s.value_ref.clone(), - }) - .collect(); - let xml = build_chart_xml( - &chart_type, - p.title.as_deref(), - p.categories_ref.as_deref(), - &series, + let series = p.series.iter().map(new_series).collect(); + let spec = ChartData::new( + chart_type, + p.title.clone(), + p.categories_ref.clone(), + series, ); - let bytes = xml.into_bytes(); + let bytes = build_chart_xml(&spec).into_bytes(); let data = match parse_chart(&bytes) { Some(d) => d, None => return Ok((self, false)), @@ -120,51 +114,137 @@ impl ChartExecutor { .map_err(BasicError::SheetIdxExceed)?; // Read the existing chart's data to keep refs/anchor while // re-generating with the new type/title. - let existing = match self + // Cloned because the chart is replaced further down, which + // needs `self.manager` mutably. One chart, not the sheet's. + let existing = self .manager .charts_of_sheet(sheet_id) - .into_iter() .find(|c| c.id == p.chart_id) - { - Some(c) => c, - None => return Ok((self, false)), - }; - let new_type = match &p.chart_type { - Some(s) => chart_type_from_str(s), - None => existing.data.chart_type.clone(), - }; - let title = match &p.title { - Some(t) => Some(t.clone()), - None => existing.data.title.clone(), + .cloned(); + let Some(existing) = existing else { + return Ok((self, false)); }; - let series: Vec = existing - .data - .series - .iter() - .filter_map(|s| { - s.val_ref.clone().map(|vr| NewChartSeries { - name: s.name.clone(), - value_ref: vr, + let mut spec = existing.data.clone(); + if let Some(t) = &p.chart_type { + spec.chart_type = chart_type_from_str(t); + } + if let Some(t) = &p.title { + spec.title = non_empty(t); + } + if let Some(l) = &p.legend_pos { + spec.legend_pos = legend_pos_from_str(l); + } + if let Some(v) = p.stacked { + spec.stacked = v; + } + if let Some(t) = &p.cat_axis_title { + spec.cat_axis_title = non_empty(t); + } + if let Some(t) = &p.val_axis_title { + spec.val_axis_title = non_empty(t); + } + if let Some(v) = p.show_data_labels { + spec.data_labels.show_value = v; + } + if let Some(v) = p.show_category_labels { + spec.data_labels.show_category = v; + } + if let Some(v) = p.show_series_labels { + spec.data_labels.show_series = v; + } + if let Some(v) = p.show_percent_labels { + spec.data_labels.show_percent = v; + } + if let Some(pos) = &p.data_label_position { + spec.data_labels.position = non_empty(pos); + } + if let Some(fmt) = &p.num_fmt { + let fmt = non_empty(fmt); + spec.val_axis_num_fmt = fmt.clone(); + spec.data_labels.num_fmt = fmt; + } + if let Some(r) = &p.categories_ref { + spec.cat_ref = non_empty(r); + } + if let Some(sc) = &p.val_axis_scale { + spec.val_axis_scale = axis_scale(sc); + } + if let Some(sc) = &p.cat_axis_scale { + spec.cat_axis_scale = axis_scale(sc); + } + if let Some(sp) = &p.of_pie_split { + spec.of_pie_split = OfPieSplit { + by: sp.by.as_ref().and_then(|b| non_empty(b)), + pos: sp.pos.filter(|v| *v > 0.0), + // Excel clamps the second plot to 5..=200% of the first. + second_size: sp.second_size.filter(|v| (5.0..=200.0).contains(v)), + }; + } + if let Some(series) = &p.series { + let previous = std::mem::take(&mut spec.series); + spec.series = series + .iter() + .enumerate() + .map(|(i, s)| { + let mut ns = new_series(s); + let old = previous.get(i); + // A caller editing a non-bubble chart has no size + // field to send, so keep the one this slot had — + // switching to bubble and back must not lose it. + if ns.size_ref.is_none() { + ns.size_ref = old.and_then(|o| o.size_ref.clone()); + } + // Same for the combo override: a caller editing + // something else does not restate it. + if ns.series_type.is_none() { + ns.series_type = old.and_then(|o| o.series_type.clone()); + } + match &ns.color { + // An explicit color replaces the series' whole + // shape properties: the authored ones describe + // a fill we are overriding, so keeping them + // would keep the old color. + Some(_) => {} + // Otherwise carry the previous slot's look over + // whole, so re-pointing a range or renaming a + // series does not reset the chart's palette. + None => { + ns.color = old.and_then(|o| o.color.clone()); + if let Some(o) = old { + ns.preserved = o.preserved.clone(); + } + } + } + ns }) - }) - .collect(); - let xml = build_chart_xml( - &new_type, - title.as_deref(), - existing.data.cat_ref.as_deref(), - &series, - ); - let bytes = xml.into_bytes(); + .collect(); + } + let bytes = build_chart_xml(&spec).into_bytes(); let data = match parse_chart(&bytes) { Some(d) => d, None => return Ok((self, false)), }; - let raw = Arc::new(vec![PassthroughPart { - path: existing.part_path.clone(), - data: bytes, - rtype: logisheets_workbook::rtypes::CHART, - rels: vec![], - }]); + // Only the chart part itself is regenerated. Its relationships + // and the satellite parts Excel writes alongside it (style, + // colors) are carried over untouched — dropping them would + // restyle the chart in Excel and break any reference the chart + // makes to another part. + let raw = Arc::new( + existing + .raw + .iter() + .map(|part| { + if part.path == existing.part_path { + PassthroughPart { + data: bytes.clone(), + ..part.clone() + } + } else { + part.clone() + } + }) + .collect::>(), + ); let changed = self .manager .update_content(sheet_id, &p.chart_id, data, raw); @@ -175,6 +255,58 @@ impl ChartExecutor { } } +/// A payload series → the workbook model. An explicit color is taken as a +/// literal RGB hex (what a color picker produces); theme-scheme colors only +/// come from the file. +fn new_series(s: &crate::edit_action::CreateChartSeries) -> ChartSeries { + let mut out = ChartSeries::new(s.name.clone(), s.value_ref.clone()); + out.size_ref = s.size_ref.as_ref().and_then(|r| non_empty(r)); + out.series_type = s + .series_type + .as_ref() + .and_then(|t| non_empty(t)) + .map(|t| chart_type_from_str(&t)); + out.color = s + .color + .as_ref() + .and_then(|c| non_empty(c)) + .map(|c| SeriesColor::Srgb(c.trim_start_matches('#').to_string())); + out +} + +/// Payload text fields use the empty string to mean "clear it". +fn non_empty(s: &str) -> Option { + if s.trim().is_empty() { + None + } else { + Some(s.to_string()) + } +} + +fn axis_scale(u: &crate::edit_action::AxisScaleUpdate) -> AxisScale { + AxisScale { + min: u.min, + max: u.max, + // Excel rejects a log base outside 2..=1000; treat anything else as + // linear rather than writing a file Excel will refuse to open. + log_base: u.log_base.filter(|b| (2.0..=1000.0).contains(b)), + reversed: u.reversed, + major_unit: u.major_unit.filter(|v| *v > 0.0), + minor_unit: u.minor_unit.filter(|v| *v > 0.0), + } +} + +fn legend_pos_from_str(s: &str) -> Option { + match s { + "top" => Some(LegendPos::Top), + "bottom" => Some(LegendPos::Bottom), + "left" => Some(LegendPos::Left), + "right" => Some(LegendPos::Right), + // "none" (and anything unrecognized) hides the legend. + _ => None, + } +} + fn chart_type_from_str(s: &str) -> ChartType { match s { "bar" => ChartType::Bar, @@ -183,6 +315,18 @@ fn chart_type_from_str(s: &str) -> ChartType { "pie" => ChartType::Pie, "doughnut" => ChartType::Doughnut, "scatter" => ChartType::Scatter, + "radar" => ChartType::Radar, + "bubble" => ChartType::Bubble, + "stock" => ChartType::Stock, + "ofPie" => ChartType::OfPie, + "barOfPie" => ChartType::BarOfPie, + "surface" => ChartType::Surface, + "surface3d" => ChartType::Surface3d, + "col3d" => ChartType::Col3d, + "bar3d" => ChartType::Bar3d, + "line3d" => ChartType::Line3d, + "area3d" => ChartType::Area3d, + "pie3d" => ChartType::Pie3d, _ => ChartType::Col, } } diff --git a/crates/controller/src/chart_manager/mod.rs b/crates/controller/src/chart_manager/mod.rs index d5199726..aef4e1e3 100644 --- a/crates/controller/src/chart_manager/mod.rs +++ b/crates/controller/src/chart_manager/mod.rs @@ -76,12 +76,16 @@ impl ChartManager { self.charts.insert(sheet_id, v); } - /// All charts on a sheet, in stored order. - pub fn charts_of_sheet(&self, sheet_id: SheetId) -> Vec { + /// All charts on a sheet, in stored order, borrowed. + /// + /// Deliberately not `Vec`: a `Chart` carries its parsed data and + /// every preserved XML subtree, so cloning the list is far from free — and + /// the read paths (rendering, saving, looking one up) only need a look. + pub fn charts_of_sheet(&self, sheet_id: SheetId) -> impl Iterator { self.charts .get(&sheet_id) - .map(|v| v.iter().cloned().collect()) - .unwrap_or_default() + .into_iter() + .flat_map(|v| v.iter()) } /// Re-anchor the chart with `chart_id` on `sheet_id`. Returns whether a diff --git a/crates/controller/src/controller/display.rs b/crates/controller/src/controller/display.rs index 92a87b91..29fe4676 100644 --- a/crates/controller/src/controller/display.rs +++ b/crates/controller/src/controller/display.rs @@ -252,12 +252,73 @@ pub struct ChartSeriesInfo { /// Resolved fill color as an RGB/ARGB hex (no `#`), or `None` to use the /// renderer's default palette. Scheme colors are resolved against the theme. pub color: Option, + /// `values` rendered with the label number format — the exact strings a + /// data label should show. The renderer cannot do this itself: Excel + /// number formats are evaluated by the core, not the host. + pub formatted_values: Vec>, + /// Bubble sizes, live from `size_ref` — a bubble chart's third dimension. + /// Empty for every other chart kind. + pub sizes: Vec>, + /// The bubble-size source range, e.g. `Sheet1!$D$2:$D$6`. + pub size_ref: Option, + /// The kind this series is drawn as when it differs from the chart's own — + /// what makes a combo chart. `None` means it follows `chart_type`. + pub series_type: Option, + /// The series' source range, e.g. `Sheet1!$B$2:$E$2`. This is what an + /// editor shows and rewrites when the user re-points the series. + pub val_ref: Option, + /// Excel number-format code for this series' values, taken from the source + /// cells (Excel's "linked to source"), falling back to the format the + /// producer cached. `None` means render the raw number. + pub num_fmt: Option, +} + +/// An axis' scale as it will be drawn. All-`None`/false is a fully automatic +/// axis, which is what most charts have. +#[derive(Debug, Clone, TS)] +#[ts(file_name = "chart_axis_scale_info.ts", rename_all = "camelCase")] +pub struct ChartAxisScaleInfo { + pub min: Option, + pub max: Option, + pub log_base: Option, + pub reversed: bool, + pub major_unit: Option, + pub minor_unit: Option, +} + +/// How a pie-of-pie / bar-of-pie divides its series between the two plots. +/// Empty for every other chart kind. +#[derive(Debug, Clone, TS)] +#[ts(file_name = "chart_of_pie_split_info.ts", rename_all = "camelCase")] +pub struct ChartOfPieSplitInfo { + /// `auto | cust | percent | pos | val`. + pub by: Option, + /// Read according to `by`: a count of trailing points, a value threshold, + /// or a percentage. + pub pos: Option, + /// The second plot's size as a percentage of the first. + pub second_size: Option, +} + +/// What a chart draws next to each data point. All-false means no labels. +#[derive(Debug, Clone, TS)] +#[ts(file_name = "chart_data_labels_info.ts", rename_all = "camelCase")] +pub struct ChartDataLabelsInfo { + pub show_value: bool, + pub show_category: bool, + pub show_series: bool, + pub show_percent: bool, + pub show_legend_key: bool, + /// `ctr|inEnd|outEnd|inBase|bestFit`, or `None` for the renderer's default. + pub position: Option, + /// Number-format code for the label's value; overrides the series format. + pub num_fmt: Option, } /// A chart anchored on a sheet, resolved for rendering. The anchor is its /// from/to cell positions plus EMU offsets into those cells; `chart_type` is -/// one of `col|bar|line|area|pie|doughnut|scatter`; `legend_pos` (if any) is -/// `top|bottom|left|right`. +/// one of `col|bar|line|area|pie|doughnut|scatter|radar|bubble|stock|ofPie| +/// barOfPie|surface|surface3d`; `legend_pos` (if any) is `top|bottom|left|right`. #[derive(Debug, Clone, TS)] #[ts(file_name = "chart_info.ts", rename_all = "camelCase")] pub struct ChartInfo { @@ -280,10 +341,22 @@ pub struct ChartInfo { pub stacked: bool, pub title: Option, pub legend_pos: Option, + /// Category labels, read live from `cat_ref` and formatted the way the + /// source cells display (so dates read as dates), falling back to the + /// labels cached in the file. pub categories: Vec, + /// The category (X) source range, e.g. `Sheet1!$A$2:$A$5`. + pub cat_ref: Option, pub series: Vec, pub cat_axis_title: Option, pub val_axis_title: Option, + pub data_labels: ChartDataLabelsInfo, + pub of_pie_split: ChartOfPieSplitInfo, + pub val_axis_scale: ChartAxisScaleInfo, + pub cat_axis_scale: ChartAxisScaleInfo, + /// Number-format code for the value axis, live from the first series' + /// source cells when the chart does not set one itself. + pub val_axis_num_fmt: Option, } /// A person referenced by a comment (author or mention). Enterprise builds diff --git a/crates/controller/src/edit_action/mod.rs b/crates/controller/src/edit_action/mod.rs index 9e87d401..79602ed9 100644 --- a/crates/controller/src/edit_action/mod.rs +++ b/crates/controller/src/edit_action/mod.rs @@ -343,20 +343,90 @@ pub struct DeleteChart { pub chart_id: String, } -/// Reconfigure an existing chart in place, keeping its anchor and data -/// references. Any field left `None` keeps the chart's current value. -/// `chart_type` is one of `col|bar|line|area|pie|doughnut|scatter`. -#[derive(Debug, Clone, TS)] +/// Reconfigure an existing chart in place, keeping its anchor. Any field left +/// `None` keeps the chart's current value; an empty string clears a text field +/// (title, axis title, number format). +/// +/// `chart_type` is one of `col|bar|line|area|pie|doughnut|scatter|radar| +/// bubble|stock|ofPie|barOfPie|surface|surface3d`; `legend_pos` is +/// `top|bottom|left|right|none`. +#[derive(Debug, Clone, Default, TS)] #[ts(file_name = "update_chart.ts", builder, rename_all = "camelCase")] pub struct UpdateChart { pub sheet_idx: usize, pub chart_id: String, pub chart_type: Option, pub title: Option, -} - -/// One series for [`CreateChart`]: an optional name and a value reference -/// formula (e.g. `Sheet1!$B$2:$E$2`). + pub legend_pos: Option, + /// Stack the series (bar/column, line, area). Ignored by pie and scatter. + pub stacked: Option, + pub cat_axis_title: Option, + pub val_axis_title: Option, + /// Show the value next to each data point. + pub show_data_labels: Option, + /// Also show the category name / series name / percentage in the label. + pub show_category_labels: Option, + pub show_series_labels: Option, + pub show_percent_labels: Option, + /// Where the label sits: `ctr|inEnd|outEnd|inBase|bestFit`. + pub data_label_position: Option, + /// Excel number-format code applied to the value axis and to data labels + /// (e.g. `#,##0.00`, `0%`). Empty clears it, falling back to the source + /// cells' own format. + pub num_fmt: Option, + /// Replace the category (X) reference, e.g. `Sheet1!$A$2:$A$5`. + pub categories_ref: Option, + /// Replace the whole series list. Colors of series that keep their position + /// are preserved when the new entry does not name one. + pub series: Option>, + /// Replace the value axis' scale wholesale. Unlike the other fields this + /// is all-or-nothing: sending it sets every part of the scale, so a `None` + /// inside means "auto" rather than "keep". That is the only way to clear a + /// fixed minimum back to automatic. + pub val_axis_scale: Option, + /// The same for the category (X) axis. + pub cat_axis_scale: Option, + /// Replace how a pie-of-pie / bar-of-pie splits its series. Like the axis + /// scales this is all-or-nothing rather than a patch. + pub of_pie_split: Option, +} + +/// The division between an of-pie chart's two plots. +#[derive(Debug, Clone, Default, TS)] +#[ts( + file_name = "of_pie_split_update.ts", + builder, + rename_all = "camelCase" +)] +pub struct OfPieSplitUpdate { + /// `auto | cust | percent | pos | val`. Anything else is treated as `auto`. + pub by: Option, + /// Read according to `by`: a count of trailing points for `pos`, a + /// threshold for `val`, a percentage for `percent`. + pub pos: Option, + /// The second plot's size, as a percentage of the first (Excel: 5..=200). + pub second_size: Option, +} + +/// An axis' scale. Every field `None`/`false` is a fully automatic axis, which +/// is Excel's default. +#[derive(Debug, Clone, Default, TS)] +#[ts(file_name = "axis_scale_update.ts", builder, rename_all = "camelCase")] +pub struct AxisScaleUpdate { + pub min: Option, + pub max: Option, + /// Log scale base (Excel allows 2..=1000). `None` is a linear axis. + pub log_base: Option, + /// Draw the axis in the opposite direction. + pub reversed: bool, + /// Spacing between major ticks / gridlines. `None` is automatic. + pub major_unit: Option, + pub minor_unit: Option, +} + +/// One series for [`CreateChart`] / [`UpdateChart`]: an optional name, the +/// value reference formula (e.g. `Sheet1!$B$2:$E$2`) and an optional explicit +/// fill color as an RGB hex string (`"4472C4"`). #[derive(Debug, Clone, TS)] #[ts( file_name = "create_chart_series.ts", @@ -366,10 +436,19 @@ pub struct UpdateChart { pub struct CreateChartSeries { pub name: Option, pub value_ref: String, + pub color: Option, + /// Bubble sizes for this series (`Sheet1!$D$2:$D$6`). Only a bubble chart + /// reads it; other kinds keep it in the model but never draw it. + pub size_ref: Option, + /// Draw this one series as a different kind — `col|bar|line|area` — which + /// is how a combo chart is expressed. `None` follows the chart's own type. + /// An override the chart cannot combine with is ignored. + pub series_type: Option, } /// Create a new chart anchored at `from`..`to`. `chart_type` is one of -/// `col|bar|line|area|pie|doughnut|scatter`. `chart_id` must be workbook-unique +/// `col|bar|line|area|pie|doughnut|scatter|radar|bubble|stock|ofPie|barOfPie| +/// surface|surface3d`. `chart_id` must be workbook-unique /// (the caller generates it; it also names the chart part). Series values are /// read live from the referenced ranges, so no cached values are needed here. #[derive(Debug, Clone, TS)] diff --git a/crates/workbook/src/lib.rs b/crates/workbook/src/lib.rs index 49757c9c..d05b9578 100644 --- a/crates/workbook/src/lib.rs +++ b/crates/workbook/src/lib.rs @@ -12,8 +12,8 @@ use xmlserde::*; pub mod prelude { pub use super::SerdeErr; pub use super::ooxml::chart::{ - ChartData, ChartSeries, ChartType, LegendPos, NewChartSeries, SeriesColor, build_chart_xml, - parse_chart, + AxisScale, ChartData, ChartSeries, ChartType, DataLabels, LegendPos, OfPieSplit, + SeriesColor, build_chart_xml, parse_chart, }; pub use super::ooxml::comments::*; pub use super::ooxml::complex_types::*; @@ -40,13 +40,13 @@ pub mod prelude { pub use super::ooxml::workbook::*; pub use super::ooxml::worksheet::*; pub use super::reader::*; - pub use super::workbook::{ChartAnchor, ChartAnchorExtent}; pub use super::workbook::Media; pub use super::workbook::PassthroughPart; pub use super::workbook::Wb; pub use super::workbook::Worksheet; pub use super::workbook::WorksheetDrawing; pub use super::workbook::Xl; + pub use super::workbook::{ChartAnchor, ChartAnchorExtent}; pub use super::writer::*; } diff --git a/crates/workbook/src/ooxml/chart.rs b/crates/workbook/src/ooxml/chart.rs index fc964dc7..a7e8b7df 100644 --- a/crates/workbook/src/ooxml/chart.rs +++ b/crates/workbook/src/ooxml/chart.rs @@ -1,18 +1,26 @@ -//! Structured, parse-only view of a chart part (`xl/charts/chartN.xml`). +//! Structured view of a chart part (`xl/charts/chartN.xml`). //! -//! This models just enough of `c:chartSpace` to render a chart: its type, the -//! data-series references and their cached values, the title, legend position, -//! and axis titles. It is deliberately NOT round-trip: the raw bytes kept in -//! `PassthroughPart` remain the source of truth for saving, so this model only -//! needs to be read. Every field is therefore `Option`/`Vec` — xmlserde panics -//! on a missing *required* child/attr, and unmapped children are simply dropped -//! (which is fine, we never re-serialize this). +//! Two things live here: [`parse_chart`], which reads `c:chartSpace` into a +//! render-ready [`ChartData`], and [`build_chart_xml`], which writes a +//! `ChartData` back out. Editing a chart goes parse → patch → build, so the two +//! must agree: **anything this model does not carry is lost the moment a user +//! edits the chart.** +//! +//! That is why the model has two halves. Everything the editor understands is +//! typed (chart kind, series, labels, axis scale …). Everything else — fills, +//! fonts, gridlines, 3-D settings, markers, trendlines — is captured verbatim +//! as [`Unparsed`] subtrees in [`PreservedXml`] and re-emitted untouched, so a +//! chart styled in Excel keeps its styling through an edit here. Saving an +//! *unedited* chart does not go through this at all: the original bytes in +//! `PassthroughPart` are written back as-is. //! //! Matching is by literal `c:`/`a:` prefixes, which is what Excel/WPS emit. A //! producer using different prefixes or a default namespace would not parse; //! that is an accepted limitation for now. use crate::xml_deserialize_from_str; +use xmlserde::quick_xml; +use xmlserde::{Unparsed, XmlSerialize}; use xmlserde_derives::XmlDeserialize; // --------------------------------------------------------------------------- @@ -28,6 +36,122 @@ pub enum ChartType { Pie, Doughnut, Scatter, + /// A spider/star chart: one spoke per category, plotted on a value axis. + Radar, + /// Scatter with a third dimension — each point sized by `c:bubbleSize`. + Bubble, + /// Open/high/low/close. The series *are* the price components: 4 of them + /// is OHLC, 3 is high-low-close. + Stock, + /// Pie of pie: one series split across a main pie and a second pie that + /// breaks down the remainder. + OfPie, + /// Bar of pie — the same split, with the second plot drawn as a bar. + BarOfPie, + /// A value surface over a category × series grid. + Surface, + /// The 3-D form of the same thing. + Surface3d, + /// The 3-D forms of the ordinary kinds. They differ from their flat + /// siblings only in the element name, a depth axis (except the pie) and a + /// couple of depth settings — the series are identical, which is why they + /// share the flat kinds' code paths and render flat. + Col3d, + Bar3d, + Line3d, + Area3d, + Pie3d, +} + +impl ChartType { + /// Whether this kind plots a category axis against a value axis. Pie-like + /// and XY kinds do not. + fn is_cartesian(&self) -> bool { + matches!( + self, + ChartType::Col + | ChartType::Bar + | ChartType::Line + | ChartType::Area + | ChartType::Radar + | ChartType::Stock + | ChartType::Surface + | ChartType::Surface3d + | ChartType::Col3d + | ChartType::Bar3d + | ChartType::Line3d + | ChartType::Area3d + ) + } + + fn is_surface(&self) -> bool { + matches!(self, ChartType::Surface | ChartType::Surface3d) + } + + /// Written as a `*3DChart` element. + fn is_3d(&self) -> bool { + matches!( + self, + ChartType::Col3d + | ChartType::Bar3d + | ChartType::Line3d + | ChartType::Area3d + | ChartType::Pie3d + | ChartType::Surface3d + ) + } + + /// Kinds that plot into a depth dimension carry a third (`c:serAx`) axis + /// alongside the usual pair. Omitting it makes the file unopenable. + fn needs_series_axis(&self) -> bool { + self.is_surface() + || matches!( + self, + ChartType::Col3d | ChartType::Bar3d | ChartType::Line3d | ChartType::Area3d + ) + } + + fn is_of_pie(&self) -> bool { + matches!(self, ChartType::OfPie | ChartType::BarOfPie) + } + + /// Whether this kind can share a plot area with other kinds. Excel only + /// combines the flat category/value kinds: everything else either owns the + /// plot area (pie, of-pie), needs its own axis shape (scatter, bubble, + /// radar) or draws into depth (the 3-D forms and surfaces). + fn is_combinable(&self) -> bool { + matches!( + self, + ChartType::Col | ChartType::Bar | ChartType::Line | ChartType::Area + ) + } + + /// The flat kind this one degrades to for rendering and for deciding which + /// series children are legal. + fn flattened(&self) -> ChartType { + match self { + ChartType::Col3d => ChartType::Col, + ChartType::Bar3d => ChartType::Bar, + ChartType::Line3d => ChartType::Line, + ChartType::Area3d => ChartType::Area, + ChartType::Pie3d => ChartType::Pie, + ChartType::Surface3d => ChartType::Surface, + other => other.clone(), + } + } +} + +/// How a pie-of-pie or bar-of-pie divides its single series between the two +/// plots. Only meaningful for those two kinds. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct OfPieSplit { + /// `c:splitType` — `auto | cust | percent | pos | val`. + pub by: Option, + /// `c:splitPos` — read according to `by`: a count of trailing points for + /// `pos`, a threshold for `val`, a percentage for `percent`. + pub pos: Option, + /// `c:secondPieSize` — the second plot's size as a percentage of the first. + pub second_size: Option, } #[derive(Debug, Clone, PartialEq)] @@ -47,6 +171,57 @@ pub enum SeriesColor { Scheme(String), } +/// What a chart writes next to each data point (`c:dLbls`). All-false means no +/// labels, which is Excel's default for a freshly inserted chart. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct DataLabels { + pub show_value: bool, + pub show_category: bool, + pub show_series: bool, + pub show_percent: bool, + pub show_legend_key: bool, + /// `c:dLblPos` — `ctr|inEnd|outEnd|inBase|bestFit|l|r|t|b`. + pub position: Option, + /// Number-format code applied to the label's value (`c:numFmt`). + pub num_fmt: Option, +} + +impl PreservedGroup { + /// A group with nothing preserved — what a combo chart's secondary groups + /// get, since the file's settings describe the primary one. + const EMPTY: PreservedGroup = PreservedGroup { + vary_colors: None, + gap_width: None, + overlap: None, + hole_size: None, + first_slice_ang: None, + scatter_style: None, + marker: None, + drop_lines: None, + radar_style: None, + hi_low_lines: None, + up_down_bars: None, + ser_lines: Vec::new(), + cust_split: None, + wireframe: None, + band_fmts: None, + gap_depth: None, + shape: None, + bubble_scale: None, + bubble_3d: None, + show_neg_bubbles: None, + size_represents: None, + }; +} + +impl DataLabels { + /// Whether anything at all is shown; used to decide if `c:dLbls` needs + /// writing and whether a renderer should draw labels. + pub fn any(&self) -> bool { + self.show_value || self.show_category || self.show_series || self.show_percent + } +} + #[derive(Debug, Clone)] pub struct ChartSeries { /// Series name, resolved from `c:tx` (literal or cached string ref). @@ -55,8 +230,163 @@ pub struct ChartSeries { pub val_ref: Option, /// Cached numeric values (`c:numCache`), indexed to match `categories`. pub cached_values: Vec>, + /// The bubble-size reference (`c:bubbleSize`), a bubble chart's third + /// dimension. `None` on every other chart kind. + pub size_ref: Option, + /// Cached bubble sizes, indexed to match `cached_values`. + pub cached_sizes: Vec>, + /// The source cells' number format as cached by the producer + /// (`c:numCache/c:formatCode`). Used to render values the way the sheet + /// does when the live cell format cannot be read. + pub format_code: Option, /// Fill color as authored (`c:spPr/a:solidFill`), if any. pub color: Option, + /// The kind this series is drawn as, when it differs from the chart's own + /// — a combo chart is exactly a chart whose series do not all agree. + /// `None` means "follow [`ChartData::chart_type`]". + pub series_type: Option, + /// Per-series XML the editor does not model. See [`PreservedSeries`]. + pub preserved: PreservedSeries, +} + +/// How an axis maps values to positions (`c:scaling` plus the tick units). +/// `None` everywhere means "auto", which is what Excel shows by default. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AxisScale { + pub min: Option, + pub max: Option, + /// `c:logBase` — a logarithmic axis. Excel allows 2..=1000. + pub log_base: Option, + /// `c:orientation val="maxMin"` — the axis runs the other way. + pub reversed: bool, + pub major_unit: Option, + pub minor_unit: Option, +} + +/// Chart XML this model does not interpret, kept verbatim so re-authoring the +/// chart cannot lose it. Each field is exactly one element, re-emitted in +/// schema order by [`build_chart_xml`]. +/// +/// The rule for adding to the typed model instead: if the editor needs to +/// *read or change* it, type it; if it only needs to survive, put it here. +#[derive(Debug, Clone, Default)] +pub struct PreservedXml { + // `c:chartSpace` level: the chart area's own fill/border and the default + // text properties every element inherits. + pub date1904: Option, + pub lang: Option, + pub rounded_corners: Option, + pub chart_space_sp_pr: Option, + pub chart_space_tx_pr: Option, + pub external_data: Option, + pub print_settings: Option, + /// `mc:AlternateContent` wrapping `c:style` — Excel's built-in chart style + /// id. It carries its own `xmlns:mc`, so re-emitting it is self-contained. + pub style: Option, + // `c:chart` level. + pub view_3d: Option, + pub floor: Option, + pub side_wall: Option, + pub back_wall: Option, + pub plot_vis_only: Option, + pub disp_blanks_as: Option, + pub show_d_lbls_over_max: Option, + // Title: the text is typed (the editor rewrites it); its placement and + // formatting are not. + pub title_layout: Option, + pub title_overlay: Option, + pub title_sp_pr: Option, + pub title_tx_pr: Option, + // Legend: only the position is typed. + pub legend_layout: Option, + pub legend_overlay: Option, + pub legend_sp_pr: Option, + pub legend_tx_pr: Option, + // Plot area. + pub plot_layout: Option, + pub plot_sp_pr: Option, + pub data_table: Option, + /// Settings of the plot group itself (bar gap width, hole size, …). + pub group: PreservedGroup, + pub cat_axis: PreservedAxis, + pub val_axis: PreservedAxis, + /// A surface chart's third axis (`c:serAx`), which runs across the series. + pub ser_axis: PreservedAxis, +} + +/// Per-chart-kind settings on the plot group (`c:barChart`, `c:pieChart`, …). +/// Which ones are present depends on the kind; they are written back only when +/// the kind still accepts them. +#[derive(Debug, Clone, Default)] +pub struct PreservedGroup { + pub vary_colors: Option, + pub gap_width: Option, + pub overlap: Option, + pub hole_size: Option, + pub first_slice_ang: Option, + pub scatter_style: Option, + pub marker: Option, + pub drop_lines: Option, + /// `c:radarStyle` — `standard`, `marker` or `filled`. + pub radar_style: Option, + /// Stock: the lines and bars drawn between the price series. + pub hi_low_lines: Option, + pub up_down_bars: Option, + /// Of-pie: the leader lines joining the two plots, and a custom split. + pub ser_lines: Vec, + pub cust_split: Option, + /// Surface: whether it is drawn as a mesh, and its colour bands. + pub wireframe: Option, + pub band_fmts: Option, + /// 3-D: the depth between series, and the solid each bar is drawn as. + pub gap_depth: Option, + pub shape: Option, + pub bubble_scale: Option, + pub bubble_3d: Option, + pub show_neg_bubbles: Option, + /// Whether a bubble's value maps to its area or its width. + pub size_represents: Option, +} + +/// Axis presentation. The scale itself is typed on [`ChartData`]; everything +/// here is style and layout the editor never touches. +#[derive(Debug, Clone, Default)] +pub struct PreservedAxis { + pub delete: Option, + pub ax_pos: Option, + pub major_gridlines: Option, + pub minor_gridlines: Option, + pub major_tick_mark: Option, + pub minor_tick_mark: Option, + pub tick_lbl_pos: Option, + pub sp_pr: Option, + pub tx_pr: Option, + pub crosses: Option, + pub crosses_at: Option, + pub cross_between: Option, + pub auto: Option, + pub lbl_algn: Option, + pub lbl_offset: Option, + pub no_multi_lvl_lbl: Option, + pub tick_lbl_skip: Option, + pub tick_mark_skip: Option, +} + +/// Per-series XML the editor does not model: markers, per-point formatting, +/// trendlines and so on. +#[derive(Debug, Clone, Default)] +pub struct PreservedSeries { + /// The series' whole `c:spPr`. [`ChartSeries::color`] is read out of it for + /// display; setting a color clears this so the builder writes a fresh fill. + pub sp_pr: Option, + pub invert_if_negative: Option, + pub marker: Option, + pub explosion: Option, + pub d_pt: Vec, + pub trendline: Vec, + pub err_bars: Option, + pub smooth: Option, + pub bubble_3d: Option, } /// A chart resolved into render-ready form. @@ -73,6 +403,21 @@ pub struct ChartData { pub series: Vec, pub cat_axis_title: Option, pub val_axis_title: Option, + /// Data labels for the whole plot group (per-point overrides are not + /// modeled; the group setting is what the UI edits). + pub data_labels: DataLabels, + /// Number-format code on the value axis (`c:valAx/c:numFmt@formatCode`). + pub val_axis_num_fmt: Option, + pub cat_axis_scale: AxisScale, + pub val_axis_scale: AxisScale, + /// How an of-pie chart splits its series. Ignored by every other kind. + pub of_pie_split: OfPieSplit, + /// Everything else in the file, kept verbatim. See [`PreservedXml`]. + /// + /// Boxed because it dwarfs the rest of the struct: `ChartData` is stored + /// by value in an `imbl::Vector`, whose fixed-size chunks are built on the + /// stack — inline, a few dozen preserved subtrees overflow it. + pub preserved: Box, } /// Parse a chart part's raw bytes into render-ready [`ChartData`]. Returns @@ -84,21 +429,94 @@ pub fn parse_chart(bytes: &[u8]) -> Option { let chart = space.chart?; let plot = chart.plot_area?; - let (chart_type, stacked, series_src) = detect_type(&plot)?; + let groups = detect_groups(&plot); + let (chart_type, stacked, series_src, group_labels) = groups.first()?.clone(); - let title = chart.title.and_then(|t| t.text()); - let legend_pos = chart.legend.and_then(|l| l.pos()); + let title = chart.title.as_ref().and_then(|t| t.text()); + let legend_pos = chart.legend.as_ref().and_then(|l| l.pos()); let (cat_axis_title, val_axis_title) = plot.axis_titles(); + let val_axis_num_fmt = plot.val_axis_num_fmt(); + let cat_axis_scale = plot.cat_axis().map(|a| a.scale()).unwrap_or_default(); + let val_axis_scale = plot.val_axis().map(|a| a.scale()).unwrap_or_default(); + let preserved = Box::new(PreservedXml { + date1904: space.date1904.clone(), + lang: space.lang.clone(), + rounded_corners: space.rounded_corners.clone(), + chart_space_sp_pr: space.sp_pr.clone(), + chart_space_tx_pr: space.tx_pr.clone(), + external_data: space.external_data.clone(), + print_settings: space.print_settings.clone(), + style: space.style.clone(), + view_3d: chart.view_3d.clone(), + floor: chart.floor.clone(), + side_wall: chart.side_wall.clone(), + back_wall: chart.back_wall.clone(), + plot_vis_only: chart.plot_vis_only.clone(), + disp_blanks_as: chart.disp_blanks_as.clone(), + show_d_lbls_over_max: chart.show_d_lbls_over_max.clone(), + title_layout: chart.title.as_ref().and_then(|t| t.layout.clone()), + title_overlay: chart.title.as_ref().and_then(|t| t.overlay.clone()), + title_sp_pr: chart.title.as_ref().and_then(|t| t.sp_pr.clone()), + title_tx_pr: chart.title.as_ref().and_then(|t| t.tx_pr.clone()), + legend_layout: chart.legend.as_ref().and_then(|l| l.layout.clone()), + legend_overlay: chart.legend.as_ref().and_then(|l| l.overlay.clone()), + legend_sp_pr: chart.legend.as_ref().and_then(|l| l.sp_pr.clone()), + legend_tx_pr: chart.legend.as_ref().and_then(|l| l.tx_pr.clone()), + plot_layout: plot.layout.clone(), + plot_sp_pr: plot.sp_pr.clone(), + data_table: plot.d_table.clone(), + group: plot.group_settings(), + cat_axis: plot.cat_axis().map(|a| a.preserved()).unwrap_or_default(), + val_axis: plot.val_axis().map(|a| a.preserved()).unwrap_or_default(), + ser_axis: plot.ser_axis().map(|a| a.preserved()).unwrap_or_default(), + }); + let of_pie_split = plot.of_pie_split(); + // A group-level `c:dLbls` is what Excel writes when you turn labels on for + // the whole chart; fall back to the first series that carries its own. + let data_labels = group_labels + .map(|l| l.to_model()) + .or_else(|| { + series_src + .iter() + .find_map(|s| s.d_lbls.as_ref()) + .map(|l| l.to_model()) + }) + .unwrap_or_default(); // Categories come from the first series that carries them (`c:cat` for // cartesian charts, `c:xVal` for scatter). - let (cat_ref, categories) = series_src + let (cat_ref, categories) = groups .iter() + .flat_map(|(_, _, ser, _)| ser.iter()) .find_map(|s| s.category_source()) .map(|src| (src.formula(), src.cached_labels())) .unwrap_or((None, Vec::new())); - let series = series_src.iter().map(|s| s.to_series()).collect(); + // Series from every group, each tagged with the kind it was drawn as when + // that is not the chart's own — which is what makes a combo chart. + let primary_kind = chart_type.clone(); + // Series are read group by group, but `c:order` is what decides the order + // Excel plots and lists them in — so a combo chart's series come back + // interleaved as authored, not bunched by plot group. + let mut ordered: Vec<(u32, ChartSeries)> = groups + .iter() + .flat_map(|(kind, _, ser, _)| { + let kind = kind.clone(); + let primary = primary_kind.clone(); + ser.iter().map(move |s| { + let mut out = s.to_series(); + if kind != primary { + out.series_type = Some(kind.clone()); + } + ( + s.order.as_ref().and_then(|o| o.val).unwrap_or(u32::MAX), + out, + ) + }) + }) + .collect(); + ordered.sort_by_key(|(order, _)| *order); + let series = ordered.into_iter().map(|(_, s)| s).collect(); Some(ChartData { chart_type, @@ -110,10 +528,22 @@ pub fn parse_chart(bytes: &[u8]) -> Option { series, cat_axis_title, val_axis_title, + data_labels, + val_axis_num_fmt, + cat_axis_scale, + val_axis_scale, + of_pie_split, + preserved, }) } -fn detect_type(plot: &CtPlotArea) -> Option<(ChartType, bool, &Vec)> { +type DetectedPlot<'a> = (ChartType, bool, &'a Vec, Option<&'a CtDLbls>); + +/// Every plot group in the area, in document order. More than one means a +/// combo chart: the first group is the chart's own kind and the rest override +/// it per series. +fn detect_groups(plot: &CtPlotArea) -> Vec> { + let mut out = Vec::new(); if let Some(b) = &plot.bar_chart { let horizontal = b.bar_dir.as_ref().and_then(|d| d.val.as_deref()) == Some("bar"); let ty = if horizontal { @@ -121,22 +551,136 @@ fn detect_type(plot: &CtPlotArea) -> Option<(ChartType, bool, &Vec)> { } else { ChartType::Col }; - return Some((ty, is_stacked(b.grouping.as_ref()), &b.ser)); + out.push(( + ty, + is_stacked(b.grouping.as_ref()), + &b.ser, + b.d_lbls.as_ref(), + )); } if let Some(l) = &plot.line_chart { - return Some((ChartType::Line, is_stacked(l.grouping.as_ref()), &l.ser)); + out.push(( + ChartType::Line, + is_stacked(l.grouping.as_ref()), + &l.ser, + l.d_lbls.as_ref(), + )); } if let Some(a) = &plot.area_chart { - return Some((ChartType::Area, is_stacked(a.grouping.as_ref()), &a.ser)); + out.push(( + ChartType::Area, + is_stacked(a.grouping.as_ref()), + &a.ser, + a.d_lbls.as_ref(), + )); + } + // The remaining kinds never combine, so at most one of them appears and it + // is the whole chart. + if let Some(single) = detect_type(plot) { + if !out.iter().any(|(k, ..)| *k == single.0) { + out.push(single); + } + } + out +} + +fn detect_type(plot: &CtPlotArea) -> Option> { + if let Some(b) = &plot.bar_chart { + let horizontal = b.bar_dir.as_ref().and_then(|d| d.val.as_deref()) == Some("bar"); + let ty = if horizontal { + ChartType::Bar + } else { + ChartType::Col + }; + return Some(( + ty, + is_stacked(b.grouping.as_ref()), + &b.ser, + b.d_lbls.as_ref(), + )); + } + if let Some(l) = &plot.line_chart { + return Some(( + ChartType::Line, + is_stacked(l.grouping.as_ref()), + &l.ser, + l.d_lbls.as_ref(), + )); + } + if let Some(a) = &plot.area_chart { + return Some(( + ChartType::Area, + is_stacked(a.grouping.as_ref()), + &a.ser, + a.d_lbls.as_ref(), + )); } if let Some(p) = &plot.pie_chart { - return Some((ChartType::Pie, false, &p.ser)); + return Some((ChartType::Pie, false, &p.ser, p.d_lbls.as_ref())); } if let Some(d) = &plot.doughnut_chart { - return Some((ChartType::Doughnut, false, &d.ser)); + return Some((ChartType::Doughnut, false, &d.ser, d.d_lbls.as_ref())); } if let Some(s) = &plot.scatter_chart { - return Some((ChartType::Scatter, false, &s.ser)); + return Some((ChartType::Scatter, false, &s.ser, s.d_lbls.as_ref())); + } + if let Some(r) = &plot.radar_chart { + return Some((ChartType::Radar, false, &r.ser, r.d_lbls.as_ref())); + } + if let Some(b) = &plot.bubble_chart { + return Some((ChartType::Bubble, false, &b.ser, b.d_lbls.as_ref())); + } + if let Some(st) = &plot.stock_chart { + return Some((ChartType::Stock, false, &st.ser, st.d_lbls.as_ref())); + } + if let Some(o) = &plot.of_pie_chart { + // `c:ofPieType` picks which shape the second plot takes; it is required + // by the schema, and "pie" is what Excel writes when in doubt. + let ty = if o.of_pie_type.as_ref().and_then(|t| t.val.as_deref()) == Some("bar") { + ChartType::BarOfPie + } else { + ChartType::OfPie + }; + return Some((ty, false, &o.ser, o.d_lbls.as_ref())); + } + if let Some(sf) = &plot.surface_chart { + return Some((ChartType::Surface, false, &sf.ser, sf.d_lbls.as_ref())); + } + if let Some(sf) = &plot.surface_3d_chart { + return Some((ChartType::Surface3d, false, &sf.ser, sf.d_lbls.as_ref())); + } + if let Some(b) = &plot.bar_3d_chart { + let horizontal = b.bar_dir.as_ref().and_then(|d| d.val.as_deref()) == Some("bar"); + let ty = if horizontal { + ChartType::Bar3d + } else { + ChartType::Col3d + }; + return Some(( + ty, + is_stacked(b.grouping.as_ref()), + &b.ser, + b.d_lbls.as_ref(), + )); + } + if let Some(l) = &plot.line_3d_chart { + return Some(( + ChartType::Line3d, + is_stacked(l.grouping.as_ref()), + &l.ser, + l.d_lbls.as_ref(), + )); + } + if let Some(a) = &plot.area_3d_chart { + return Some(( + ChartType::Area3d, + is_stacked(a.grouping.as_ref()), + &a.ser, + a.d_lbls.as_ref(), + )); + } + if let Some(p) = &plot.pie_3d_chart { + return Some((ChartType::Pie3d, false, &p.ser, p.d_lbls.as_ref())); } None } @@ -156,9 +700,33 @@ fn is_stacked(grouping: Option<&CtStrAttr>) -> bool { #[xmlserde(root = b"c:chartSpace")] #[xmlserde(alias(b"chartSpace"))] struct CtChartSpace { + #[xmlserde(name = b"c:date1904", ty = "child")] + #[xmlserde(alias(b"date1904"))] + date1904: Option, + #[xmlserde(name = b"c:lang", ty = "child")] + #[xmlserde(alias(b"lang"))] + lang: Option, + #[xmlserde(name = b"c:roundedCorners", ty = "child")] + #[xmlserde(alias(b"roundedCorners"))] + rounded_corners: Option, #[xmlserde(name = b"c:chart", ty = "child")] #[xmlserde(alias(b"chart"))] chart: Option, + #[xmlserde(name = b"c:spPr", ty = "child")] + #[xmlserde(alias(b"spPr"))] + sp_pr: Option, + #[xmlserde(name = b"c:txPr", ty = "child")] + #[xmlserde(alias(b"txPr"))] + tx_pr: Option, + #[xmlserde(name = b"c:externalData", ty = "child")] + #[xmlserde(alias(b"externalData"))] + external_data: Option, + #[xmlserde(name = b"c:printSettings", ty = "child")] + #[xmlserde(alias(b"printSettings"))] + print_settings: Option, + #[xmlserde(name = b"mc:AlternateContent", ty = "child")] + #[xmlserde(alias(b"AlternateContent"))] + style: Option, } #[derive(Debug, XmlDeserialize, Default)] @@ -166,16 +734,46 @@ struct CtChart { #[xmlserde(name = b"c:title", ty = "child")] #[xmlserde(alias(b"title"))] title: Option, + #[xmlserde(name = b"c:view3D", ty = "child")] + #[xmlserde(alias(b"view3D"))] + view_3d: Option, + #[xmlserde(name = b"c:floor", ty = "child")] + #[xmlserde(alias(b"floor"))] + floor: Option, + #[xmlserde(name = b"c:sideWall", ty = "child")] + #[xmlserde(alias(b"sideWall"))] + side_wall: Option, + #[xmlserde(name = b"c:backWall", ty = "child")] + #[xmlserde(alias(b"backWall"))] + back_wall: Option, #[xmlserde(name = b"c:plotArea", ty = "child")] #[xmlserde(alias(b"plotArea"))] plot_area: Option, #[xmlserde(name = b"c:legend", ty = "child")] #[xmlserde(alias(b"legend"))] legend: Option, + #[xmlserde(name = b"c:plotVisOnly", ty = "child")] + #[xmlserde(alias(b"plotVisOnly"))] + plot_vis_only: Option, + #[xmlserde(name = b"c:dispBlanksAs", ty = "child")] + #[xmlserde(alias(b"dispBlanksAs"))] + disp_blanks_as: Option, + #[xmlserde(name = b"c:showDLblsOverMax", ty = "child")] + #[xmlserde(alias(b"showDLblsOverMax"))] + show_d_lbls_over_max: Option, } #[derive(Debug, XmlDeserialize, Default)] struct CtPlotArea { + #[xmlserde(name = b"c:layout", ty = "child")] + #[xmlserde(alias(b"layout"))] + layout: Option, + #[xmlserde(name = b"c:spPr", ty = "child")] + #[xmlserde(alias(b"spPr"))] + sp_pr: Option, + #[xmlserde(name = b"c:dTable", ty = "child")] + #[xmlserde(alias(b"dTable"))] + d_table: Option, #[xmlserde(name = b"c:barChart", ty = "child")] #[xmlserde(alias(b"barChart"))] bar_chart: Option, @@ -194,20 +792,162 @@ struct CtPlotArea { #[xmlserde(name = b"c:scatterChart", ty = "child")] #[xmlserde(alias(b"scatterChart"))] scatter_chart: Option, + #[xmlserde(name = b"c:radarChart", ty = "child")] + #[xmlserde(alias(b"radarChart"))] + radar_chart: Option, + #[xmlserde(name = b"c:bubbleChart", ty = "child")] + #[xmlserde(alias(b"bubbleChart"))] + bubble_chart: Option, + #[xmlserde(name = b"c:stockChart", ty = "child")] + #[xmlserde(alias(b"stockChart"))] + stock_chart: Option, + #[xmlserde(name = b"c:ofPieChart", ty = "child")] + #[xmlserde(alias(b"ofPieChart"))] + of_pie_chart: Option, + #[xmlserde(name = b"c:surfaceChart", ty = "child")] + #[xmlserde(alias(b"surfaceChart"))] + surface_chart: Option, + #[xmlserde(name = b"c:surface3DChart", ty = "child")] + #[xmlserde(alias(b"surface3DChart"))] + surface_3d_chart: Option, + #[xmlserde(name = b"c:bar3DChart", ty = "child")] + #[xmlserde(alias(b"bar3DChart"))] + bar_3d_chart: Option, + #[xmlserde(name = b"c:line3DChart", ty = "child")] + #[xmlserde(alias(b"line3DChart"))] + line_3d_chart: Option, + #[xmlserde(name = b"c:area3DChart", ty = "child")] + #[xmlserde(alias(b"area3DChart"))] + area_3d_chart: Option, + #[xmlserde(name = b"c:pie3DChart", ty = "child")] + #[xmlserde(alias(b"pie3DChart"))] + pie_3d_chart: Option, + #[xmlserde(name = b"c:catAx", ty = "child")] #[xmlserde(alias(b"catAx"))] cat_ax: Vec, #[xmlserde(name = b"c:valAx", ty = "child")] #[xmlserde(alias(b"valAx"))] val_ax: Vec, + #[xmlserde(name = b"c:serAx", ty = "child")] + #[xmlserde(alias(b"serAx"))] + ser_ax: Vec, } impl CtPlotArea { + /// The horizontal (category) axis. Scatter charts have no `c:catAx` — the + /// first of their two `c:valAx` plays that role. + fn cat_axis(&self) -> Option<&CtAxis> { + self.cat_ax.first().or_else(|| self.val_ax.first()) + } + + /// A surface chart's third axis, running across the series. + fn ser_axis(&self) -> Option<&CtAxis> { + self.ser_ax.first() + } + + /// The vertical (value) axis. On a scatter chart that is the *second* + /// `c:valAx`; everywhere else the only one. + fn val_axis(&self) -> Option<&CtAxis> { + if self.cat_ax.is_empty() && self.val_ax.len() > 1 { + self.val_ax.get(1) + } else { + self.val_ax.first() + } + } + /// (category-axis title, value-axis title). fn axis_titles(&self) -> (Option, Option) { - let cat = self.cat_ax.iter().find_map(|a| a.title()); - let val = self.val_ax.iter().find_map(|a| a.title()); - (cat, val) + ( + self.cat_axis().and_then(|a| a.title()), + self.val_axis().and_then(|a| a.title()), + ) + } + + fn val_axis_num_fmt(&self) -> Option { + self.val_axis()? + .num_fmt + .as_ref() + .and_then(|n| n.format_code.clone()) + .and_then(explicit_format) + } + + fn of_pie_split(&self) -> OfPieSplit { + let Some(o) = &self.of_pie_chart else { + return OfPieSplit::default(); + }; + OfPieSplit { + by: o.split_type.as_ref().and_then(|t| t.val.clone()), + pos: o.split_pos.as_ref().and_then(|v| v.val), + second_size: o.second_pie_size.as_ref().and_then(|v| v.val), + } + } + + /// Settings that belong to whichever plot group this chart uses. + fn group_settings(&self) -> PreservedGroup { + for b in [&self.bar_chart, &self.bar_3d_chart].into_iter().flatten() { + return PreservedGroup { + vary_colors: b.vary_colors.clone(), + gap_width: b.gap_width.clone(), + overlap: b.overlap.clone(), + gap_depth: b.gap_depth.clone(), + shape: b.shape.clone(), + ..Default::default() + }; + } + for g in [ + &self.line_chart, + &self.area_chart, + &self.line_3d_chart, + &self.area_3d_chart, + ] + .into_iter() + .flatten() + { + return PreservedGroup { + vary_colors: g.vary_colors.clone(), + marker: g.marker.clone(), + drop_lines: g.drop_lines.clone(), + gap_depth: g.gap_depth.clone(), + ..Default::default() + }; + } + for c in [ + &self.pie_chart, + &self.doughnut_chart, + &self.scatter_chart, + &self.radar_chart, + &self.bubble_chart, + &self.stock_chart, + &self.of_pie_chart, + &self.surface_chart, + &self.surface_3d_chart, + &self.pie_3d_chart, + ] + .into_iter() + .flatten() + { + return PreservedGroup { + vary_colors: c.vary_colors.clone(), + hole_size: c.hole_size.clone(), + first_slice_ang: c.first_slice_ang.clone(), + scatter_style: c.scatter_style.clone(), + radar_style: c.radar_style.clone(), + bubble_scale: c.bubble_scale.clone(), + bubble_3d: c.bubble_3d.clone(), + show_neg_bubbles: c.show_neg_bubbles.clone(), + size_represents: c.size_represents.clone(), + hi_low_lines: c.hi_low_lines.clone(), + up_down_bars: c.up_down_bars.clone(), + ser_lines: c.ser_lines.clone(), + cust_split: c.cust_split.clone(), + wireframe: c.wireframe.clone(), + band_fmts: c.band_fmts.clone(), + gap_width: c.gap_width.clone(), + ..Default::default() + }; + } + PreservedGroup::default() } } @@ -222,6 +962,24 @@ struct CtBarChart { #[xmlserde(name = b"c:ser", ty = "child")] #[xmlserde(alias(b"ser"))] ser: Vec, + #[xmlserde(name = b"c:dLbls", ty = "child")] + #[xmlserde(alias(b"dLbls"))] + d_lbls: Option, + #[xmlserde(name = b"c:varyColors", ty = "child")] + #[xmlserde(alias(b"varyColors"))] + vary_colors: Option, + #[xmlserde(name = b"c:gapWidth", ty = "child")] + #[xmlserde(alias(b"gapWidth"))] + gap_width: Option, + #[xmlserde(name = b"c:overlap", ty = "child")] + #[xmlserde(alias(b"overlap"))] + overlap: Option, + #[xmlserde(name = b"c:gapDepth", ty = "child")] + #[xmlserde(alias(b"gapDepth"))] + gap_depth: Option, + #[xmlserde(name = b"c:shape", ty = "child")] + #[xmlserde(alias(b"shape"))] + shape: Option, } #[derive(Debug, XmlDeserialize, Default)] @@ -232,6 +990,21 @@ struct CtGroupedChart { #[xmlserde(name = b"c:ser", ty = "child")] #[xmlserde(alias(b"ser"))] ser: Vec, + #[xmlserde(name = b"c:dLbls", ty = "child")] + #[xmlserde(alias(b"dLbls"))] + d_lbls: Option, + #[xmlserde(name = b"c:varyColors", ty = "child")] + #[xmlserde(alias(b"varyColors"))] + vary_colors: Option, + #[xmlserde(name = b"c:marker", ty = "child")] + #[xmlserde(alias(b"marker"))] + marker: Option, + #[xmlserde(name = b"c:dropLines", ty = "child")] + #[xmlserde(alias(b"dropLines"))] + drop_lines: Option, + #[xmlserde(name = b"c:gapDepth", ty = "child")] + #[xmlserde(alias(b"gapDepth"))] + gap_depth: Option, } #[derive(Debug, XmlDeserialize, Default)] @@ -239,16 +1012,82 @@ struct CtSimpleChart { #[xmlserde(name = b"c:ser", ty = "child")] #[xmlserde(alias(b"ser"))] ser: Vec, + #[xmlserde(name = b"c:dLbls", ty = "child")] + #[xmlserde(alias(b"dLbls"))] + d_lbls: Option, + #[xmlserde(name = b"c:varyColors", ty = "child")] + #[xmlserde(alias(b"varyColors"))] + vary_colors: Option, + #[xmlserde(name = b"c:holeSize", ty = "child")] + #[xmlserde(alias(b"holeSize"))] + hole_size: Option, + #[xmlserde(name = b"c:firstSliceAng", ty = "child")] + #[xmlserde(alias(b"firstSliceAng"))] + first_slice_ang: Option, + #[xmlserde(name = b"c:scatterStyle", ty = "child")] + #[xmlserde(alias(b"scatterStyle"))] + scatter_style: Option, + #[xmlserde(name = b"c:radarStyle", ty = "child")] + #[xmlserde(alias(b"radarStyle"))] + radar_style: Option, + #[xmlserde(name = b"c:bubbleScale", ty = "child")] + #[xmlserde(alias(b"bubbleScale"))] + bubble_scale: Option, + #[xmlserde(name = b"c:bubble3D", ty = "child")] + #[xmlserde(alias(b"bubble3D"))] + bubble_3d: Option, + #[xmlserde(name = b"c:showNegBubbles", ty = "child")] + #[xmlserde(alias(b"showNegBubbles"))] + show_neg_bubbles: Option, + #[xmlserde(name = b"c:sizeRepresents", ty = "child")] + #[xmlserde(alias(b"sizeRepresents"))] + size_represents: Option, + #[xmlserde(name = b"c:hiLowLines", ty = "child")] + #[xmlserde(alias(b"hiLowLines"))] + hi_low_lines: Option, + #[xmlserde(name = b"c:upDownBars", ty = "child")] + #[xmlserde(alias(b"upDownBars"))] + up_down_bars: Option, + #[xmlserde(name = b"c:ofPieType", ty = "child")] + #[xmlserde(alias(b"ofPieType"))] + of_pie_type: Option, + #[xmlserde(name = b"c:splitType", ty = "child")] + #[xmlserde(alias(b"splitType"))] + split_type: Option, + #[xmlserde(name = b"c:splitPos", ty = "child")] + #[xmlserde(alias(b"splitPos"))] + split_pos: Option, + #[xmlserde(name = b"c:secondPieSize", ty = "child")] + #[xmlserde(alias(b"secondPieSize"))] + second_pie_size: Option, + #[xmlserde(name = b"c:custSplit", ty = "child")] + #[xmlserde(alias(b"custSplit"))] + cust_split: Option, + #[xmlserde(name = b"c:serLines", ty = "child")] + #[xmlserde(alias(b"serLines"))] + ser_lines: Vec, + #[xmlserde(name = b"c:wireframe", ty = "child")] + #[xmlserde(alias(b"wireframe"))] + wireframe: Option, + #[xmlserde(name = b"c:bandFmts", ty = "child")] + #[xmlserde(alias(b"bandFmts"))] + band_fmts: Option, + #[xmlserde(name = b"c:gapWidth", ty = "child")] + #[xmlserde(alias(b"gapWidth"))] + gap_width: Option, } #[derive(Debug, XmlDeserialize, Default)] struct CtSer { + #[xmlserde(name = b"c:order", ty = "child")] + #[xmlserde(alias(b"order"))] + order: Option, #[xmlserde(name = b"c:tx", ty = "child")] #[xmlserde(alias(b"tx"))] tx: Option, #[xmlserde(name = b"c:spPr", ty = "child")] #[xmlserde(alias(b"spPr"))] - sp_pr: Option, + sp_pr: Option, #[xmlserde(name = b"c:cat", ty = "child")] #[xmlserde(alias(b"cat"))] cat: Option, @@ -261,9 +1100,41 @@ struct CtSer { #[xmlserde(name = b"c:yVal", ty = "child")] #[xmlserde(alias(b"yVal"))] y_val: Option, + #[xmlserde(name = b"c:bubbleSize", ty = "child")] + #[xmlserde(alias(b"bubbleSize"))] + bubble_size: Option, + #[xmlserde(name = b"c:bubble3D", ty = "child")] + #[xmlserde(alias(b"bubble3D"))] + bubble_3d: Option, + #[xmlserde(name = b"c:dLbls", ty = "child")] + #[xmlserde(alias(b"dLbls"))] + d_lbls: Option, + #[xmlserde(name = b"c:invertIfNegative", ty = "child")] + #[xmlserde(alias(b"invertIfNegative"))] + invert_if_negative: Option, + #[xmlserde(name = b"c:marker", ty = "child")] + #[xmlserde(alias(b"marker"))] + marker: Option, + #[xmlserde(name = b"c:explosion", ty = "child")] + #[xmlserde(alias(b"explosion"))] + explosion: Option, + #[xmlserde(name = b"c:dPt", ty = "child")] + #[xmlserde(alias(b"dPt"))] + d_pt: Vec, + #[xmlserde(name = b"c:trendline", ty = "child")] + #[xmlserde(alias(b"trendline"))] + trendline: Vec, + #[xmlserde(name = b"c:errBars", ty = "child")] + #[xmlserde(alias(b"errBars"))] + err_bars: Option, + #[xmlserde(name = b"c:smooth", ty = "child")] + #[xmlserde(alias(b"smooth"))] + smooth: Option, } #[derive(Debug, XmlDeserialize, Default)] +#[xmlserde(root = b"c:spPr")] +#[xmlserde(alias(b"spPr"))] struct CtChartShapeProps { #[xmlserde(name = b"a:solidFill", ty = "child")] solid_fill: Option, @@ -296,6 +1167,36 @@ impl CtChartShapeProps { } } +/// Read the fill color out of a preserved `c:spPr`. The subtree is kept whole +/// (a fill is only part of what `spPr` carries), so the color is recovered by +/// re-parsing it rather than by mapping the element twice. +fn color_of(sp_pr: &Unparsed) -> Option { + let xml = render_unparsed("c:spPr", sp_pr); + xml_deserialize_from_str::(&xml) + .ok() + .and_then(|p| p.color()) +} + +/// Serialize a preserved subtree back to XML under `tag`. +fn render_unparsed(tag: &str, u: &Unparsed) -> String { + let mut writer = quick_xml::Writer::new(Vec::new()); + u.serialize(tag.as_bytes(), &mut writer); + String::from_utf8(writer.into_inner()).unwrap_or_default() +} + +/// Append a preserved subtree, if there is one. +fn push_preserved(s: &mut String, tag: &str, u: &Option) { + if let Some(u) = u { + s.push_str(&render_unparsed(tag, u)); + } +} + +fn push_preserved_all(s: &mut String, tag: &str, list: &[Unparsed]) { + for u in list { + s.push_str(&render_unparsed(tag, u)); + } +} + impl CtSer { fn category_source(&self) -> Option<&CtAxDataSource> { self.cat.as_ref().or(self.x_val.as_ref()) @@ -312,7 +1213,27 @@ impl CtSer { name, val_ref: val.and_then(|v| v.formula()), cached_values: val.map(|v| v.cached_values()).unwrap_or_default(), - color: self.sp_pr.as_ref().and_then(|p| p.color()), + size_ref: self.bubble_size.as_ref().and_then(|b| b.formula()), + cached_sizes: self + .bubble_size + .as_ref() + .map(|b| b.cached_values()) + .unwrap_or_default(), + format_code: val.and_then(|v| v.format_code()), + color: self.sp_pr.as_ref().and_then(color_of), + // Filled in by the caller, which knows which group this came from. + series_type: None, + preserved: PreservedSeries { + sp_pr: self.sp_pr.clone(), + invert_if_negative: self.invert_if_negative.clone(), + marker: self.marker.clone(), + explosion: self.explosion.clone(), + d_pt: self.d_pt.clone(), + trendline: self.trendline.clone(), + err_bars: self.err_bars.clone(), + smooth: self.smooth.clone(), + bubble_3d: self.bubble_3d.clone(), + }, } } } @@ -361,6 +1282,14 @@ impl CtNumDataSource { .map(|c| c.values()) .unwrap_or_default() } + + fn format_code(&self) -> Option { + self.num_ref + .as_ref() + .and_then(|r| r.num_cache.as_ref()) + .and_then(|c| c.format_code.as_ref()) + .and_then(|t| non_empty(t.v.clone())) + } } #[derive(Debug, XmlDeserialize, Default)] @@ -375,6 +1304,9 @@ struct CtNumRef { #[derive(Debug, XmlDeserialize, Default)] struct CtNumData { + #[xmlserde(name = b"c:formatCode", ty = "child")] + #[xmlserde(alias(b"formatCode"))] + format_code: Option, #[xmlserde(name = b"c:ptCount", ty = "child")] #[xmlserde(alias(b"ptCount"))] pt_count: Option, @@ -514,6 +1446,18 @@ struct CtTitle { #[xmlserde(name = b"c:tx", ty = "child")] #[xmlserde(alias(b"tx"))] tx: Option, + #[xmlserde(name = b"c:layout", ty = "child")] + #[xmlserde(alias(b"layout"))] + layout: Option, + #[xmlserde(name = b"c:overlay", ty = "child")] + #[xmlserde(alias(b"overlay"))] + overlay: Option, + #[xmlserde(name = b"c:spPr", ty = "child")] + #[xmlserde(alias(b"spPr"))] + sp_pr: Option, + #[xmlserde(name = b"c:txPr", ty = "child")] + #[xmlserde(alias(b"txPr"))] + tx_pr: Option, } impl CtTitle { @@ -581,6 +1525,18 @@ struct CtLegend { #[xmlserde(name = b"c:legendPos", ty = "child")] #[xmlserde(alias(b"legendPos"))] legend_pos: Option, + #[xmlserde(name = b"c:layout", ty = "child")] + #[xmlserde(alias(b"layout"))] + layout: Option, + #[xmlserde(name = b"c:overlay", ty = "child")] + #[xmlserde(alias(b"overlay"))] + overlay: Option, + #[xmlserde(name = b"c:spPr", ty = "child")] + #[xmlserde(alias(b"spPr"))] + sp_pr: Option, + #[xmlserde(name = b"c:txPr", ty = "child")] + #[xmlserde(alias(b"txPr"))] + tx_pr: Option, } impl CtLegend { @@ -602,6 +1558,191 @@ struct CtAxis { #[xmlserde(name = b"c:title", ty = "child")] #[xmlserde(alias(b"title"))] title: Option, + #[xmlserde(name = b"c:numFmt", ty = "child")] + #[xmlserde(alias(b"numFmt"))] + num_fmt: Option, + #[xmlserde(name = b"c:scaling", ty = "child")] + #[xmlserde(alias(b"scaling"))] + scaling: Option, + #[xmlserde(name = b"c:majorUnit", ty = "child")] + #[xmlserde(alias(b"majorUnit"))] + major_unit: Option, + #[xmlserde(name = b"c:minorUnit", ty = "child")] + #[xmlserde(alias(b"minorUnit"))] + minor_unit: Option, + #[xmlserde(name = b"c:delete", ty = "child")] + #[xmlserde(alias(b"delete"))] + delete: Option, + #[xmlserde(name = b"c:axPos", ty = "child")] + #[xmlserde(alias(b"axPos"))] + ax_pos: Option, + #[xmlserde(name = b"c:majorGridlines", ty = "child")] + #[xmlserde(alias(b"majorGridlines"))] + major_gridlines: Option, + #[xmlserde(name = b"c:minorGridlines", ty = "child")] + #[xmlserde(alias(b"minorGridlines"))] + minor_gridlines: Option, + #[xmlserde(name = b"c:majorTickMark", ty = "child")] + #[xmlserde(alias(b"majorTickMark"))] + major_tick_mark: Option, + #[xmlserde(name = b"c:minorTickMark", ty = "child")] + #[xmlserde(alias(b"minorTickMark"))] + minor_tick_mark: Option, + #[xmlserde(name = b"c:tickLblPos", ty = "child")] + #[xmlserde(alias(b"tickLblPos"))] + tick_lbl_pos: Option, + #[xmlserde(name = b"c:spPr", ty = "child")] + #[xmlserde(alias(b"spPr"))] + sp_pr: Option, + #[xmlserde(name = b"c:txPr", ty = "child")] + #[xmlserde(alias(b"txPr"))] + tx_pr: Option, + #[xmlserde(name = b"c:crosses", ty = "child")] + #[xmlserde(alias(b"crosses"))] + crosses: Option, + #[xmlserde(name = b"c:crossesAt", ty = "child")] + #[xmlserde(alias(b"crossesAt"))] + crosses_at: Option, + #[xmlserde(name = b"c:crossBetween", ty = "child")] + #[xmlserde(alias(b"crossBetween"))] + cross_between: Option, + #[xmlserde(name = b"c:auto", ty = "child")] + #[xmlserde(alias(b"auto"))] + auto: Option, + #[xmlserde(name = b"c:lblAlgn", ty = "child")] + #[xmlserde(alias(b"lblAlgn"))] + lbl_algn: Option, + #[xmlserde(name = b"c:lblOffset", ty = "child")] + #[xmlserde(alias(b"lblOffset"))] + lbl_offset: Option, + #[xmlserde(name = b"c:noMultiLvlLbl", ty = "child")] + #[xmlserde(alias(b"noMultiLvlLbl"))] + no_multi_lvl_lbl: Option, + #[xmlserde(name = b"c:tickLblSkip", ty = "child")] + #[xmlserde(alias(b"tickLblSkip"))] + tick_lbl_skip: Option, + #[xmlserde(name = b"c:tickMarkSkip", ty = "child")] + #[xmlserde(alias(b"tickMarkSkip"))] + tick_mark_skip: Option, +} + +impl CtAxis { + fn scale(&self) -> AxisScale { + let sc = self.scaling.as_ref(); + AxisScale { + min: sc.and_then(|s| s.min.as_ref()).and_then(|v| v.val), + max: sc.and_then(|s| s.max.as_ref()).and_then(|v| v.val), + log_base: sc.and_then(|s| s.log_base.as_ref()).and_then(|v| v.val), + reversed: sc + .and_then(|s| s.orientation.as_ref()) + .and_then(|o| o.val.as_deref()) + == Some("maxMin"), + major_unit: self.major_unit.as_ref().and_then(|v| v.val), + minor_unit: self.minor_unit.as_ref().and_then(|v| v.val), + } + } + + fn preserved(&self) -> PreservedAxis { + PreservedAxis { + delete: self.delete.clone(), + ax_pos: self.ax_pos.clone(), + major_gridlines: self.major_gridlines.clone(), + minor_gridlines: self.minor_gridlines.clone(), + major_tick_mark: self.major_tick_mark.clone(), + minor_tick_mark: self.minor_tick_mark.clone(), + tick_lbl_pos: self.tick_lbl_pos.clone(), + sp_pr: self.sp_pr.clone(), + tx_pr: self.tx_pr.clone(), + crosses: self.crosses.clone(), + crosses_at: self.crosses_at.clone(), + cross_between: self.cross_between.clone(), + auto: self.auto.clone(), + lbl_algn: self.lbl_algn.clone(), + lbl_offset: self.lbl_offset.clone(), + no_multi_lvl_lbl: self.no_multi_lvl_lbl.clone(), + tick_lbl_skip: self.tick_lbl_skip.clone(), + tick_mark_skip: self.tick_mark_skip.clone(), + } + } +} + +/// `c:scaling` — the axis' value range and direction. +#[derive(Debug, XmlDeserialize, Default)] +struct CtScaling { + #[xmlserde(name = b"c:logBase", ty = "child")] + #[xmlserde(alias(b"logBase"))] + log_base: Option, + #[xmlserde(name = b"c:orientation", ty = "child")] + #[xmlserde(alias(b"orientation"))] + orientation: Option, + #[xmlserde(name = b"c:max", ty = "child")] + #[xmlserde(alias(b"max"))] + max: Option, + #[xmlserde(name = b"c:min", ty = "child")] + #[xmlserde(alias(b"min"))] + min: Option, +} + +/// `c:numFmt` — a format code plus the "linked to source" flag (which we do +/// not model: a linked format is simply absent here and the renderer falls back +/// to the source cells' format). +#[derive(Debug, XmlDeserialize, Default)] +struct CtNumFmt { + #[xmlserde(name = b"formatCode", ty = "attr")] + format_code: Option, +} + +/// `c:dLbls` — which parts of a data point are written next to it. +#[derive(Debug, XmlDeserialize, Default)] +struct CtDLbls { + #[xmlserde(name = b"c:numFmt", ty = "child")] + #[xmlserde(alias(b"numFmt"))] + num_fmt: Option, + #[xmlserde(name = b"c:dLblPos", ty = "child")] + #[xmlserde(alias(b"dLblPos"))] + pos: Option, + #[xmlserde(name = b"c:showLegendKey", ty = "child")] + #[xmlserde(alias(b"showLegendKey"))] + show_legend_key: Option, + #[xmlserde(name = b"c:showVal", ty = "child")] + #[xmlserde(alias(b"showVal"))] + show_val: Option, + #[xmlserde(name = b"c:showCatName", ty = "child")] + #[xmlserde(alias(b"showCatName"))] + show_cat_name: Option, + #[xmlserde(name = b"c:showSerName", ty = "child")] + #[xmlserde(alias(b"showSerName"))] + show_ser_name: Option, + #[xmlserde(name = b"c:showPercent", ty = "child")] + #[xmlserde(alias(b"showPercent"))] + show_percent: Option, +} + +impl CtDLbls { + fn to_model(&self) -> DataLabels { + DataLabels { + show_value: flag(self.show_val.as_ref()), + show_category: flag(self.show_cat_name.as_ref()), + show_series: flag(self.show_ser_name.as_ref()), + show_percent: flag(self.show_percent.as_ref()), + show_legend_key: flag(self.show_legend_key.as_ref()), + position: self.pos.as_ref().and_then(|p| p.val.clone()), + num_fmt: self + .num_fmt + .as_ref() + .and_then(|n| n.format_code.clone()) + .and_then(explicit_format), + } + } +} + +/// An OOXML boolean attribute: `1`/`true` are on, everything else (including a +/// missing element) is off. +fn flag(attr: Option<&CtStrAttr>) -> bool { + matches!( + attr.and_then(|a| a.val.as_deref()), + Some("1") | Some("true") + ) } impl CtAxis { @@ -624,12 +1765,29 @@ struct CtU32Attr { val: Option, } +#[derive(Debug, XmlDeserialize, Default)] +struct CtF64Attr { + #[xmlserde(name = b"val", ty = "attr")] + val: Option, +} + #[derive(Debug, XmlDeserialize, Default)] struct CtText { #[xmlserde(ty = "text")] v: String, } +/// A `formatCode` that actually says something. `General` is the absence of a +/// format, so it is reported as `None` — otherwise an editor would show it as +/// an explicit setting and writing it back would pin the axis to it. +fn explicit_format(s: String) -> Option { + if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("general") { + None + } else { + Some(s) + } +} + fn non_empty(s: String) -> Option { if s.trim().is_empty() { None } else { Some(s) } } @@ -646,12 +1804,51 @@ fn format_num(n: f64) -> String { // Chart XML generation (authoring new charts) // --------------------------------------------------------------------------- -/// A series for a newly-created chart: an optional literal name and the value -/// reference formula (e.g. `Sheet1!$B$2:$E$2`). -#[derive(Debug, Clone)] -pub struct NewChartSeries { - pub name: Option, - pub value_ref: String, +/// Convenience constructors for authoring a chart from scratch (no cached +/// values: everything is read live from the references). +impl ChartSeries { + pub fn new(name: Option, val_ref: String) -> Self { + ChartSeries { + name, + val_ref: Some(val_ref), + cached_values: Vec::new(), + size_ref: None, + cached_sizes: Vec::new(), + format_code: None, + color: None, + series_type: None, + preserved: PreservedSeries::default(), + } + } +} + +impl ChartData { + /// A brand-new chart: the given type/title/series, a bottom legend (what + /// Excel inserts by default) and nothing else set. + pub fn new( + chart_type: ChartType, + title: Option, + categories_ref: Option, + series: Vec, + ) -> Self { + ChartData { + chart_type, + stacked: false, + title, + legend_pos: Some(LegendPos::Bottom), + cat_ref: categories_ref, + categories: Vec::new(), + series, + cat_axis_title: None, + val_axis_title: None, + data_labels: DataLabels::default(), + val_axis_num_fmt: None, + cat_axis_scale: AxisScale::default(), + val_axis_scale: AxisScale::default(), + of_pie_split: OfPieSplit::default(), + preserved: Box::default(), + } + } } fn xml_escape(s: &str) -> String { @@ -661,91 +1858,381 @@ fn xml_escape(s: &str) -> String { .replace('"', """) } -/// Generate a minimal but valid `c:chartSpace` for a new chart. `numCache` is -/// intentionally omitted — values are resolved live from the series references -/// (see `Worksheet::get_charts`), and Excel recomputes the cache on open. The -/// result parses cleanly back through [`parse_chart`]. -pub fn build_chart_xml( - chart_type: &ChartType, - title: Option<&str>, - categories_ref: Option<&str>, - series: &[NewChartSeries], -) -> String { +/// Generate a `c:chartSpace` for `data`. `numCache` is intentionally omitted — +/// values are resolved live from the series references (see +/// `Worksheet::get_charts`), and Excel recomputes the cache on open. The result +/// parses cleanly back through [`parse_chart`], so this is also how an existing +/// chart is re-authored after an edit: parse → patch → build. +pub fn build_chart_xml(data: &ChartData) -> String { const AX_CAT: u64 = 111_111_111; const AX_VAL: u64 = 222_222_222; - let mut s = String::with_capacity(1024); + // Only a surface uses the third axis. + const AX_SER: u64 = 333_333_333; + let chart_type = &data.chart_type; + let kept = &data.preserved; + + let mut s = String::with_capacity(2048); s.push_str(r#""#); s.push_str( - r#""#, + r#""#, ); - match title { - Some(t) => { + push_preserved(&mut s, "c:date1904", &kept.date1904); + push_preserved(&mut s, "c:lang", &kept.lang); + push_preserved(&mut s, "c:roundedCorners", &kept.rounded_corners); + push_preserved(&mut s, "mc:AlternateContent", &kept.style); + s.push_str(""); + match data.title.as_deref() { + Some(t) if !t.is_empty() => { s.push_str(""); s.push_str(&xml_escape(t)); - s.push_str(""); + s.push_str(""); + push_preserved(&mut s, "c:layout", &kept.title_layout); + match &kept.title_overlay { + Some(o) => s.push_str(&render_unparsed("c:overlay", o)), + None => s.push_str(""), + } + push_preserved(&mut s, "c:spPr", &kept.title_sp_pr); + push_preserved(&mut s, "c:txPr", &kept.title_tx_pr); + s.push_str(""); s.push_str(""); } - None => s.push_str(""), + _ => s.push_str(""), + } + push_preserved(&mut s, "c:view3D", &kept.view_3d); + push_preserved(&mut s, "c:floor", &kept.floor); + push_preserved(&mut s, "c:sideWall", &kept.side_wall); + push_preserved(&mut s, "c:backWall", &kept.back_wall); + s.push_str(""); + match &kept.plot_layout { + Some(l) => s.push_str(&render_unparsed("c:layout", l)), + None => s.push_str(""), } - s.push_str(""); - let is_scatter = matches!(chart_type, ChartType::Scatter); - let cartesian = matches!( - chart_type, - ChartType::Col | ChartType::Bar | ChartType::Line | ChartType::Area - ); + // Scatter and bubble both plot X against Y, so both need two value axes; + // radar, stock and surface are category-vs-value like the plain kinds. + let xy = matches!(chart_type, ChartType::Scatter | ChartType::Bubble); + let cartesian = chart_type.is_cartesian(); + let labels = &data.data_labels; + let group = &kept.group; + + push_plot_groups(&mut s, data, labels, group, (AX_CAT, AX_VAL, AX_SER)); + + if cartesian { + push_axis(&mut s, Axis::Cat, AX_CAT, AX_VAL, data); + push_axis(&mut s, Axis::Val, AX_VAL, AX_CAT, data); + if chart_type.needs_series_axis() { + push_axis(&mut s, Axis::Ser, AX_SER, AX_VAL, data); + } + } else if xy { + // Scatter has two value axes: X along the bottom, Y at the left. The + // X one carries what would otherwise be the category axis' settings. + push_axis(&mut s, Axis::ScatterX, AX_CAT, AX_VAL, data); + push_axis(&mut s, Axis::Val, AX_VAL, AX_CAT, data); + } + + push_preserved(&mut s, "c:dTable", &kept.data_table); + push_preserved(&mut s, "c:spPr", &kept.plot_sp_pr); + s.push_str(""); + if let Some(pos) = &data.legend_pos { + let v = match pos { + LegendPos::Top => "t", + LegendPos::Bottom => "b", + LegendPos::Left => "l", + LegendPos::Right => "r", + }; + s.push_str(&format!("", v)); + push_preserved(&mut s, "c:layout", &kept.legend_layout); + match &kept.legend_overlay { + Some(o) => s.push_str(&render_unparsed("c:overlay", o)), + None => s.push_str(""), + } + push_preserved(&mut s, "c:spPr", &kept.legend_sp_pr); + push_preserved(&mut s, "c:txPr", &kept.legend_tx_pr); + s.push_str(""); + } + match &kept.plot_vis_only { + Some(p) => s.push_str(&render_unparsed("c:plotVisOnly", p)), + None => s.push_str(""), + } + match &kept.disp_blanks_as { + Some(d) => s.push_str(&render_unparsed("c:dispBlanksAs", d)), + None => s.push_str(""), + } + push_preserved(&mut s, "c:showDLblsOverMax", &kept.show_d_lbls_over_max); + s.push_str(""); + push_preserved(&mut s, "c:spPr", &kept.chart_space_sp_pr); + push_preserved(&mut s, "c:txPr", &kept.chart_space_tx_pr); + push_preserved(&mut s, "c:externalData", &kept.external_data); + push_preserved(&mut s, "c:printSettings", &kept.print_settings); + s.push_str(""); + s +} + +/// Emit every plot group the chart needs. +/// +/// A chart is a combo when its series do not all agree on a kind. The chart's +/// own `chart_type` leads and keeps the settings the file carried; each further +/// kind gets a group of its own, in the order the series first ask for it, with +/// default settings — per-group settings of a secondary group are not modeled. +/// +/// Overrides only apply when both the chart and the override are combinable +/// kinds; anything else is folded back into the primary group so the result +/// stays a chart Excel will open. +fn push_plot_groups( + s: &mut String, + data: &ChartData, + labels: &DataLabels, + group: &PreservedGroup, + ax: (u64, u64, u64), +) { + let primary = &data.chart_type; + let kind_of = |ser: &ChartSeries| -> ChartType { + match &ser.series_type { + Some(k) if primary.is_combinable() && k.is_combinable() => k.clone(), + _ => primary.clone(), + } + }; + + // The primary group first, then each override kind in first-use order. + let mut order: Vec = vec![primary.clone()]; + for ser in &data.series { + let k = kind_of(ser); + if !order.contains(&k) { + order.push(k); + } + } + let empty = PreservedGroup::EMPTY; + for (i, kind) in order.iter().enumerate() { + // Each series keeps the position it has in the model, so `c:order` + // describes the chart's own series order rather than the order the + // groups happen to be written in — otherwise every edit would + // reshuffle a combo chart. + let members: Vec<(usize, &ChartSeries)> = data + .series + .iter() + .enumerate() + .filter(|(_, ser)| kind_of(ser) == *kind) + .collect(); + // The primary group is written even when it has no series, so a chart + // whose series were all overridden still declares its own kind. + if members.is_empty() && i > 0 { + continue; + } + push_plot_group( + s, + kind, + data, + &members, + labels, + if i == 0 { group } else { &empty }, + // Stacking belongs to the group the file described. + i == 0 && data.stacked, + ax, + ); + } +} + +/// One `c:*Chart` plot group: the element for `chart_type`, its settings and +/// its series. A combo chart calls this once per group, so everything here is +/// scoped to the group — the axes are the caller's job. +#[allow(non_snake_case)] +fn push_plot_group( + s: &mut String, + chart_type: &ChartType, + data: &ChartData, + series: &[(usize, &ChartSeries)], + labels: &DataLabels, + group: &PreservedGroup, + stacked: bool, + ax: (u64, u64, u64), +) { + let (AX_CAT, AX_VAL, AX_SER) = ax; + let categories_ref = data.cat_ref.as_deref(); match chart_type { - ChartType::Col | ChartType::Bar => { - let dir = if matches!(chart_type, ChartType::Bar) { + // The 3-D kinds share their flat sibling's arm: same series, same + // settings, plus a depth axis and the depth-only children. + ChartType::Col | ChartType::Bar | ChartType::Col3d | ChartType::Bar3d => { + let three_d = chart_type.is_3d(); + let dir = if matches!(chart_type, ChartType::Bar | ChartType::Bar3d) { "bar" } else { "col" }; - s.push_str(""); + let tag = if three_d { "bar3DChart" } else { "barChart" }; + let grouping = if stacked { "stacked" } else { "clustered" }; + s.push_str(&format!("", tag)); s.push_str(&format!("", dir)); - s.push_str(""); - push_cartesian_series(&mut s, categories_ref, series); + s.push_str(&format!("", grouping)); + push_vary_colors(s, group); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + push_preserved(s, "c:gapWidth", &group.gap_width); + if three_d { + push_preserved(s, "c:gapDepth", &group.gap_depth); + push_preserved(s, "c:shape", &group.shape); + } else { + // Stacked bars must not be offset from each other; an authored + // overlap only survives while the chart stays unstacked. The + // 3-D form has no overlap at all — depth separates the series. + match (&group.overlap, stacked) { + (_, true) => s.push_str(""), + (Some(o), false) => s.push_str(&render_unparsed("c:overlap", o)), + (None, false) => {} + } + } + push_axis_ids(s, chart_type, AX_CAT, AX_VAL, AX_SER); + s.push_str(&format!("", tag)); + } + ChartType::Line | ChartType::Area | ChartType::Line3d | ChartType::Area3d => { + let three_d = chart_type.is_3d(); + let area = matches!(chart_type, ChartType::Area | ChartType::Area3d); + let tag = match (area, three_d) { + (true, false) => "areaChart", + (true, true) => "area3DChart", + (false, false) => "lineChart", + (false, true) => "line3DChart", + }; + let grouping = if stacked { "stacked" } else { "standard" }; + s.push_str(&format!("", tag)); + s.push_str(&format!("", grouping)); + push_vary_colors(s, group); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + push_preserved(s, "c:dropLines", &group.drop_lines); + if matches!(chart_type, ChartType::Line) { + push_preserved(s, "c:marker", &group.marker); + } + if three_d { + push_preserved(s, "c:gapDepth", &group.gap_depth); + } + push_axis_ids(s, chart_type, AX_CAT, AX_VAL, AX_SER); + s.push_str(&format!("", tag)); + } + ChartType::Pie | ChartType::Doughnut | ChartType::Pie3d => { + let doughnut = matches!(chart_type, ChartType::Doughnut); + let three_d = matches!(chart_type, ChartType::Pie3d); + let tag = match (doughnut, three_d) { + (true, _) => "doughnutChart", + (false, true) => "pie3DChart", + (false, false) => "pieChart", + }; + s.push_str(&format!("", tag)); + match &group.vary_colors { + Some(v) => s.push_str(&render_unparsed("c:varyColors", v)), + None => s.push_str(""), + } + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + // `c:firstSliceAng` belongs to the flat pie and the doughnut; a + // 3-D pie has no such child. + if !three_d { + push_preserved(s, "c:firstSliceAng", &group.first_slice_ang); + } + if doughnut { + match &group.hole_size { + Some(h) => s.push_str(&render_unparsed("c:holeSize", h)), + None => s.push_str(""), + } + } + s.push_str(&format!("", tag)); + } + ChartType::Radar => { + s.push_str(""); + match &group.radar_style { + Some(st) => s.push_str(&render_unparsed("c:radarStyle", st)), + None => s.push_str(""), + } + push_vary_colors(s, group); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); s.push_str(&format!( "", AX_CAT, AX_VAL )); - s.push_str(""); + s.push_str(""); } - ChartType::Line | ChartType::Area => { - let tag = if matches!(chart_type, ChartType::Area) { - "areaChart" - } else { - "lineChart" - }; - s.push_str(&format!("", tag)); - s.push_str(""); - push_cartesian_series(&mut s, categories_ref, series); + ChartType::Bubble => { + s.push_str(""); + push_vary_colors(s, group); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + push_preserved(s, "c:bubble3D", &group.bubble_3d); + push_preserved(s, "c:bubbleScale", &group.bubble_scale); + push_preserved(s, "c:showNegBubbles", &group.show_neg_bubbles); + push_preserved(s, "c:sizeRepresents", &group.size_represents); s.push_str(&format!( "", AX_CAT, AX_VAL )); - s.push_str(&format!("", tag)); + s.push_str(""); + } + ChartType::Stock => { + // A stock chart is a line group whose series *are* the price + // components, so it carries no `c:grouping` of its own. + s.push_str(""); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + push_preserved(s, "c:dropLines", &group.drop_lines); + push_preserved(s, "c:hiLowLines", &group.hi_low_lines); + push_preserved(s, "c:upDownBars", &group.up_down_bars); + s.push_str(&format!( + "", + AX_CAT, AX_VAL + )); + s.push_str(""); } - ChartType::Pie | ChartType::Doughnut => { - let tag = if matches!(chart_type, ChartType::Doughnut) { - "doughnutChart" + ChartType::OfPie | ChartType::BarOfPie => { + let bar = matches!(chart_type, ChartType::BarOfPie); + s.push_str(""); + s.push_str(&format!( + "", + if bar { "bar" } else { "pie" } + )); + match &group.vary_colors { + Some(v) => s.push_str(&render_unparsed("c:varyColors", v)), + None => s.push_str(""), + } + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); + push_preserved(s, "c:gapWidth", &group.gap_width); + let split = &data.of_pie_split; + if let Some(by) = &split.by { + s.push_str(&format!("", xml_escape(by))); + } + if let Some(pos) = split.pos { + s.push_str(&format!("", pos)); + } + push_preserved(s, "c:custSplit", &group.cust_split); + if let Some(size) = split.second_size { + s.push_str(&format!("", size)); + } + push_preserved_all(s, "c:serLines", &group.ser_lines); + s.push_str(""); + } + ChartType::Surface | ChartType::Surface3d => { + let flat = matches!(chart_type, ChartType::Surface); + let tag = if flat { + "surfaceChart" } else { - "pieChart" + "surface3DChart" }; s.push_str(&format!("", tag)); - s.push_str(""); - push_cartesian_series(&mut s, categories_ref, series); - if matches!(chart_type, ChartType::Doughnut) { - s.push_str(""); - } + push_preserved(s, "c:wireframe", &group.wireframe); + push_series_list(s, categories_ref, series, labels, chart_type); + push_preserved(s, "c:bandFmts", &group.band_fmts); + push_axis_ids(s, chart_type, AX_CAT, AX_VAL, AX_SER); s.push_str(&format!("", tag)); } ChartType::Scatter => { - s.push_str( - "", - ); - push_scatter_series(&mut s, categories_ref, series); + s.push_str(""); + match &group.scatter_style { + Some(st) => s.push_str(&render_unparsed("c:scatterStyle", st)), + None => s.push_str(""), + } + push_vary_colors(s, group); + push_series_list(s, categories_ref, series, labels, chart_type); + push_data_labels(s, labels); s.push_str(&format!( "", AX_CAT, AX_VAL @@ -753,73 +2240,317 @@ pub fn build_chart_xml( s.push_str(""); } } +} - if cartesian { - s.push_str(&format!( - "", - AX_CAT, AX_VAL - )); - s.push_str(&format!( - "", - AX_VAL, AX_CAT - )); - } else if is_scatter { +/// The axis ids a plot group declares: the usual pair, plus the depth axis for +/// the kinds that have one. +fn push_axis_ids(s: &mut String, kind: &ChartType, cat: u64, val: u64, ser: u64) { + s.push_str(&format!( + "", + cat, val + )); + if kind.needs_series_axis() { + s.push_str(&format!("", ser)); + } +} + +fn push_vary_colors(s: &mut String, group: &PreservedGroup) { + match &group.vary_colors { + Some(v) => s.push_str(&render_unparsed("c:varyColors", v)), + None => s.push_str(""), + } +} + +/// Which axis is being written. `ScatterX` is a value axis that sits where the +/// category axis normally would, and so takes the category axis' settings. +enum Axis { + Cat, + Val, + ScatterX, + /// A surface chart's third axis (`c:serAx`). + Ser, +} + +/// One axis element, in schema order: our generated identity and scale, then +/// everything the file had that this model does not interpret. +fn push_axis(s: &mut String, which: Axis, ax_id: u64, cross_ax: u64, data: &ChartData) { + let (tag, kept, scale, title, num_fmt, default_pos) = match which { + Axis::Cat => ( + "c:catAx", + &data.preserved.cat_axis, + &data.cat_axis_scale, + data.cat_axis_title.as_deref(), + None, + "b", + ), + Axis::ScatterX => ( + "c:valAx", + &data.preserved.cat_axis, + &data.cat_axis_scale, + data.cat_axis_title.as_deref(), + None, + "b", + ), + Axis::Val => ( + "c:valAx", + &data.preserved.val_axis, + &data.val_axis_scale, + data.val_axis_title.as_deref(), + data.val_axis_num_fmt.as_deref(), + "l", + ), + Axis::Ser => ( + "c:serAx", + &data.preserved.ser_axis, + &data.cat_axis_scale, + None, + None, + "b", + ), + }; + s.push_str(&format!("<{}>", tag, ax_id)); + // The series axis indexes series, not values, so it is always automatic. + if matches!(which, Axis::Ser) { + push_scaling(s, &AxisScale::default()); + } else { + push_scaling(s, scale); + } + match &kept.delete { + Some(d) => s.push_str(&render_unparsed("c:delete", d)), + None => s.push_str(""), + } + match &kept.ax_pos { + Some(p) => s.push_str(&render_unparsed("c:axPos", p)), + None => s.push_str(&format!("", default_pos)), + } + match (&kept.major_gridlines, matches!(which, Axis::Val)) { + (Some(g), _) => s.push_str(&render_unparsed("c:majorGridlines", g)), + // A fresh value axis gets gridlines, which is Excel's default. + (None, true) => s.push_str(""), + (None, false) => {} + } + push_preserved(s, "c:minorGridlines", &kept.minor_gridlines); + push_axis_title(s, title); + if let Some(fmt) = num_fmt { s.push_str(&format!( - "", - AX_CAT, AX_VAL + "", + xml_escape(fmt) )); + } + push_preserved(s, "c:majorTickMark", &kept.major_tick_mark); + push_preserved(s, "c:minorTickMark", &kept.minor_tick_mark); + push_preserved(s, "c:tickLblPos", &kept.tick_lbl_pos); + push_preserved(s, "c:spPr", &kept.sp_pr); + push_preserved(s, "c:txPr", &kept.tx_pr); + s.push_str(&format!("", cross_ax)); + // `crosses` and `crossesAt` are mutually exclusive. + match (&kept.crosses, &kept.crosses_at) { + (Some(c), _) => s.push_str(&render_unparsed("c:crosses", c)), + (None, Some(c)) => s.push_str(&render_unparsed("c:crossesAt", c)), + (None, None) => {} + } + if matches!(which, Axis::Ser) { + push_preserved(s, "c:tickLblSkip", &kept.tick_lbl_skip); + push_preserved(s, "c:tickMarkSkip", &kept.tick_mark_skip); + } else if matches!(which, Axis::Cat) { + push_preserved(s, "c:auto", &kept.auto); + push_preserved(s, "c:lblAlgn", &kept.lbl_algn); + push_preserved(s, "c:lblOffset", &kept.lbl_offset); + push_preserved(s, "c:tickLblSkip", &kept.tick_lbl_skip); + push_preserved(s, "c:tickMarkSkip", &kept.tick_mark_skip); + push_preserved(s, "c:noMultiLvlLbl", &kept.no_multi_lvl_lbl); + } else { + push_preserved(s, "c:crossBetween", &kept.cross_between); + if let Some(u) = scale.major_unit { + s.push_str(&format!("", u)); + } + if let Some(u) = scale.minor_unit { + s.push_str(&format!("", u)); + } + } + s.push_str(&format!("", tag.trim_start_matches('<'))); +} + +/// `c:scaling` — required on every axis, so it is always written even when the +/// scale is entirely automatic. +fn push_scaling(s: &mut String, scale: &AxisScale) { + s.push_str(""); + if let Some(b) = scale.log_base { + s.push_str(&format!("", b)); + } + s.push_str(&format!( + "", + if scale.reversed { "maxMin" } else { "minMax" } + )); + if let Some(m) = scale.max { + s.push_str(&format!("", m)); + } + if let Some(m) = scale.min { + s.push_str(&format!("", m)); + } + s.push_str(""); +} + +fn push_axis_title(s: &mut String, title: Option<&str>) { + let Some(t) = title.filter(|t| !t.is_empty()) else { + return; + }; + s.push_str(""); + s.push_str(&xml_escape(t)); + s.push_str(""); +} + +/// `c:dLbls` for the plot group. Written only when something is shown — an +/// all-off element is legal but noise, and its absence means the same thing. +fn push_data_labels(s: &mut String, labels: &DataLabels) { + if !labels.any() { + return; + } + s.push_str(""); + if let Some(fmt) = &labels.num_fmt { s.push_str(&format!( - "", - AX_VAL, AX_CAT + "", + xml_escape(fmt) )); } + if let Some(pos) = &labels.position { + s.push_str(&format!("", xml_escape(pos))); + } + let b = |v: bool| if v { "1" } else { "0" }; + s.push_str(&format!( + "", + b(labels.show_legend_key), + b(labels.show_value), + b(labels.show_category), + b(labels.show_series), + b(labels.show_percent), + )); + s.push_str(""); +} - s.push_str(""); - s.push_str(""); - s.push_str(""); - s.push_str(""); - s +/// The series' shape properties: the authored `c:spPr` verbatim when it is +/// still valid, or a minimal fill when the editor set a color (which clears +/// the preserved subtree, since it no longer describes the series). +fn push_series_shape(s: &mut String, ser: &ChartSeries) { + if let Some(raw) = &ser.preserved.sp_pr { + s.push_str(&render_unparsed("c:spPr", raw)); + return; + } + let Some(color) = &ser.color else { return }; + s.push_str(""); + match color { + SeriesColor::Srgb(hex) => s.push_str(&format!("", xml_escape(hex))), + SeriesColor::Scheme(name) => { + s.push_str(&format!("", xml_escape(name))) + } + } + s.push_str(""); } -fn push_cartesian_series(s: &mut String, categories_ref: Option<&str>, series: &[NewChartSeries]) { - for (i, ser) in series.iter().enumerate() { - s.push_str(""); - s.push_str(&format!("", i, i)); - if let Some(name) = &ser.name { - s.push_str(""); - s.push_str(&xml_escape(name)); - s.push_str(""); +/// The `idx`/`order`/`tx`/`spPr` head shared by every series flavor, plus the +/// per-kind bits the file carried (markers, exploded slices, per-point +/// formatting) in schema order. +fn push_series_head( + s: &mut String, + i: usize, + ser: &ChartSeries, + labels: &DataLabels, + kind: &ChartType, +) { + s.push_str(""); + s.push_str(&format!("", i, i)); + if let Some(name) = &ser.name { + s.push_str(""); + s.push_str(&xml_escape(name)); + s.push_str(""); + } + push_series_shape(s, ser); + let kept = &ser.preserved; + // A surface series is just a row of the grid: the schema gives it nothing + // between the shape properties and the data, not even labels. + if kind.is_surface() { + return; + } + match &kind.flattened() { + ChartType::Col | ChartType::Bar | ChartType::Bubble => { + push_preserved(s, "c:invertIfNegative", &kept.invert_if_negative) } - if let Some(cat) = categories_ref { - s.push_str(""); - s.push_str(&xml_escape(cat)); - s.push_str(""); + ChartType::Line | ChartType::Scatter | ChartType::Radar | ChartType::Stock => { + push_preserved(s, "c:marker", &kept.marker) } - s.push_str(""); - s.push_str(&xml_escape(&ser.value_ref)); - s.push_str(""); - s.push_str(""); + ChartType::Pie | ChartType::Doughnut | ChartType::OfPie | ChartType::BarOfPie => { + push_preserved(s, "c:explosion", &kept.explosion) + } + ChartType::Area => {} + // `flattened()` maps every 3-D kind onto one of the above, and the + // surfaces returned early. + _ => {} + } + push_preserved_all(s, "c:dPt", &kept.d_pt); + // Per-series labels mirror the group setting: readers that only look at + // the series (and our own fallback path) then agree with the group. + push_data_labels(s, labels); + // An of-pie series carries neither, and a pie series never has them. + if !kind.is_of_pie() && !matches!(kind, ChartType::Pie | ChartType::Doughnut) { + push_preserved_all(s, "c:trendline", &kept.trendline); + push_preserved(s, "c:errBars", &kept.err_bars); } } -fn push_scatter_series(s: &mut String, x_ref: Option<&str>, series: &[NewChartSeries]) { - for (i, ser) in series.iter().enumerate() { - s.push_str(""); - s.push_str(&format!("", i, i)); - if let Some(name) = &ser.name { - s.push_str(""); - s.push_str(&xml_escape(name)); - s.push_str(""); +/// The trailing child a series may carry: `c:smooth` on a line/scatter, +/// `c:bubble3D` on a bubble. +fn push_series_tail(s: &mut String, ser: &ChartSeries, kind: &ChartType) { + // A 3-D line has no `c:smooth`; everything else follows its flat form. + match &kind.flattened() { + ChartType::Line | ChartType::Scatter | ChartType::Stock if !kind.is_3d() => { + push_preserved(s, "c:smooth", &ser.preserved.smooth) } - if let Some(x) = x_ref { - s.push_str(""); - s.push_str(&xml_escape(x)); - s.push_str(""); + ChartType::Bubble => push_preserved(s, "c:bubble3D", &ser.preserved.bubble_3d), + _ => {} + } + s.push_str(""); +} + +/// The `c:ser` list. `kind` decides which optional children are legal, so a +/// setting that only exists for one chart kind is not carried into another. +fn push_series_list( + s: &mut String, + cat_ref: Option<&str>, + series: &[(usize, &ChartSeries)], + labels: &DataLabels, + kind: &ChartType, +) { + // Scatter and bubble address their points as (x, y) pairs; everything else + // pairs a category label with a value. + let xy = matches!(kind, ChartType::Scatter | ChartType::Bubble); + for (idx, ser) in series.iter() { + let Some(val_ref) = &ser.val_ref else { + continue; + }; + // `idx`/`order` are the series' position in the chart as a whole, not + // within this group — that is what keeps a combo chart's order stable. + push_series_head(s, *idx, ser, labels, kind); + let (cat_tag, val_tag, cat_wrapper) = if xy { + ("c:xVal", "c:yVal", "c:numRef") + } else { + ("c:cat", "c:val", "c:strRef") + }; + if let Some(cat) = cat_ref { + s.push_str(&format!("<{}><{}>", cat_tag, cat_wrapper)); + s.push_str(&xml_escape(cat)); + s.push_str(&format!("", cat_wrapper, cat_tag)); } - s.push_str(""); - s.push_str(&xml_escape(&ser.value_ref)); - s.push_str(""); - s.push_str(""); + s.push_str(&format!("<{}>", val_tag)); + s.push_str(&xml_escape(val_ref)); + s.push_str(&format!("", val_tag)); + if matches!(kind, ChartType::Bubble) { + if let Some(size) = &ser.size_ref { + s.push_str(""); + s.push_str(&xml_escape(size)); + s.push_str(""); + } + } + push_series_tail(s, ser, kind); } } @@ -874,29 +2605,772 @@ mod tests { #[test] fn build_chart_xml_round_trips_through_parser() { let series = vec![ - NewChartSeries { - name: Some("Revenue".to_string()), - value_ref: "Sheet1!$B$2:$E$2".to_string(), - }, - NewChartSeries { - name: Some("Cost & ".to_string()), // exercises escaping - value_ref: "Sheet1!$B$3:$E$3".to_string(), - }, + ChartSeries::new(Some("Revenue".to_string()), "Sheet1!$B$2:$E$2".to_string()), + // Exercises escaping. + ChartSeries::new( + Some("Cost & ".to_string()), + "Sheet1!$B$3:$E$3".to_string(), + ), ]; - let xml = build_chart_xml( - &ChartType::Col, - Some("Quarterly"), - Some("Sheet1!$B$1:$E$1"), - &series, + let data = ChartData::new( + ChartType::Col, + Some("Quarterly".to_string()), + Some("Sheet1!$B$1:$E$1".to_string()), + series, + ); + let xml = build_chart_xml(&data); + let out = parse_chart(xml.as_bytes()).expect("generated chart parses"); + assert_eq!(out.chart_type, ChartType::Col); + assert_eq!(out.title.as_deref(), Some("Quarterly")); + assert_eq!(out.legend_pos, Some(LegendPos::Bottom)); + assert_eq!(out.series.len(), 2); + assert_eq!(out.series[0].name.as_deref(), Some("Revenue")); + assert_eq!(out.series[0].val_ref.as_deref(), Some("Sheet1!$B$2:$E$2")); + assert_eq!(out.series[1].name.as_deref(), Some("Cost & ")); + assert_eq!(out.cat_ref.as_deref(), Some("Sheet1!$B$1:$E$1")); + assert!(!out.data_labels.any()); + } + + /// Everything the editor can change must survive build → parse, otherwise + /// an edit would silently drop the setting it did not touch. + #[test] + fn build_chart_xml_round_trips_full_settings() { + let mut data = ChartData::new( + ChartType::Bar, + Some("Sales".to_string()), + Some("Sheet1!$A$2:$A$5".to_string()), + vec![ + ChartSeries::new(Some("2025".to_string()), "Sheet1!$B$2:$B$5".to_string()), + ChartSeries::new(Some("2026".to_string()), "Sheet1!$C$2:$C$5".to_string()), + ], + ); + data.stacked = true; + data.legend_pos = Some(LegendPos::Right); + data.cat_axis_title = Some("Region".to_string()); + data.val_axis_title = Some("Amount".to_string()); + data.val_axis_num_fmt = Some("#,##0".to_string()); + data.data_labels = DataLabels { + show_value: true, + show_category: false, + show_series: false, + show_percent: true, + show_legend_key: false, + position: Some("ctr".to_string()), + num_fmt: Some("0.0%".to_string()), + }; + data.series[0].color = Some(SeriesColor::Srgb("FF0000".to_string())); + data.series[1].color = Some(SeriesColor::Scheme("accent2".to_string())); + + let out = parse_chart(build_chart_xml(&data).as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::Bar); + assert!(out.stacked); + assert_eq!(out.legend_pos, Some(LegendPos::Right)); + assert_eq!(out.cat_axis_title.as_deref(), Some("Region")); + assert_eq!(out.val_axis_title.as_deref(), Some("Amount")); + assert_eq!(out.val_axis_num_fmt.as_deref(), Some("#,##0")); + assert_eq!(out.data_labels, data.data_labels); + assert_eq!( + out.series[0].color, + Some(SeriesColor::Srgb("FF0000".to_string())) + ); + assert_eq!( + out.series[1].color, + Some(SeriesColor::Scheme("accent2".to_string())) + ); + } + + /// The whole point of [`PreservedXml`]: re-authoring a chart that came + /// from Excel must not strip the styling this model does not understand. + /// Everything asserted here is XML we never interpret. + #[test] + fn rebuilding_keeps_unmodeled_styling() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let wb = crate::workbook::Wb::from_file(&buf).unwrap(); + let bytes = wb + .xl + .worksheets + .values() + .filter_map(|w| w.drawing.as_ref()) + .flat_map(|d| d.chart_parts.iter()) + .find(|p| p.path.ends_with("chart1.xml")) + .map(|p| p.data.clone()) + .expect("chart part present"); + + let mut data = parse_chart(&bytes).expect("parses"); + // A realistic edit: change the kind and add a title. + data.chart_type = ChartType::Line; + data.title = Some("Edited".to_string()); + let out = build_chart_xml(&data); + + // Chart-area fill and border, and the workbook-wide default font. + assert!( + out.contains(r#""#), + "chart area fill kept" ); - let data = parse_chart(xml.as_bytes()).expect("generated chart parses"); + assert!(out.contains(""), "print settings kept"); + assert!( + out.contains("mc:AlternateContent"), + "the built-in style id kept" + ); + assert!(out.contains(r#""#), "date1904 kept"); + assert!(out.contains(r#""#), "lang kept"); + // Fonts: the title's and the axes' text properties. + assert!( + out.contains(r#""), + "styled gridlines kept" + ); + assert!( + out.contains(r#""#), + "tick marks kept" + ); + assert!( + out.contains(r#""#), + "tick label position kept" + ); + assert!( + out.contains(r#""#), + "category label offset kept" + ); + assert!( + out.contains(r#""#), + "axis crossing kept" + ); + // Series shape properties survive whole — not reduced to a fill. + assert!( + out.contains( + r#""# + ), + "series line + effects kept, not just the fill" + ); + // Legend text properties. + assert!(out.contains(""), "legend font kept"); + + // And it is still a valid chart afterwards. + let re = parse_chart(out.as_bytes()).expect("rebuilt chart parses"); + assert_eq!(re.chart_type, ChartType::Line); + assert_eq!(re.title.as_deref(), Some("Edited")); + assert_eq!(re.series.len(), 3); + assert_eq!( + re.series[0].color, + Some(SeriesColor::Scheme("accent1".to_string())) + ); + } + + /// Settings that only make sense for one chart kind are written back only + /// while that kind is in use — a bar chart's gap width must not leak into + /// a pie. + #[test] + fn kind_specific_settings_are_scoped() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let wb = crate::workbook::Wb::from_file(&buf).unwrap(); + let bytes = wb + .xl + .worksheets + .values() + .filter_map(|w| w.drawing.as_ref()) + .flat_map(|d| d.chart_parts.iter()) + .find(|p| p.path.ends_with("chart1.xml")) + .map(|p| p.data.clone()) + .unwrap(); + let mut data = parse_chart(&bytes).unwrap(); + + let as_bar = build_chart_xml(&data); + assert!( + as_bar.contains(r#""#), + "gap width kept" + ); + assert!(as_bar.contains(r#""#), "overlap kept"); + assert!( + as_bar.contains(r#""#), + "bar-only series flag kept" + ); + + data.chart_type = ChartType::Pie; + let as_pie = build_chart_xml(&data); + assert!(!as_pie.contains("gapWidth"), "gap width dropped for a pie"); + assert!(!as_pie.contains("overlap"), "overlap dropped for a pie"); + assert!( + !as_pie.contains("invertIfNegative"), + "bar-only series flag dropped for a pie" + ); + assert!(parse_chart(as_pie.as_bytes()).is_some(), "still parses"); + + // Stacking forces full overlap regardless of what the file said. + data.chart_type = ChartType::Col; + data.stacked = true; + let stacked = build_chart_xml(&data); + assert!(stacked.contains(r#""#)); + assert!(!stacked.contains(r#""#)); + } + + #[test] + fn axis_scale_round_trips() { + let mut data = ChartData::new( + ChartType::Col, + None, + None, + vec![ChartSeries::new(None, "Sheet1!$B$2:$B$5".to_string())], + ); + data.val_axis_scale = AxisScale { + min: Some(-5.0), + max: Some(120.5), + log_base: Some(10.0), + reversed: true, + major_unit: Some(20.0), + minor_unit: Some(5.0), + }; + let out = parse_chart(build_chart_xml(&data).as_bytes()).expect("parses"); + assert_eq!(out.val_axis_scale, data.val_axis_scale); + // An untouched axis stays fully automatic. + assert_eq!(out.cat_axis_scale, AxisScale::default()); + } + + /// Setting a color replaces the series' shape properties; leaving colors + /// alone keeps them byte-for-byte. + #[test] + fn setting_a_color_replaces_the_series_fill() { + let buf = std::fs::read("../../tests/graph.xlsx").unwrap(); + let wb = crate::workbook::Wb::from_file(&buf).unwrap(); + let bytes = wb + .xl + .worksheets + .values() + .filter_map(|w| w.drawing.as_ref()) + .flat_map(|d| d.chart_parts.iter()) + .find(|p| p.path.ends_with("chart1.xml")) + .map(|p| p.data.clone()) + .unwrap(); + let mut data = parse_chart(&bytes).unwrap(); + data.series[0].color = Some(SeriesColor::Srgb("FF0000".to_string())); + data.series[0].preserved.sp_pr = None; + + let out = build_chart_xml(&data); + let re = parse_chart(out.as_bytes()).unwrap(); + assert_eq!( + re.series[0].color, + Some(SeriesColor::Srgb("FF0000".to_string())) + ); + // The other series kept theirs, effects and all. + assert!(out.contains( + r#""# + )); + } + + /// Radar plots categories as spokes, so it keeps the cat/val axis pair and + /// the `c:cat`/`c:val` series shape. + #[test] + fn radar_chart_round_trips() { + let mut data = ChartData::new( + ChartType::Radar, + Some("Skills".to_string()), + Some("Sheet1!$A$2:$A$6".to_string()), + vec![ + ChartSeries::new(Some("Me".to_string()), "Sheet1!$B$2:$B$6".to_string()), + ChartSeries::new(Some("Team".to_string()), "Sheet1!$C$2:$C$6".to_string()), + ], + ); + data.data_labels.show_value = true; + + let xml = build_chart_xml(&data); + assert!(xml.contains("")); + assert!(xml.contains(r#""#)); + assert!(xml.contains(""), "radar keeps a category axis"); + assert!(xml.contains("")); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::Radar); + assert_eq!(out.title.as_deref(), Some("Skills")); + assert_eq!(out.series.len(), 2); + assert_eq!(out.series[1].val_ref.as_deref(), Some("Sheet1!$C$2:$C$6")); + assert_eq!(out.cat_ref.as_deref(), Some("Sheet1!$A$2:$A$6")); + assert!(out.data_labels.show_value); + } + + /// Bubble carries a third reference per series and, like scatter, two value + /// axes rather than a category axis. + #[test] + fn bubble_chart_round_trips() { + let mut series = ChartSeries::new(Some("Products".to_string()), "Sheet1!$C$2:$C$6".into()); + series.size_ref = Some("Sheet1!$D$2:$D$6".to_string()); + let data = ChartData::new( + ChartType::Bubble, + None, + Some("Sheet1!$B$2:$B$6".to_string()), + vec![series], + ); + + let xml = build_chart_xml(&data); + assert!(xml.contains("")); + assert!(!xml.contains(""), "bubble has two value axes"); + assert_eq!(xml.matches("").count(), 2); + assert!(xml.contains(""), "X is numeric"); + assert!(xml.contains("")); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::Bubble); + assert_eq!(out.series[0].val_ref.as_deref(), Some("Sheet1!$C$2:$C$6")); + assert_eq!(out.series[0].size_ref.as_deref(), Some("Sheet1!$D$2:$D$6")); + assert_eq!(out.cat_ref.as_deref(), Some("Sheet1!$B$2:$B$6")); + } + + /// Switching an existing chart to bubble and back must not strip the size + /// reference, and bubble-only settings must not leak into other kinds. + #[test] + fn bubble_settings_are_scoped() { + let mut series = ChartSeries::new(None, "Sheet1!$C$2:$C$6".to_string()); + series.size_ref = Some("Sheet1!$D$2:$D$6".to_string()); + let mut data = ChartData::new(ChartType::Bubble, None, None, vec![series]); + + // The size ref survives a trip through another kind, because the model + // keeps it even while the written XML has nowhere to put it. + data.chart_type = ChartType::Col; + let as_col = build_chart_xml(&data); + assert!(!as_col.contains("bubbleSize"), "no bubbleSize on a column"); + assert!(!as_col.contains("bubbleScale")); + assert_eq!(data.series[0].size_ref.as_deref(), Some("Sheet1!$D$2:$D$6")); + + data.chart_type = ChartType::Bubble; + let back = parse_chart(build_chart_xml(&data).as_bytes()).unwrap(); + assert_eq!(back.series[0].size_ref.as_deref(), Some("Sheet1!$D$2:$D$6")); + } + + /// Stock is a line group whose series are the price components. It must + /// not gain a `c:grouping` (the schema has none) and keeps the drawn + /// connectors between series. + #[test] + fn stock_chart_round_trips() { + let data = ChartData::new( + ChartType::Stock, + Some("ACME".to_string()), + Some("Sheet1!$A$2:$A$6".to_string()), + vec![ + ChartSeries::new(Some("Open".into()), "Sheet1!$B$2:$B$6".into()), + ChartSeries::new(Some("High".into()), "Sheet1!$C$2:$C$6".into()), + ChartSeries::new(Some("Low".into()), "Sheet1!$D$2:$D$6".into()), + ChartSeries::new(Some("Close".into()), "Sheet1!$E$2:$E$6".into()), + ], + ); + let xml = build_chart_xml(&data); + assert!(xml.contains("")); + assert!(!xml.contains("") && xml.contains("")); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::Stock); + assert_eq!(out.series.len(), 4); + assert_eq!(out.series[3].name.as_deref(), Some("Close")); + assert_eq!(out.series[3].val_ref.as_deref(), Some("Sheet1!$E$2:$E$6")); + } + + /// Of-pie writes the split that decides which points land in the second + /// plot; without it the chart is a plain pie on reload. + #[test] + fn of_pie_round_trips_with_its_split() { + let mut data = ChartData::new( + ChartType::OfPie, + None, + Some("Sheet1!$A$2:$A$8".to_string()), + vec![ChartSeries::new(None, "Sheet1!$B$2:$B$8".to_string())], + ); + data.of_pie_split = OfPieSplit { + by: Some("pos".to_string()), + pos: Some(3.0), + second_size: Some(75.0), + }; + + let xml = build_chart_xml(&data); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + assert!(xml.contains(r#""#)); + assert!(!xml.contains(""), "of-pie has no axes"); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::OfPie); + assert_eq!(out.of_pie_split, data.of_pie_split); + + // The bar form differs only in `ofPieType`, and must survive as such. + data.chart_type = ChartType::BarOfPie; + let bar = parse_chart(build_chart_xml(&data).as_bytes()).expect("parses"); + assert_eq!(bar.chart_type, ChartType::BarOfPie); + assert_eq!(bar.of_pie_split.by.as_deref(), Some("pos")); + } + + /// A surface needs three axes; emitting only the usual pair produces a + /// file Excel refuses. Its series also take no labels or markers. + #[test] + fn surface_chart_round_trips_with_three_axes() { + let mut data = ChartData::new( + ChartType::Surface, + None, + Some("Sheet1!$B$1:$E$1".to_string()), + vec![ + ChartSeries::new(Some("r1".into()), "Sheet1!$B$2:$E$2".into()), + ChartSeries::new(Some("r2".into()), "Sheet1!$B$3:$E$3".into()), + ], + ); + // Labels are on, but a surface series has nowhere to put them. + data.data_labels.show_value = true; + + let xml = build_chart_xml(&data); + assert!(xml.contains("")); + // Three ids listed by the group, plus one on each of the three axis + // elements it points at. + assert_eq!(xml.matches(""), "the third axis is written"); + assert!( + !xml[xml.find("").unwrap()..xml.find("").unwrap()].contains(""), + "a surface series carries no labels" + ); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ChartType::Surface); + assert_eq!(out.series.len(), 2); + + // The 3-D form is a different element and must come back as one. + data.chart_type = ChartType::Surface3d; + let xml3 = build_chart_xml(&data); + assert!(xml3.contains("")); + assert_eq!( + parse_chart(xml3.as_bytes()).unwrap().chart_type, + ChartType::Surface3d + ); + } + + /// Switching between the new kinds must not carry one kind's required + /// children into another, which would make the XML invalid. + #[test] + fn new_kinds_do_not_leak_settings() { + let mut data = ChartData::new( + ChartType::OfPie, + None, + None, + vec![ChartSeries::new(None, "Sheet1!$B$2:$B$8".to_string())], + ); + data.of_pie_split = OfPieSplit { + by: Some("val".to_string()), + pos: Some(10.0), + second_size: Some(60.0), + }; + + for ty in [ + ChartType::Col, + ChartType::Pie, + ChartType::Stock, + ChartType::Surface, + ] { + data.chart_type = ty.clone(); + let xml = build_chart_xml(&data); + assert!(!xml.contains("ofPieType"), "{:?} kept ofPieType", ty); + assert!(!xml.contains("splitType"), "{:?} kept splitType", ty); + assert!(!xml.contains("secondPieSize"), "{:?} kept the size", ty); + assert!( + parse_chart(xml.as_bytes()).is_some(), + "{:?} still parses", + ty + ); + } + // Only a surface writes the third axis. + data.chart_type = ChartType::Stock; + assert!(!build_chart_xml(&data).contains("")); + // And the split survives in the model, so switching back restores it. + data.chart_type = ChartType::OfPie; + let back = parse_chart(build_chart_xml(&data).as_bytes()).unwrap(); + assert_eq!(back.of_pie_split.pos, Some(10.0)); + } + + /// The 3-D kinds are the flat ones in a different element, with a depth + /// axis. What matters is that each writes its own tag, that the ones with + /// depth get three axes, and that a flat-only child never leaks in. + #[test] + fn three_d_kinds_round_trip() { + let series = || { + vec![ + ChartSeries::new(Some("a".into()), "Sheet1!$B$2:$E$2".into()), + ChartSeries::new(Some("b".into()), "Sheet1!$B$3:$E$3".into()), + ] + }; + let cases = [ + (ChartType::Col3d, "bar3DChart", true), + (ChartType::Bar3d, "bar3DChart", true), + (ChartType::Line3d, "line3DChart", true), + (ChartType::Area3d, "area3DChart", true), + (ChartType::Pie3d, "pie3DChart", false), + ]; + for (ty, tag, has_depth_axis) in cases { + let data = ChartData::new( + ty.clone(), + Some("T".to_string()), + Some("Sheet1!$B$1:$E$1".to_string()), + series(), + ); + let xml = build_chart_xml(&data); + assert!(xml.contains(&format!("", tag)), "{:?} tag", ty); + assert_eq!( + xml.contains(""), + has_depth_axis, + "{:?} depth axis", + ty + ); + // A 3-D bar chart has no overlap, and a 3-D pie no start angle. + assert!(!xml.contains(""), "{:?} axes", ty); + } + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!(out.chart_type, ty, "{:?} survives the round trip", ty); + assert_eq!(out.series.len(), 2); + assert_eq!(out.series[1].val_ref.as_deref(), Some("Sheet1!$B$3:$E$3")); + } + } + + /// Column and bar share `c:bar3DChart`, told apart by `barDir` exactly as + /// their flat forms are. + #[test] + fn three_d_bar_direction_round_trips() { + for (ty, dir) in [(ChartType::Col3d, "col"), (ChartType::Bar3d, "bar")] { + let data = ChartData::new( + ty.clone(), + None, + None, + vec![ChartSeries::new(None, "Sheet1!$B$2:$E$2".into())], + ); + let xml = build_chart_xml(&data); + assert!(xml.contains(&format!("", dir))); + assert_eq!(parse_chart(xml.as_bytes()).unwrap().chart_type, ty); + } + } + + /// Depth settings belong to the 3-D forms; switching to the flat one must + /// drop them, and switching back must bring them out of the preserved bag. + #[test] + fn depth_settings_are_scoped_to_3d() { + let source = r#" +Sheet1!$B$2:$E$2"#; + let mut data = parse_chart(source.as_bytes()).expect("parses"); + assert_eq!(data.chart_type, ChartType::Col3d); + + let rebuilt = build_chart_xml(&data); + assert!(rebuilt.contains(r#""#), "depth kept"); + assert!( + rebuilt.contains(r#""#), + "shape kept" + ); + + data.chart_type = ChartType::Col; + let flat = build_chart_xml(&data); + assert!(!flat.contains("gapDepth"), "flat bar has no depth"); + assert!(!flat.contains(""), "flat bar has two axes"); + assert!( + flat.contains(r#""#), + "width still kept" + ); + + // Back to 3-D and the depth settings return: they were only hidden. + data.chart_type = ChartType::Col3d; + assert!(build_chart_xml(&data).contains(r#""#)); + } + + /// A combo chart is one plot area holding several groups. Reading it must + /// keep every group's series — before this, everything after the first + /// group was silently dropped on the next edit. + #[test] + fn combo_chart_keeps_every_group() { + let source = r#" +RevenueSheet1!$A$2:$A$5Sheet1!$B$2:$B$5MarginSheet1!$C$2:$C$5"#; + + let data = parse_chart(source.as_bytes()).expect("parses"); + // The first group is the chart's own kind; the second overrides it. assert_eq!(data.chart_type, ChartType::Col); - assert_eq!(data.title.as_deref(), Some("Quarterly")); - assert_eq!(data.legend_pos, Some(LegendPos::Bottom)); - assert_eq!(data.series.len(), 2); + assert_eq!(data.series.len(), 2, "both groups' series are read"); assert_eq!(data.series[0].name.as_deref(), Some("Revenue")); - assert_eq!(data.series[0].val_ref.as_deref(), Some("Sheet1!$B$2:$E$2")); - assert_eq!(data.series[1].name.as_deref(), Some("Cost & ")); - assert_eq!(data.cat_ref.as_deref(), Some("Sheet1!$B$1:$E$1")); + assert_eq!(data.series[0].series_type, None, "follows the chart"); + assert_eq!(data.series[1].name.as_deref(), Some("Margin")); + assert_eq!(data.series[1].series_type, Some(ChartType::Line)); + // Read in `c:order`, not grouped by plot group. + assert_eq!( + data.series + .iter() + .map(|s| s.name.as_deref()) + .collect::>(), + vec![Some("Revenue"), Some("Margin")] + ); + // Categories are found even though only the bar group carries them. + assert_eq!(data.cat_ref.as_deref(), Some("Sheet1!$A$2:$A$5")); + + // And writing it back produces both groups again. + let xml = build_chart_xml(&data); + assert!(xml.contains(""), "bar group written"); + assert!(xml.contains(""), "line group written"); + assert!( + xml.contains(r#""#), + "primary settings kept" + ); + let out = parse_chart(xml.as_bytes()).expect("re-parses"); + assert_eq!(out.series.len(), 2); + assert_eq!(out.series[1].series_type, Some(ChartType::Line)); + assert_eq!(out.series[1].val_ref.as_deref(), Some("Sheet1!$C$2:$C$5")); + } + + /// `idx`/`order` are workbook-wide, so the second group must continue the + /// numbering rather than restart — two series sharing `idx` confuses Excel. + #[test] + fn combo_series_are_numbered_across_groups() { + let mut data = ChartData::new( + ChartType::Col, + None, + None, + vec![ + ChartSeries::new(Some("a".into()), "Sheet1!$B$2:$B$5".into()), + ChartSeries::new(Some("b".into()), "Sheet1!$C$2:$C$5".into()), + ChartSeries::new(Some("c".into()), "Sheet1!$D$2:$D$5".into()), + ], + ); + data.series[2].series_type = Some(ChartType::Line); + + let xml = build_chart_xml(&data); + for i in 0..3 { + assert_eq!( + xml.matches(&format!("", i)).count(), + 1, + "idx {} appears exactly once", + i + ); + } + // The overridden series is the one in the line group. + let line = &xml[xml.find("").unwrap()..]; + assert!(line.contains("Sheet1!$D$2:$D$5")); + assert!(!line.contains("Sheet1!$B$2:$B$5")); + // It keeps its position in the chart (third), not its position within + // the group it was moved into. + assert!(line.contains(r#""#)); + + // Which means the order survives a rebuild: writing and re-reading + // must not bunch the overridden series at the end. + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert_eq!( + out.series + .iter() + .map(|s| s.val_ref.as_deref()) + .collect::>(), + vec![ + Some("Sheet1!$B$2:$B$5"), + Some("Sheet1!$C$2:$C$5"), + Some("Sheet1!$D$2:$D$5"), + ], + "series order is stable across an edit" + ); + assert_eq!(out.series[2].series_type, Some(ChartType::Line)); + } + + /// Only the flat category/value kinds combine. An override that names + /// anything else — or an override on a chart that cannot combine — folds + /// back into the primary group rather than producing invalid XML. + #[test] + fn uncombinable_overrides_fold_into_the_primary() { + let mut data = ChartData::new( + ChartType::Col, + None, + None, + vec![ + ChartSeries::new(None, "Sheet1!$B$2:$B$5".into()), + ChartSeries::new(None, "Sheet1!$C$2:$C$5".into()), + ], + ); + + // A pie cannot share a plot area. + data.series[1].series_type = Some(ChartType::Pie); + let xml = build_chart_xml(&data); + assert!(!xml.contains(""), "no second group"); + assert_eq!(xml.matches("").count(), 2, "both stay in the bar"); + assert!(parse_chart(xml.as_bytes()).is_some()); + + // Nor can a chart that owns its plot area take overrides. + data.chart_type = ChartType::Pie; + data.series[1].series_type = Some(ChartType::Line); + let xml = build_chart_xml(&data); + assert!(!xml.contains("")); + assert_eq!(xml.matches("").count(), 2); + assert!(parse_chart(xml.as_bytes()).is_some()); + } + + /// Stacking describes the group the file carried, so it must not spread to + /// a group that was only created by an override. + #[test] + fn combo_stacking_stays_on_the_primary_group() { + let mut data = ChartData::new( + ChartType::Col, + None, + None, + vec![ + ChartSeries::new(None, "Sheet1!$B$2:$B$5".into()), + ChartSeries::new(None, "Sheet1!$C$2:$C$5".into()), + ], + ); + data.stacked = true; + data.series[1].series_type = Some(ChartType::Line); + + let xml = build_chart_xml(&data); + let bar = &xml[xml.find("").unwrap()..xml.find("").unwrap()]; + let line = &xml[xml.find("").unwrap()..]; + assert!(bar.contains(r#""#)); + assert!( + line.contains(r#""#), + "line is not stacked" + ); + + let out = parse_chart(xml.as_bytes()).expect("parses"); + assert!(out.stacked, "the chart is still a stacked column chart"); + assert_eq!(out.series[1].series_type, Some(ChartType::Line)); + } + + /// A combo chart's series keep the order the file gave them, even though + /// they are read one plot group at a time. Excel plots and lists series by + /// `c:order`, so regrouping them would visibly reshuffle the chart. + #[test] + fn combo_series_keep_their_authored_order() { + // Written as bar(order 0), bar(order 2) then line(order 1) — so the + // line belongs between the two bars. + let source = r#" +firstSheet1!$B$2:$B$5thirdSheet1!$D$2:$D$5secondSheet1!$C$2:$C$5"#; + + let data = parse_chart(source.as_bytes()).expect("parses"); + assert_eq!( + data.series + .iter() + .map(|s| s.name.as_deref()) + .collect::>(), + vec![Some("first"), Some("second"), Some("third")], + "the line series sits between the two bars, as authored" + ); + assert_eq!(data.series[1].series_type, Some(ChartType::Line)); + } + + /// A chart with no legend must stay legend-less through a rebuild. + #[test] + fn build_chart_xml_omits_legend_when_unset() { + let mut data = ChartData::new( + ChartType::Pie, + None, + None, + vec![ChartSeries::new(None, "Sheet1!$B$2:$B$5".to_string())], + ); + data.legend_pos = None; + let out = parse_chart(build_chart_xml(&data).as_bytes()).expect("parses"); + assert_eq!(out.legend_pos, None); + assert_eq!(out.title, None); } } diff --git a/docs/chart.md b/docs/chart.md index f650e0aa..c5ceb81e 100644 --- a/docs/chart.md +++ b/docs/chart.md @@ -15,7 +15,7 @@ need no proprietary migration. xl/charts/chartN.xml (c:chartSpace) ← source of truth, in the .xlsx ├─ crates/workbook parse + serialize + lossless round-trip ├─ crates/controller ChartManager (in Status; undo/redo via snapshot) - │ ├─ payloads CreateChart / MoveChart / DeleteChart + │ ├─ payloads CreateChart / UpdateChart / MoveChart / DeleteChart │ └─ get_charts() → ChartInfo (live values re-read from source ranges) ├─ crates/wasms/server GetCharts RPC + payload dispatch └─ packages/engine ChartLayer in Spreadsheet.svelte, rendered with ECharts @@ -23,7 +23,16 @@ xl/charts/chartN.xml (c:chartSpace) ← source of truth, in the .xlsx - **Source values are read live.** `get_charts` resolves each series' reference (e.g. `Sheet1!$B$2:$E$2`) to current cell values, so editing data updates the - chart. The OOXML `numCache` is only a fallback. + chart. Category labels and number formats are read live the same way. The + OOXML `numCache` is only a fallback. +- **Editing rewrites the chart XML.** `UpdateChart` patches the parsed model and + regenerates `c:chartSpace` from it. Everything the editor understands is + typed; everything else — fills, fonts, gridlines, markers, 3-D settings, + trendlines — is captured as verbatim `Unparsed` subtrees (`PreservedXml`) and + written back untouched, so an edit cannot silently restyle a chart authored in + Excel. The chart's relationships and its sibling `style1.xml` / `colors1.xml` + parts ride along too. Adding a typed field means adding it to *both* + `ChartData` and `build_chart_xml`. - **Anchored by stable `CellId`.** Charts shift with row/column insert/delete, like images. - **Rendering library: ECharts**, bundled in `logisheets-engine` as an external @@ -43,49 +52,146 @@ xl/charts/chartN.xml (c:chartSpace) ← source of truth, in the .xlsx | Create from selection (toolbar 📊) | ✅ | `create_chart_from_scratch` + user | | Save round-trips (incl. moved/created/deleted) | ✅ | tests | | Lossless round-trip of chart part bytes | ✅ | `chart_round_trips` | -| Chart types: column, bar, line, area, pie, doughnut, scatter | ✅ | parser/serializer tests | +| Chart types: all 16 classic `c:*Chart` groups | ✅ | round-trip tests per kind + `new_kinds_do_not_leak_settings` | +| Combo charts (a kind per series) | ✅ | `combo_chart_keeps_every_group`, `create_a_combo_chart_and_keep_it_through_edits` | | Series colors match the workbook theme (scheme + RGB) | ✅ | `chart_reflects_live_data` | -| Reconfigure a chart's type/title (selection dropdown) | ✅ | `update_chart_changes_type_and_title` | - -Undo/redo works for all chart edits (snapshot-based). +| Category labels follow the source cells (formatted) | ✅ | `chart_categories_and_formats_are_live` | +| Data labels (value / category / series name / percent, position) | ✅ | `update_chart_changes_every_setting` + browser | +| Number formats on labels and the value axis | ✅ | same | +| Reconfigure type, title, legend, stacking, axis titles | ✅ | `update_chart_changes_every_setting` | +| Re-point the data (categories, per-series range, name, color) | ✅ | `update_chart_repoints_series_and_keeps_colors` | +| Size-anchored charts (`oneCellAnchor`) draw at their real size | ✅ | browser | +| Insert picks the chart type; labels/series inferred from the selection | ✅ | browser | +| Editing keeps unmodeled styling (fonts, fills, gridlines, markers) | ✅ | `rebuilding_keeps_unmodeled_styling`, `editing_a_chart_keeps_its_styling_and_satellite_parts` | +| Editing keeps the chart's rels and style/colors parts | ✅ | same | +| Value-axis scale: min, max, unit, log base, reverse | ✅ | `update_chart_sets_the_axis_scale`, `axis_scale_round_trips` | + +Undo/redo works for all chart edits (`chart_edits_are_undoable`). Charts live in +`Status`, which the undo stack snapshots whole on every undoable action — which +is why `ChartManager` uses persistent (`imbl`) collections: a snapshot is a +refcount bump, not a deep copy of every chart's preserved XML. For the same +reason `charts_of_sheet` hands out borrows rather than a cloned `Vec`. Series fill colors are resolved from the OOXML: direct RGB passes through, and scheme colors (`accent1..6`, `tx/bg/dk/lt`, hyperlink) map to the workbook -theme, so a loaded chart matches Excel's colors. A selected chart shows a -type-picker dropdown (top-left) that reconfigures it in place via `UpdateChart` -(keeping the anchor and data references); `Engine.updateChart(id, {chartType, -title})` is the programmatic entry point. +theme, so a loaded chart matches Excel's colors. Regenerating the XML on an edit +writes those colors back, so a re-typed or re-pointed chart keeps its palette. + +A selected chart shows a type picker and a ⚙ button; the ⚙ opens the chart +editor (`ChartSettings.svelte`) — type, title, legend position, stacking, axis +titles, value-axis scale (min / max / unit / log base / reverse), data labels, +number format, and the data itself (category range, plus each series' name / +range / color, with add and remove). Every control is one field of +`UpdateChart`, and `Engine.updateChart(id, patch)` is the same thing +programmatically. + +Most `UpdateChart` fields patch (`None` keeps, `""` clears). The axis scale is +the exception: it replaces the scale wholesale, which is the only way to put a +fixed bound back to automatic. + +**Where numbers get formatted.** Data labels and category labels are rendered +core-side with `ssf-rs` and shipped as strings (`ChartSeriesInfo.formattedValues`), +so they match the sheet exactly. Axis ticks are the exception — the renderer +picks the tick values, so the engine formats them with `formatAxisNumber`, a +small subset renderer (separators, decimals, percent, currency affixes). ## What's remaining **Fidelity gaps** -- **Category labels** are not read live (values are); category axis uses the - cached `strCache` labels. +- Fonts, fills, gridline styling and 3-D effects survive an edit but are not + *rendered* — the ECharts layer draws its own defaults. They are preserved for + Excel, not reproduced here. - Color modifiers (`lumMod`/`lumOff` tints on scheme colors) and per-data-point pie slice colors are not applied — base scheme/RGB colors are. - **`numCache` is not written on save.** Compliant readers recompute from the refs; a reader that only trusts the cache would show empty until recalculated. -- **Style/color/font detail** beyond type + title + legend position + axis - titles is not modeled. +- Setting a series color explicitly replaces that series' whole `c:spPr`, so a + custom outline or effect on *that* series is dropped in exchange. Leaving + colors alone keeps it byte-for-byte. +- Series names are literals, not references: renaming the header cell does not + rename the series. +- A secondary value axis is not modeled. **Coverage gaps** -- Only column/bar/line/area/pie/doughnut/scatter. Combo, stock, radar, bubble, - surface, 3-D, etc. are unsupported. +- **All 16 classic `c:*Chart` groups** are read, written and editable. The + `cx:` family (treemap, sunburst, histogram, pareto, box & whisker, + waterfall, funnel, filled map) lives in a different part and is not. +- A **combo chart** is modeled as one whose series disagree: `ChartSeries` + carries an optional `series_type`, and the writer emits one plot group per + distinct kind, the chart's own kind first. Only the flat category/value kinds + combine (`is_combinable`); an override Excel could not honour folds back into + the primary group rather than producing a file it would refuse. Series keep + the order `c:order` gave them, not the order the groups were read in. +- Per-group settings of a combo's *secondary* groups are not modeled — the + file's settings describe the primary group, and the rest get defaults. +- The **3-D kinds render flat** (`col3d` draws as `col`, and so on): the depth + round-trips to Excel but drawing it would mean pulling in `echarts-gl`. +- Some kinds are rendered as the nearest thing ECharts core can draw, which is + a deliberate trade rather than a bug: **stock** becomes a candlestick (an + HLC chart has no open, so open is set to the close), a **surface** becomes a + heatmap of the same grid, and the **3-D** kinds become their flat forms. All + still round-trip to Excel as their proper type. +- A stock chart's series are read **positionally** (4 = open/high/low/close, + 3 = high/low/close); any other count falls back to plain lines. Volume + variants, which put volume on a secondary axis, are not modeled. +- Of-pie honours `splitType` `pos`/`val`/`percent`; `cust` (a hand-assigned + split) falls back to the automatic one, though the `c:custSplit` element is + preserved. +- **The modern "chartEx" family is not read at all**: treemap, sunburst, + histogram, Pareto, box & whisker, waterfall, funnel and filled map live in a + separate part (`xl/charts/chartEx1.xml`, `cx:` namespace, its own + relationship type) that the reader does not pick up. +- **Chart sheets** (a whole sheet that is one chart) are not supported. +- **An unsupported chart is dropped on save.** `parse_chart` returns `None`, the + chart never enters `ChartManager`, and the save path only emits anchors for + charts it holds — so the drawing goes out without it. Verified by hand on a + fixture whose `c:barChart` was swapped for `c:bubbleChart` before bubble was + supported. +- 100% stacked (`percentStacked`) is read as plain `stacked`, so editing such a + chart degrades it to a normal stacked one. - **Non-chart `graphicData` (SmartArt, OLE embeds) does NOT round-trip** — the drawing `graphicFrame` model is chart-only, so such objects are dropped on save. (Plain charts and text-box shapes are preserved.) -**Editing gaps** -- Reconfiguring covers **type and title**; changing the legend position, axis - titles, or the data range of an existing chart still needs UI. -- The insert button only creates a **column** chart; no chart-type picker on - insert (you can switch type after, via the selection dropdown). - **Minor UX** - The mouse wheel does not scroll the grid while the cursor is over a chart (the transparent drag-capture cover swallows it); could re-dispatch wheel to the canvas like the React overlay layers do. +## Selecting a chart + +Clicking a chart selects it, and the cells it plots are outlined on the grid in +the colour of the series they feed — categories dashed, bubble sizes dotted — +so you can see what a chart is reading without opening the editor. The ranges +come from `chart/source-ranges.ts`, which parses the chart's own A1 references. + +One thing it will not draw: a range on another sheet, which the editor lists +instead. A range that has scrolled out of view is skipped too (`isRangeVisible`), +since there is nothing to show. + +Both the outlines and the chart frames are positioned with +`xForColStartUnclamped` / `yForRowStartUnclamped` rather than the plain +helpers. The plain ones only walk the rows and columns the grid has laid out, +so they answer with the window's own edge for anything scrolled past — which +flattens an overlay against the edge instead of letting it scroll away. The +unclamped pair extrapolates at the size of the row/column nearest that edge, +exact whenever the ones outside match it. + +## Inserting from a selection + +`chart/from-selection.ts` turns the selected range into the chart's data +references, the way Excel infers them: a leading column of text becomes the +categories, a leading row of text becomes the series names, every other column +is a series. The corner cell votes for neither edge — the label column is +decided from the rows below it and the header row from the data columns to its +right — so a table with row labels but no header row keeps its first row as +data. A bubble chart reads differently: its first data column is the shared X +and the rest pair up as (Y, size). + +It takes cell lookups as a `SelectionCells` interface rather than the data +service, so the inference is unit-tested without a workbook +(`from-selection.test.ts`). + ## Key files - `crates/workbook/src/ooxml/chart.rs` — parse (`parse_chart`) + generate @@ -93,7 +199,11 @@ title})` is the programmatic entry point. - `crates/workbook/src/ooxml/drawing_part.rs` — `graphicFrame` anchor model. - `crates/controller/src/chart_manager/` — `ChartManager` + executor. - `crates/controller/src/api/worksheet.rs` — `get_charts` (live-value resolution). -- `packages/engine/src/lib/chart/` — ECharts setup + model + renderer. +- `packages/engine/src/lib/chart/` — ECharts setup, model, renderer, + `ChartSettings.svelte` (the editor), `num-format.ts` (axis ticks), + `from-info.ts` (binding → model), `from-selection.ts` (insert inference) and + `source-ranges.ts` (the source outlines). The non-Svelte modules are the + engine's tested seam. - `packages/engine/src/lib/components/Spreadsheet.svelte` — `ChartLayer`, select/move/resize/delete, `insertChart`. -- `src/components/toolbar/index.tsx` — Insert Chart button. +- `src/components/toolbar/index.tsx` — Insert Chart button + type menu. diff --git a/packages/engine/src/lib/chart/ChartSettings.svelte b/packages/engine/src/lib/chart/ChartSettings.svelte new file mode 100644 index 00000000..9affdbca --- /dev/null +++ b/packages/engine/src/lib/chart/ChartSettings.svelte @@ -0,0 +1,644 @@ + + + + + + + diff --git a/packages/engine/src/lib/chart/echarts.ts b/packages/engine/src/lib/chart/echarts.ts index ac06642e..ff107dce 100644 --- a/packages/engine/src/lib/chart/echarts.ts +++ b/packages/engine/src/lib/chart/echarts.ts @@ -13,8 +13,11 @@ import * as echarts from 'echarts/core' import { BarChart, + CandlestickChart, + HeatmapChart, LineChart, PieChart, + RadarChart, ScatterChart, } from 'echarts/charts' import { @@ -23,6 +26,8 @@ import { TooltipComponent, GridComponent, DatasetComponent, + RadarComponent, + VisualMapComponent, } from 'echarts/components' import {LabelLayout} from 'echarts/features' import {CanvasRenderer} from 'echarts/renderers' @@ -33,13 +38,25 @@ echarts.use([ BarChart, LineChart, PieChart, + // Radar is its own series type; bubble is not — it is a scatter whose + // points carry a `symbolSize`, so it needs no extra module. + RadarChart, ScatterChart, + // Stock renders as a candlestick. A surface has no 2-D equivalent in + // ECharts core (the real thing needs echarts-gl), so it is drawn as a + // heatmap of the same grid — which is what Excel's contour variants are. + CandlestickChart, + HeatmapChart, // Components. TitleComponent, LegendComponent, TooltipComponent, GridComponent, DatasetComponent, + // The radar axis (its spokes and rings) is a component of its own. + RadarComponent, + // The heatmap's colour scale. + VisualMapComponent, // Features. LabelLayout, // Renderer. Canvas only — SVG renderer is intentionally not registered. diff --git a/packages/engine/src/lib/chart/from-info.test.ts b/packages/engine/src/lib/chart/from-info.test.ts new file mode 100644 index 00000000..6d0b85d9 --- /dev/null +++ b/packages/engine/src/lib/chart/from-info.test.ts @@ -0,0 +1,244 @@ +import {describe, expect, it} from 'vitest' +import type {ChartInfo} from 'logisheets-web' +import {chartInfoToModel} from './from-info' + +/** + * A `ChartInfo` as the core sends it. Everything the adapter reads has to be + * present here — a field the adapter forgets is invisible at the type level + * (both sides are optional) and shows up only as a chart that quietly ignores + * one of its settings. + */ +function info(over: Partial = {}): ChartInfo { + return { + chartId: 'chart1', + fromRow: 0, + fromCol: 0, + fromColOff: 0, + fromRowOff: 0, + toRow: 10, + toCol: 5, + toColOff: 0, + toRowOff: 0, + chartType: 'col', + stacked: false, + categories: ['Q1', 'Q2'], + series: [ + { + name: 'Sales', + values: [1, 2], + formattedValues: ['1', '2'], + sizes: [], + }, + ], + dataLabels: { + showValue: false, + showCategory: false, + showSeries: false, + showPercent: false, + showLegendKey: false, + }, + ofPieSplit: {}, + valAxisScale: {reversed: false}, + catAxisScale: {reversed: false}, + ...over, + } as ChartInfo +} + +describe('chartInfoToModel', () => { + it('carries the render fields across', () => { + const model = chartInfoToModel( + info({ + chartType: 'line', + title: 'T', + stacked: true, + legendPos: 'top', + catAxisTitle: 'X', + valAxisTitle: 'Y', + valAxisNumFmt: '#,##0', + catRef: 'Sheet1!$A$2:$A$3', + }) + ) + expect(model).toMatchObject({ + chartId: 'chart1', + chartType: 'line', + title: 'T', + stacked: true, + legendPosition: 'top', + catAxisTitle: 'X', + valAxisTitle: 'Y', + valAxisNumFmt: '#,##0', + catRef: 'Sheet1!$A$2:$A$3', + categories: ['Q1', 'Q2'], + }) + }) + + it('treats a chart with no legend as one that shows none', () => { + expect(chartInfoToModel(info()).legendPosition).toBe('none') + }) + + it('synthesizes 1..n categories when the chart has none', () => { + const model = chartInfoToModel( + info({ + categories: [], + series: [ + { + name: 'S', + values: [5, 6, 7], + formattedValues: ['5', '6', '7'], + sizes: [], + }, + ], + }) + ) + expect(model.categories).toEqual([1, 2, 3]) + }) + + it('keeps the gaps in a series rather than zeroing them', () => { + const model = chartInfoToModel( + info({ + series: [ + { + name: 'S', + // The binding types these as number[]/string[], but the + // core sends nulls for empty cells. + values: [1, null, 3], + formattedValues: ['1', null, '3'], + sizes: [], + } as unknown as ChartInfo['series'][number], + ], + }) + ) + expect(model.series[0].values).toEqual([1, null, 3]) + expect(model.series[0].formattedValues).toEqual(['1', null, '3']) + }) + + it('copies the arrays instead of aliasing the binding', () => { + const source = info() + const model = chartInfoToModel(source) + expect(model.categories).not.toBe(source.categories) + expect(model.series[0].values).not.toBe(source.series[0].values) + }) + + it('maps the data-label flags', () => { + const model = chartInfoToModel( + info({ + dataLabels: { + showValue: true, + showCategory: true, + showSeries: false, + showPercent: true, + showLegendKey: false, + position: 'ctr', + }, + }) + ) + expect(model.dataLabels).toEqual({ + value: true, + category: true, + series: false, + percent: true, + position: 'ctr', + }) + }) + + it('maps the axis scales, including a log axis', () => { + const model = chartInfoToModel( + info({ + valAxisScale: { + min: 0, + max: 80, + majorUnit: 20, + logBase: 10, + reversed: true, + }, + catAxisScale: {reversed: true}, + }) + ) + expect(model.valAxisScale).toEqual({ + min: 0, + max: 80, + majorUnit: 20, + logBase: 10, + reversed: true, + }) + expect(model.catAxisScale?.reversed).toBe(true) + }) + + it('maps the of-pie split', () => { + const model = chartInfoToModel( + info({ + chartType: 'ofPie', + ofPieSplit: {by: 'pos', pos: 2, secondSize: 75}, + }) + ) + expect(model.ofPieSplit).toEqual({by: 'pos', pos: 2, secondSize: 75}) + }) + + it('carries a bubble series’ sizes and its source range', () => { + const model = chartInfoToModel( + info({ + chartType: 'bubble', + series: [ + { + name: 'S', + values: [1, 2], + formattedValues: ['1', '2'], + sizes: [10, 20], + sizeRef: 'Sheet1!$D$2:$D$3', + valRef: 'Sheet1!$C$2:$C$3', + }, + ], + }) + ) + expect(model.series[0].sizes).toEqual([10, 20]) + expect(model.series[0].valRef).toBe('Sheet1!$C$2:$C$3') + }) + + it('carries a combo series’ own kind', () => { + const model = chartInfoToModel( + info({ + series: [ + { + name: 'Bars', + values: [1], + formattedValues: ['1'], + sizes: [], + }, + { + name: 'Line', + values: [2], + formattedValues: ['2'], + sizes: [], + seriesType: 'line', + }, + ], + }) + ) + expect(model.series[0].seriesType).toBeUndefined() + expect(model.series[1].seriesType).toBe('line') + }) + + it('passes a resolved series color through, and undefined when there is none', () => { + const model = chartInfoToModel( + info({ + series: [ + { + name: 'S', + values: [1], + formattedValues: ['1'], + sizes: [], + color: '4472C4', + }, + { + name: 'T', + values: [2], + formattedValues: ['2'], + sizes: [], + }, + ], + }) + ) + expect(model.series[0].color).toBe('4472C4') + expect(model.series[1].color).toBeUndefined() + }) +}) diff --git a/packages/engine/src/lib/chart/from-info.ts b/packages/engine/src/lib/chart/from-info.ts index 73707339..bc20ac3b 100644 --- a/packages/engine/src/lib/chart/from-info.ts +++ b/packages/engine/src/lib/chart/from-info.ts @@ -27,11 +27,28 @@ export function chartInfoToModel(info: ChartInfo): ChartModel { // `values` is typed readonly number[] by the generated binding but // carries nulls at runtime for gaps; the model allows both. values: [...s.values] as Array, + formattedValues: [...s.formattedValues] as Array, color: s.color ?? undefined, + valRef: s.valRef, + seriesType: s.seriesType as ChartType | undefined, + sizes: [...s.sizes] as Array, + sizeRef: s.sizeRef, })), legendPosition: (info.legendPos as LegendPosition | undefined) ?? 'none', stacked: info.stacked, catAxisTitle: info.catAxisTitle, valAxisTitle: info.valAxisTitle, + dataLabels: { + value: info.dataLabels.showValue, + category: info.dataLabels.showCategory, + series: info.dataLabels.showSeries, + percent: info.dataLabels.showPercent, + position: info.dataLabels.position, + }, + valAxisNumFmt: info.valAxisNumFmt, + catRef: info.catRef, + valAxisScale: info.valAxisScale, + catAxisScale: info.catAxisScale, + ofPieSplit: info.ofPieSplit, } } diff --git a/packages/engine/src/lib/chart/from-selection.test.ts b/packages/engine/src/lib/chart/from-selection.test.ts new file mode 100644 index 00000000..27187050 --- /dev/null +++ b/packages/engine/src/lib/chart/from-selection.test.ts @@ -0,0 +1,249 @@ +import {describe, expect, it} from 'vitest' +import {chartDataRefsFromSelection} from './from-selection' +import type {SelectionCells, SelectionRange} from './from-selection' + +/** + * A sheet built from a literal grid, indexed [row][col]. Strings are text + * cells, numbers are numeric, `null` is empty — which is what the header/label + * probe has to tell apart. + */ +function sheet(grid: Array>): SelectionCells { + const at = (r: number, c: number) => grid[r]?.[c] ?? null + return { + isText: async (r, c) => typeof at(r, c) === 'string' && at(r, c) !== '', + textAt: async (r, c) => { + const v = at(r, c) + return v === null || v === '' ? undefined : String(v) + }, + } +} + +const range = ( + startRow: number, + startCol: number, + endRow: number, + endCol: number +): SelectionRange => ({startRow, startCol, endRow, endCol}) + +/** Labels down the left, series names across the top, numbers in between. */ +const TABLE = [ + ['', 'Q1', 'Q2'], + ['North', 1, 2], + ['South', 3, 4], +] + +describe('chartDataRefsFromSelection', () => { + it('reads a leading text column as categories and a text row as names', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 2), + 'Sheet1', + sheet(TABLE) + ) + // Categories skip the header row, so they start at row 2. + expect(refs.categoriesRef).toBe('Sheet1!$A$2:$A$3') + expect(refs.series).toEqual([ + {name: 'Q1', valueRef: 'Sheet1!$B$2:$B$3'}, + {name: 'Q2', valueRef: 'Sheet1!$C$2:$C$3'}, + ]) + }) + + it('keeps the first row as data when only the labels make it text', async () => { + // Row labels but no header row. The corner cell is text, but it is the + // label column's own cell — it must not make the row look like names, + // or the first data row is silently eaten. + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 2), + 'Sheet1', + sheet([ + ['North', 9, 9], + ['South', 1, 2], + ['East', 3, 4], + ]) + ) + expect(refs.series.map((s) => s.name)).toEqual([undefined, undefined]) + // No header row, so the data starts at row 1 and keeps all three rows. + expect(refs.categoriesRef).toBe('Sheet1!$A$1:$A$3') + expect(refs.series[0].valueRef).toBe('Sheet1!$B$1:$B$3') + }) + + it('leaves categories undefined when nothing labels the rows', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 1), + 'Sheet1', + sheet([ + [1, 2], + [3, 4], + [5, 6], + ]) + ) + expect(refs.categoriesRef).toBeUndefined() + expect(refs.series).toHaveLength(2) + expect(refs.series[0].valueRef).toBe('Sheet1!$A$1:$A$3') + }) + + it('has no label column to spare in a single-column selection', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 0), + 'Sheet1', + sheet([['North'], [1], [2]]) + ) + // The one column is the data, and its text first cell is the series + // name — there is no second column for it to label. + expect(refs.categoriesRef).toBeUndefined() + expect(refs.series).toEqual([ + {name: 'North', valueRef: 'Sheet1!$A$2:$A$3'}, + ]) + }) + + it('has no header row to spare in a single-row selection', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 0, 2), + 'Sheet1', + sheet([['North', 1, 2]]) + ) + // One row cannot also be a header row, so nothing is named; the text + // cell still labels the single category. + expect(refs.series.map((s) => s.name)).toEqual([undefined, undefined]) + expect(refs.categoriesRef).toBe('Sheet1!$A$1:$A$1') + }) + + it('accepts a selection given bottom-right to top-left', async () => { + const normal = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 2), + 'Sheet1', + sheet(TABLE) + ) + const reversed = await chartDataRefsFromSelection( + 'col', + range(2, 2, 0, 0), + 'Sheet1', + sheet(TABLE) + ) + expect(reversed).toEqual(normal) + }) + + it('quotes a sheet name that needs it', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 0, 2, 2), + "Bob's Data", + sheet(TABLE) + ) + expect(refs.categoriesRef).toBe("'Bob''s Data'!$A$2:$A$3") + expect(refs.series[0].valueRef).toBe("'Bob''s Data'!$B$2:$B$3") + }) + + it('addresses columns past Z correctly', async () => { + const refs = await chartDataRefsFromSelection( + 'col', + range(0, 25, 1, 27), + 'Sheet1', + sheet([]) + ) + expect(refs.series.map((s) => s.valueRef)).toEqual([ + 'Sheet1!$Z$1:$Z$2', + 'Sheet1!$AA$1:$AA$2', + 'Sheet1!$AB$1:$AB$2', + ]) + }) + + describe('bubble', () => { + // X, then (Y, size) pairs — all numeric, so the generic label-column + // rule must not claim the first column. + const BUBBLE = [ + ['X', 'Sales', 'Size', 'Cost', 'Weight'], + [1, 10, 100, 20, 200], + [2, 11, 110, 21, 210], + ] + + it('takes the first data column as the shared X', async () => { + const refs = await chartDataRefsFromSelection( + 'bubble', + range(0, 0, 2, 4), + 'Sheet1', + sheet(BUBBLE) + ) + expect(refs.categoriesRef).toBe('Sheet1!$A$2:$A$3') + expect(refs.series).toEqual([ + { + name: 'Sales', + valueRef: 'Sheet1!$B$2:$B$3', + sizeRef: 'Sheet1!$C$2:$C$3', + }, + { + name: 'Cost', + valueRef: 'Sheet1!$D$2:$D$3', + sizeRef: 'Sheet1!$E$2:$E$3', + }, + ]) + }) + + it('plots a trailing Y that has no size column', async () => { + const refs = await chartDataRefsFromSelection( + 'bubble', + range(0, 0, 2, 3), + 'Sheet1', + sheet(BUBBLE) + ) + expect(refs.series).toHaveLength(2) + expect(refs.series[1]).toEqual({ + name: 'Cost', + valueRef: 'Sheet1!$D$2:$D$3', + sizeRef: undefined, + }) + }) + + it('keeps a text first column as X rather than as labels', async () => { + // The same shape as a normal chart's label column — a bubble reads + // it as X regardless, because it needs numeric X values. + const refs = await chartDataRefsFromSelection( + 'bubble', + range(0, 0, 2, 2), + 'Sheet1', + sheet(TABLE) + ) + expect(refs.categoriesRef).toBe('Sheet1!$A$2:$A$3') + expect(refs.series).toEqual([ + { + name: 'Q1', + valueRef: 'Sheet1!$B$2:$B$3', + sizeRef: 'Sheet1!$C$2:$C$3', + }, + ]) + }) + + it('falls back to one series when there is only one column', async () => { + const refs = await chartDataRefsFromSelection( + 'bubble', + range(0, 0, 2, 0), + 'Sheet1', + sheet([[1], [2], [3]]) + ) + // Nothing to pair with, so no series rather than a broken one. + expect(refs.series).toHaveLength(0) + expect(refs.categoriesRef).toBeUndefined() + }) + }) + + it('gives every other kind one series per column', async () => { + for (const kind of ['line', 'pie', 'radar', 'stock', 'surface']) { + const refs = await chartDataRefsFromSelection( + kind, + range(0, 0, 2, 2), + 'Sheet1', + sheet(TABLE) + ) + expect(refs.series.map((s) => s.valueRef)).toEqual([ + 'Sheet1!$B$2:$B$3', + 'Sheet1!$C$2:$C$3', + ]) + expect(refs.series.every((s) => s.sizeRef === undefined)).toBe(true) + } + }) +}) diff --git a/packages/engine/src/lib/chart/from-selection.ts b/packages/engine/src/lib/chart/from-selection.ts new file mode 100644 index 00000000..28a20de9 --- /dev/null +++ b/packages/engine/src/lib/chart/from-selection.ts @@ -0,0 +1,143 @@ +/** + * Turns a selected cell range into the data references a new chart needs. + * + * This is the "insert chart" heuristic, and it mirrors what Excel infers: a + * leading column of text becomes the category labels, a leading row of text + * becomes the series names, and every remaining column is one series. A bubble + * chart reads differently — its first data column is the shared X and the rest + * pair up as (Y, size). + * + * It is kept out of the spreadsheet component, and reads cells through + * {@link SelectionCells} rather than the data service, so the inference can be + * exercised without a workbook. + */ +import {quoteSheetName, toA1notation} from '../components/utils' + +/** The cell lookups the inference needs. */ +export interface SelectionCells { + /** Whether this cell holds non-empty text (as opposed to a number). */ + isText(row: number, col: number): Promise + /** The cell's display text, or undefined when it is empty. */ + textAt(row: number, col: number): Promise +} + +export interface SelectionRange { + startRow: number + startCol: number + endRow: number + endCol: number +} + +/** One series of the chart to create. */ +export interface SelectedSeries { + name: string | undefined + valueRef: string + /** Bubble sizes; only a bubble chart produces these. */ + sizeRef?: string +} + +export interface ChartDataRefs { + categoriesRef: string | undefined + series: SelectedSeries[] +} + +/** + * How many cells of a row/column to sample when deciding whether it holds + * labels. Excel looks at the whole edge; a handful is enough in practice and + * keeps the probe cheap on a wide selection. + */ +const PROBE_LIMIT = 5 + +/** True when any of the first few coordinates holds text. */ +async function anyText( + cells: SelectionCells, + coords: Array<[number, number]> +): Promise { + for (const [r, c] of coords.slice(0, PROBE_LIMIT)) { + // A blank cell says nothing either way; a text cell does. + if (await cells.isText(r, c)) return true + } + return false +} + +export async function chartDataRefsFromSelection( + chartType: string, + range: SelectionRange, + sheetName: string, + cells: SelectionCells +): Promise { + const startRow = Math.min(range.startRow, range.endRow) + const endRow = Math.max(range.startRow, range.endRow) + const startCol = Math.min(range.startCol, range.endCol) + const endCol = Math.max(range.startCol, range.endCol) + const qs = quoteSheetName(sheetName) + + // A single row or column has nothing to spare for labels. + // + // The label column is decided first, and from the rows *below* the corner + // cell: the corner belongs to neither edge, and letting it vote made a + // table with row labels but no header row (`North 1 2` / `South 3 4`) read + // its first data row as series names. When the selection is a single row + // there is nothing below the corner, so the corner itself decides. + const labelProbe: Array<[number, number]> = + endRow > startRow + ? Array.from({length: endRow - startRow}, (_, i) => [ + startRow + 1 + i, + startCol, + ]) + : [[startRow, startCol]] + const labelCol = endCol > startCol && (await anyText(cells, labelProbe)) + + // A bubble chart's X is its first data column, numeric or not — so the + // generic label-column rule does not apply to it. + const xIsFirstColumn = chartType === 'bubble' && endCol > startCol + const dataStartCol = labelCol && !xIsFirstColumn ? startCol + 1 : startCol + + // The header row is then decided from the data columns only, for the same + // reason: the label column's own heading says nothing about the rest. + const headerRow = + endRow > startRow && + (await anyText( + cells, + Array.from({length: endCol - dataStartCol + 1}, (_, i) => [ + startRow, + dataStartCol + i, + ]) + )) + + const dataStartRow = headerRow ? startRow + 1 : startRow + const catCol = toA1notation( + labelCol && !xIsFirstColumn ? startCol : dataStartCol + ) + const categoriesRef = + labelCol || xIsFirstColumn + ? `${qs}!$${catCol}$${dataStartRow + 1}:$${catCol}$${endRow + 1}` + : undefined + + const colRef = (c: number) => { + const col = toA1notation(c) + return `${qs}!$${col}$${dataStartRow + 1}:$${col}$${endRow + 1}` + } + const headerOf = async (c: number) => + headerRow ? await cells.textAt(startRow, c) : undefined + + const series: SelectedSeries[] = [] + if (chartType === 'bubble') { + // Three columns per bubble series: X, Y, size. The first data column is + // the shared X, then each Y/size pair adds a series. A trailing Y with + // no size still plots, at a default radius. + for (let c = dataStartCol + 1; c <= endCol; c += 2) { + series.push({ + name: await headerOf(c), + valueRef: colRef(c), + sizeRef: c + 1 <= endCol ? colRef(c + 1) : undefined, + }) + } + } else { + for (let c = dataStartCol; c <= endCol; c++) { + series.push({name: await headerOf(c), valueRef: colRef(c)}) + } + } + + return {categoriesRef, series} +} diff --git a/packages/engine/src/lib/chart/index.ts b/packages/engine/src/lib/chart/index.ts index 07d94d35..6832cf93 100644 --- a/packages/engine/src/lib/chart/index.ts +++ b/packages/engine/src/lib/chart/index.ts @@ -1,9 +1,28 @@ export {default as ChartView} from './ChartView.svelte' export {mapChartToOption} from './to-option' export {chartInfoToModel} from './from-info' +export {formatAxisNumber} from './num-format' +export {chartDataRefsFromSelection} from './from-selection' +export {chartSourceRanges, isRangeVisible, parseA1Range} from './source-ranges' export type { + CellRange, + ChartSourceRange, + GridWindow, + SourceKind, +} from './source-ranges' +export type { + ChartDataRefs, + SelectedSeries, + SelectionCells, + SelectionRange, +} from './from-selection' +export type { + AxisScale, ChartModel, ChartSeries, ChartType, + ChartUpdate, + DataLabels, LegendPosition, + OfPieSplit, } from './types' diff --git a/packages/engine/src/lib/chart/num-format.ts b/packages/engine/src/lib/chart/num-format.ts new file mode 100644 index 00000000..bf33f018 --- /dev/null +++ b/packages/engine/src/lib/chart/num-format.ts @@ -0,0 +1,83 @@ +/** + * A small Excel number-format renderer for *axis ticks only*. + * + * Everything else a chart shows (data labels, category labels) is formatted by + * the core, which owns the real formatter (`ssf-rs`) and can therefore render + * any format code exactly as the sheet does. Axis ticks are the one exception: + * the renderer picks the tick values itself, so they cannot be pre-formatted, + * and the core's formatter lives in the worker's WASM — not reachable from a + * synchronous ECharts callback on the main thread. + * + * So this covers the numeric codes an axis realistically carries — thousands + * separators, fixed decimals, percentages, a currency prefix/suffix — and + * falls back to the plain number for anything it does not understand. It is + * deliberately not a general implementation: dates and text sections belong to + * category labels, which take the core-formatted path. + */ + +/** A format code reduced to the knobs an axis tick needs. */ +interface NumericFormat { + prefix: string + suffix: string + decimals: number + grouped: boolean + percent: boolean +} + +/** Strip the literal-escaping syntax Excel allows around currency symbols. */ +function literalOf(token: string): string { + // [$€-x-euro2] → €, "kr" → kr, \$ → $ + const bracketed = token.match(/^\[\$([^\]-]*)/) + if (bracketed) return bracketed[1] + if (token.startsWith('"') && token.endsWith('"') && token.length >= 2) + return token.slice(1, -1) + return token.replace(/\\/g, '') +} + +function parseFormat(fmt: string): NumericFormat | undefined { + // Only the positive section drives axis ticks; negatives on an axis are + // rendered with a leading minus by the same pattern. + const section = fmt.split(';')[0].trim() + if (!section || section.toLowerCase() === 'general') return undefined + // A date/time code is not something we try to render here. + if (/[ymdhs]/i.test(section.replace(/\[[^\]]*\]|"[^"]*"/g, ''))) return undefined + + const core = section.match(/[#0](?:[#0,]*)(?:\.[#0]+)?/) + if (!core) return undefined + const pattern = core[0] + const start = section.indexOf(pattern) + const prefixRaw = section.slice(0, start) + const suffixRaw = section.slice(start + pattern.length) + + const percent = section.includes('%') + const [intPart, decPart = ''] = pattern.split('.') + return { + prefix: literalOf(prefixRaw), + suffix: literalOf(suffixRaw.replace('%', '')) + (percent ? '%' : ''), + decimals: decPart.replace(/[^#0]/g, '').length, + grouped: intPart.includes(','), + percent, + } +} + +const cache = new Map() + +/** + * Render `value` with the Excel format code `fmt`. Returns the plain number + * when there is no code, or when the code is outside the supported subset. + */ +export function formatAxisNumber(fmt: string | undefined, value: number): string { + if (!fmt) return String(value) + if (!cache.has(fmt)) cache.set(fmt, parseFormat(fmt)) + const f = cache.get(fmt) + if (!f) return String(value) + + const n = f.percent ? value * 100 : value + const body = f.grouped + ? n.toLocaleString('en-US', { + minimumFractionDigits: f.decimals, + maximumFractionDigits: f.decimals, + }) + : n.toFixed(f.decimals) + return `${f.prefix}${body}${f.suffix}` +} diff --git a/packages/engine/src/lib/chart/source-ranges.test.ts b/packages/engine/src/lib/chart/source-ranges.test.ts new file mode 100644 index 00000000..2ab49ee5 --- /dev/null +++ b/packages/engine/src/lib/chart/source-ranges.test.ts @@ -0,0 +1,256 @@ +import {describe, expect, it} from 'vitest' +import type {ChartInfo} from 'logisheets-web' +import {chartSourceRanges, isRangeVisible, parseA1Range} from './source-ranges' + +describe('parseA1Range', () => { + it('reads an absolute range with a sheet', () => { + expect(parseA1Range('Sheet1!$B$2:$E$2')).toEqual({ + sheet: 'Sheet1', + range: {startRow: 1, startCol: 1, endRow: 1, endCol: 4}, + }) + }) + + it('reads a range with no sheet as belonging to the chart’s own', () => { + const parsed = parseA1Range('$A$1:$A$3') + expect(parsed?.sheet).toBeUndefined() + expect(parsed?.range).toEqual({ + startRow: 0, + startCol: 0, + endRow: 2, + endCol: 0, + }) + }) + + it('reads a single cell as a one-cell range', () => { + expect(parseA1Range('Sheet1!$C$5')?.range).toEqual({ + startRow: 4, + startCol: 2, + endRow: 4, + endCol: 2, + }) + }) + + it('accepts relative references too', () => { + expect(parseA1Range('Sheet1!B2:C3')?.range).toEqual({ + startRow: 1, + startCol: 1, + endRow: 2, + endCol: 2, + }) + }) + + it('unquotes a sheet name, including doubled apostrophes', () => { + expect(parseA1Range("'Bob''s Data'!$A$1")?.sheet).toBe("Bob's Data") + expect(parseA1Range("'My Sheet'!$A$1")?.sheet).toBe('My Sheet') + }) + + it('keeps a sheet name that contains an exclamation mark', () => { + // The split is on the *last* `!`, so the name survives. + expect(parseA1Range("'Wow!'!$A$1")?.sheet).toBe('Wow!') + }) + + it('normalizes a range written bottom-right to top-left', () => { + expect(parseA1Range('$E$4:$B$2')?.range).toEqual({ + startRow: 1, + startCol: 1, + endRow: 3, + endCol: 4, + }) + }) + + it('handles columns past Z', () => { + expect(parseA1Range('$AA$1')?.range.startCol).toBe(26) + expect(parseA1Range('$AB$1')?.range.startCol).toBe(27) + }) + + it('returns undefined for anything it cannot read', () => { + for (const bad of ['', 'Sheet1!', 'not a ref', '$A$0', 'A', '1', '#REF!']) { + expect(parseA1Range(bad), bad).toBeUndefined() + } + }) +}) + +describe('isRangeVisible', () => { + // Rows 10..20, columns 2..8 are laid out. + const window = {firstRow: 10, lastRow: 20, firstCol: 2, lastCol: 8} + const range = ( + startRow: number, + startCol: number, + endRow: number, + endCol: number + ) => ({startRow, startCol, endRow, endCol}) + + it('accepts a range inside the window', () => { + expect(isRangeVisible(range(12, 3, 15, 5), window)).toBe(true) + }) + + it('accepts a range that only overlaps it', () => { + // Scrolled partly off the top, and partly off the right. + expect(isRangeVisible(range(5, 3, 12, 5), window)).toBe(true) + expect(isRangeVisible(range(12, 6, 15, 40), window)).toBe(true) + }) + + it('accepts a range that spans the whole window', () => { + expect(isRangeVisible(range(0, 0, 100, 100), window)).toBe(true) + }) + + it('rejects a range scrolled past on either side', () => { + expect(isRangeVisible(range(0, 3, 9, 5), window)).toBe(false) + expect(isRangeVisible(range(21, 3, 30, 5), window)).toBe(false) + expect(isRangeVisible(range(12, 0, 15, 1), window)).toBe(false) + expect(isRangeVisible(range(12, 9, 15, 12), window)).toBe(false) + }) + + it('counts a range touching the very edge as visible', () => { + expect(isRangeVisible(range(0, 3, 10, 5), window)).toBe(true) + expect(isRangeVisible(range(20, 8, 40, 40), window)).toBe(true) + }) +}) + +function info(over: Partial = {}): ChartInfo { + return { + chartId: 'c1', + fromRow: 0, + fromCol: 0, + fromColOff: 0, + fromRowOff: 0, + toRow: 10, + toCol: 5, + toColOff: 0, + toRowOff: 0, + chartType: 'col', + stacked: false, + categories: [], + series: [], + dataLabels: { + showValue: false, + showCategory: false, + showSeries: false, + showPercent: false, + showLegendKey: false, + }, + ofPieSplit: {}, + valAxisScale: {reversed: false}, + catAxisScale: {reversed: false}, + ...over, + } as ChartInfo +} + +const series = (over: Record) => + ({ + name: 'S', + values: [1], + formattedValues: ['1'], + sizes: [], + ...over, + }) as ChartInfo['series'][number] + +describe('chartSourceRanges', () => { + it('lists the categories first, then each series', () => { + const ranges = chartSourceRanges( + info({ + catRef: 'Sheet1!$A$2:$A$4', + series: [ + series({name: 'One', valRef: 'Sheet1!$B$2:$B$4'}), + series({name: 'Two', valRef: 'Sheet1!$C$2:$C$4'}), + ], + }) + ) + expect(ranges.map((r) => [r.kind, r.seriesName])).toEqual([ + ['categories', undefined], + ['values', 'One'], + ['values', 'Two'], + ]) + expect(ranges[1].range).toEqual({ + startRow: 1, + startCol: 1, + endRow: 3, + endCol: 1, + }) + }) + + it('outlines a series in its own colour', () => { + const ranges = chartSourceRanges( + info({ + series: [series({valRef: 'Sheet1!$B$2:$B$4', color: 'FF0000'})], + }) + ) + expect(ranges[0].color).toBe('#FF0000') + }) + + it('drops the alpha from an ARGB colour', () => { + const ranges = chartSourceRanges( + info({ + series: [series({valRef: 'Sheet1!$B$2:$B$4', color: 'FF4472C4'})], + }) + ) + expect(ranges[0].color).toBe('#4472C4') + }) + + it('falls back to a distinct colour per series when none is set', () => { + const ranges = chartSourceRanges( + info({ + series: [ + series({valRef: 'Sheet1!$B$2:$B$4'}), + series({valRef: 'Sheet1!$C$2:$C$4'}), + ], + }) + ) + expect(ranges[0].color).not.toBe(ranges[1].color) + }) + + it('gives the categories a colour no series will take', () => { + const ranges = chartSourceRanges( + info({ + catRef: 'Sheet1!$A$2:$A$4', + series: [series({valRef: 'Sheet1!$B$2:$B$4'})], + }) + ) + expect(ranges[0].kind).toBe('categories') + expect(ranges[0].color).not.toBe(ranges[1].color) + }) + + it('includes a bubble series’ size range, in the series colour', () => { + const ranges = chartSourceRanges( + info({ + chartType: 'bubble', + series: [ + series({ + name: 'B', + valRef: 'Sheet1!$C$2:$C$4', + sizeRef: 'Sheet1!$D$2:$D$4', + color: '00FF00', + }), + ], + }) + ) + expect(ranges.map((r) => r.kind)).toEqual(['values', 'sizes']) + expect(ranges[1].seriesName).toBe('B') + expect(ranges[1].color).toBe(ranges[0].color) + }) + + it('keeps the sheet a cross-sheet reference names', () => { + const ranges = chartSourceRanges( + info({series: [series({valRef: "'Other Sheet'!$B$2:$B$4"})]}) + ) + expect(ranges[0].sheet).toBe('Other Sheet') + }) + + it('skips a reference it cannot parse rather than guessing', () => { + const ranges = chartSourceRanges( + info({ + catRef: 'nonsense', + series: [ + series({valRef: 'Sheet1!$B$2:$B$4'}), + series({valRef: ''}), + ], + }) + ) + expect(ranges).toHaveLength(1) + expect(ranges[0].kind).toBe('values') + }) + + it('returns nothing for a chart with no references at all', () => { + expect(chartSourceRanges(info())).toEqual([]) + }) +}) diff --git a/packages/engine/src/lib/chart/source-ranges.ts b/packages/engine/src/lib/chart/source-ranges.ts new file mode 100644 index 00000000..ccae9757 --- /dev/null +++ b/packages/engine/src/lib/chart/source-ranges.ts @@ -0,0 +1,145 @@ +/** + * Where a chart's numbers come from, as cell ranges the grid can outline. + * + * Selecting a chart in Excel draws a coloured border around each range that + * feeds it — categories in one colour, every series in its own — which is how + * you see at a glance what a chart is plotting and can tell two similar charts + * apart. This module turns a `ChartInfo`'s references into those rectangles; + * painting them is the grid's job. + */ +import type {ChartInfo} from 'logisheets-web' +import {toCssColor} from './to-option' + +export interface CellRange { + startRow: number + startCol: number + endRow: number + endCol: number +} + +/** What a highlighted range feeds. */ +export type SourceKind = 'categories' | 'values' | 'sizes' + +export interface ChartSourceRange { + kind: SourceKind + /** The series this belongs to; undefined for the shared categories. */ + seriesName?: string + /** + * The sheet the reference names, or undefined when it carries none — in + * which case it means the sheet the chart sits on. + */ + sheet?: string + range: CellRange + /** Outline colour: the series' own where it has one. */ + color: string +} + +/** + * Fallback outline colours, used when a series has no colour of its own. These + * are ECharts' default palette, so an outline matches the bar it belongs to. + */ +const PALETTE = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de'] + +/** Categories are not a series, so they get a colour no series will take. */ +const CATEGORY_COLOR = '#9254de' + +/** `$B$2` / `B2` → zero-based (row, col). */ +function parseA1Cell(s: string): {row: number; col: number} | undefined { + const m = /^\$?([A-Za-z]{1,3})\$?([0-9]+)$/.exec(s.trim()) + if (!m) return undefined + let col = 0 + for (const ch of m[1].toUpperCase()) { + col = col * 26 + (ch.charCodeAt(0) - 64) + } + const row = Number(m[2]) + if (col === 0 || row === 0) return undefined + return {row: row - 1, col: col - 1} +} + +/** + * Parse `[Sheet!]$C$R[:$C$R]`, the shape chart references take. The sheet name + * may be quoted, in which case doubled apostrophes are literal ones. Corners + * are normalized, so a reference written bottom-up still reads top-down. + */ +export function parseA1Range( + ref: string +): {sheet?: string; range: CellRange} | undefined { + const bang = ref.lastIndexOf('!') + let sheet: string | undefined + let body = ref + if (bang >= 0) { + let name = ref.slice(0, bang) + if (name.length >= 2 && name.startsWith("'") && name.endsWith("'")) { + name = name.slice(1, -1).replace(/''/g, "'") + } + sheet = name + body = ref.slice(bang + 1) + } + const [a, b] = body.includes(':') ? body.split(':') : [body, body] + const start = parseA1Cell(a) + const end = parseA1Cell(b) + if (!start || !end) return undefined + return { + sheet, + range: { + startRow: Math.min(start.row, end.row), + startCol: Math.min(start.col, end.col), + endRow: Math.max(start.row, end.row), + endCol: Math.max(start.col, end.col), + }, + } +} + +/** The block of cells the grid currently has laid out, inclusive. */ +export interface GridWindow { + firstRow: number + lastRow: number + firstCol: number + lastCol: number +} + +/** + * Whether any part of `range` falls inside the laid-out window. + * + * This has to be checked before drawing: the grid only knows the size of the + * rows and columns it has rendered, so asking it where an off-screen range + * sits gives the window's own edge — which would pin a bogus outline to the + * top-left corner instead of letting the range scroll away. A partly visible + * range is fine, and clamps to the edge on its own. + */ +export function isRangeVisible(range: CellRange, window: GridWindow): boolean { + return ( + range.endRow >= window.firstRow && + range.startRow <= window.lastRow && + range.endCol >= window.firstCol && + range.startCol <= window.lastCol + ) +} + +/** + * Every range that feeds `info`, in the order they should be drawn: categories + * first, then each series' values and (for a bubble chart) its sizes. + * References that do not parse are skipped rather than guessed at. + */ +export function chartSourceRanges(info: ChartInfo): ChartSourceRange[] { + const out: ChartSourceRange[] = [] + const push = ( + ref: string | undefined, + kind: SourceKind, + color: string, + seriesName?: string + ) => { + if (!ref) return + const parsed = parseA1Range(ref) + if (!parsed) return + out.push({kind, seriesName, sheet: parsed.sheet, range: parsed.range, color}) + } + + push(info.catRef, 'categories', CATEGORY_COLOR) + info.series.forEach((s, i) => { + const color = toCssColor(s.color) ?? PALETTE[i % PALETTE.length] + push(s.valRef, 'values', color, s.name) + push(s.sizeRef, 'sizes', color, s.name) + }) + return out +} diff --git a/packages/engine/src/lib/chart/to-option.test.ts b/packages/engine/src/lib/chart/to-option.test.ts new file mode 100644 index 00000000..1327ff65 --- /dev/null +++ b/packages/engine/src/lib/chart/to-option.test.ts @@ -0,0 +1,543 @@ +import {describe, expect, it} from 'vitest' +import {mapChartToOption} from './to-option' +import {formatAxisNumber} from './num-format' +import type {ChartModel} from './types' + +function model(over: Partial = {}): ChartModel { + return { + chartId: 'c1', + chartType: 'col', + categories: ['Q1', 'Q2'], + series: [ + { + name: 'Sales', + values: [1234.5, 6789], + formattedValues: ['1,234.50', '6,789.00'], + }, + ], + ...over, + } +} + +/** The label formatter ECharts would call for point `i` of series 0. */ +function labelText( + option: ReturnType, + i: number, + percent?: number +): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (option.series as any[])[0] + return s.label.formatter({dataIndex: i, percent}) +} + +describe('mapChartToOption — data labels', () => { + it('shows nothing when the chart has no labels', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (mapChartToOption(model()).series as any[])[0] + expect(s.label.show).toBe(false) + }) + + it('renders the value with the format the core resolved', () => { + const option = mapChartToOption( + model({ + dataLabels: { + value: true, + category: false, + series: false, + percent: false, + }, + }) + ) + expect(labelText(option, 0)).toBe('1,234.50') + expect(labelText(option, 1)).toBe('6,789.00') + }) + + it('joins the parts Excel-style when several are enabled', () => { + const option = mapChartToOption( + model({ + dataLabels: { + value: true, + category: true, + series: true, + percent: false, + }, + }) + ) + expect(labelText(option, 0)).toBe('Sales, Q1, 1,234.50') + }) + + it('falls back to the raw number when no formatted string was sent', () => { + const option = mapChartToOption( + model({ + series: [{name: 'S', values: [42]}], + dataLabels: { + value: true, + category: false, + series: false, + percent: false, + }, + }) + ) + expect(labelText(option, 0)).toBe('42') + }) + + it('adds the percentage on a pie, which only ECharts can compute', () => { + const option = mapChartToOption( + model({ + chartType: 'pie', + dataLabels: { + value: false, + category: true, + series: false, + percent: true, + }, + }) + ) + expect(labelText(option, 0, 15.4)).toBe('Q1, 15.4%') + }) +}) + +describe('mapChartToOption — axes', () => { + it('formats value-axis ticks with the chart number format', () => { + const option = mapChartToOption(model({valAxisNumFmt: '#,##0.0'})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fmt = (option.yAxis as any).axisLabel.formatter + expect(fmt(1234.5)).toBe('1,234.5') + }) + + it('leaves ticks alone when the chart has no number format', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((mapChartToOption(model()).yAxis as any).axisLabel).toBeUndefined() + }) + + it('puts the value axis on X for a bar chart', () => { + const option = mapChartToOption( + model({chartType: 'bar', valAxisNumFmt: '0%'}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.xAxis as any).axisLabel.formatter(0.25)).toBe('25%') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.yAxis as any).type).toBe('category') + }) +}) + +describe('mapChartToOption — axis scale', () => { + it('pins the bounds and tick spacing the chart sets', () => { + const option = mapChartToOption( + model({ + valAxisScale: { + min: 0, + max: 80, + majorUnit: 20, + reversed: false, + }, + }) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const y = option.yAxis as any + expect(y.min).toBe(0) + expect(y.max).toBe(80) + expect(y.interval).toBe(20) + expect(y.type).toBe('value') + }) + + it('leaves an automatic axis to the renderer', () => { + const option = mapChartToOption(model({valAxisScale: {reversed: false}})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const y = option.yAxis as any + expect(y.min).toBeUndefined() + expect(y.max).toBeUndefined() + expect(y.interval).toBeUndefined() + expect(y.inverse).toBeUndefined() + }) + + it('switches the axis type for a log scale', () => { + const option = mapChartToOption( + model({valAxisScale: {logBase: 10, reversed: true}}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const y = option.yAxis as any + expect(y.type).toBe('log') + expect(y.logBase).toBe(10) + expect(y.inverse).toBe(true) + }) + + it('applies the scale to X when the bars are horizontal', () => { + const option = mapChartToOption( + model({chartType: 'bar', valAxisScale: {max: 50, reversed: false}}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.xAxis as any).max).toBe(50) + }) + + it('only takes the direction from a category axis', () => { + const option = mapChartToOption( + model({catAxisScale: {reversed: true, min: 3}}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const x = option.xAxis as any + expect(x.inverse).toBe(true) + expect(x.min).toBeUndefined() + }) +}) + +describe('mapChartToOption — radar', () => { + it('turns categories into spokes with one shared maximum', () => { + const option = mapChartToOption( + model({ + chartType: 'radar', + categories: ['Speed', 'Power', 'Range'], + series: [ + {name: 'A', values: [3, 9, 6]}, + {name: 'B', values: [7, 2, 4]}, + ], + }) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const radar = (option as any).radar + expect(radar.indicator.map((i: {name: string}) => i.name)).toEqual([ + 'Speed', + 'Power', + 'Range', + ]) + // A single max across every series, so the shapes stay comparable. + expect(radar.indicator.every((i: {max: number}) => i.max === 9)).toBe(true) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (option.series as any[])[0] + expect(s.type).toBe('radar') + expect(s.data).toHaveLength(2) + expect(s.data[1].value).toEqual([7, 2, 4]) + expect(option.grid).toBeUndefined() + }) + + it('treats gaps as zero so the polygon still closes', () => { + const option = mapChartToOption( + model({ + chartType: 'radar', + categories: ['a', 'b'], + series: [{name: 'A', values: [5, null]}], + }) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.series as any[])[0].data[0].value).toEqual([5, 0]) + }) +}) + +describe('mapChartToOption — bubble', () => { + const bubble = () => + mapChartToOption( + model({ + chartType: 'bubble', + categories: [1, 2, 3], + series: [ + { + name: 'P', + values: [10, 20, 30], + sizes: [25, 100, null], + }, + ], + }) + ) + + it('packs [x, y, size] triples', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (bubble().series as any[])[0] + expect(s.type).toBe('scatter') + expect(s.data[0]).toEqual([1, 10, 25]) + expect(s.data[2]).toEqual([3, 30, null]) + }) + + it('sizes by area, normalized to the largest bubble', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (bubble().series as any[])[0] + // 100 is the largest → full radius; 25 is a quarter of the area, so + // half the radius. + expect(s.symbolSize([1, 10, 100])).toBeCloseTo(40) + expect(s.symbolSize([1, 10, 25])).toBeCloseTo(20) + }) + + it('falls back to a default radius when a size is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (bubble().series as any[])[0] + expect(s.symbolSize([3, 30, null])).toBe(10) + // A series with no sizes at all still plots. + const none = mapChartToOption( + model({chartType: 'bubble', series: [{name: 'P', values: [1, 2]}]}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((none.series as any[])[0].symbolSize([0, 1, null])).toBe(10) + }) + + it('keeps two value axes, like scatter', () => { + const option = bubble() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.xAxis as any).type).toBe('value') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.yAxis as any).type).toBe('value') + }) +}) + +describe('mapChartToOption — stock', () => { + const ohlc = (names: string[]) => + model({ + chartType: 'stock', + categories: ['Mon', 'Tue'], + series: names.map((n, i) => ({ + name: n, + values: [10 + i, 20 + i], + })), + }) + + it('reads four series as open/high/low/close', () => { + const option = ohlc(['Open', 'High', 'Low', 'Close']) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (mapChartToOption(option).series as any[])[0] + expect(s.type).toBe('candlestick') + // ECharts order is [open, close, low, high]. + expect(s.data[0]).toEqual([10, 13, 12, 11]) + }) + + it('reads three series as high/low/close, with open = close', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (mapChartToOption(ohlc(['High', 'Low', 'Close'])).series as any[])[0] + // High=10, Low=11, Close=12 → [open=12, close=12, low=11, high=10] + expect(s.data[0]).toEqual([12, 12, 11, 10]) + }) + + it('falls back to lines when the series count is not a stock shape', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (mapChartToOption(ohlc(['A', 'B'])).series as any[])[0] + expect(s.type).toBe('bar') + }) + + it('lets the price axis frame the data instead of starting at zero', () => { + const option = mapChartToOption(ohlc(['O', 'H', 'L', 'C'])) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.yAxis as any).scale).toBe(true) + }) +}) + +describe('mapChartToOption — of pie', () => { + const ofPie = (over = {}) => + model({ + chartType: 'ofPie', + categories: ['a', 'b', 'c', 'd', 'e'], + series: [{name: 'S', values: [50, 30, 10, 6, 4]}], + ...over, + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plots = (m: ChartModel) => mapChartToOption(m).series as any[] + + it('splits the last N points by position', () => { + const [main, second] = plots(ofPie({ofPieSplit: {by: 'pos', pos: 2}})) + expect(main.data.map((d: {name: string}) => d.name)).toEqual([ + 'a', + 'b', + 'c', + 'Other', + ]) + // "Other" is the sum of what moved to the second plot. + expect(main.data[3].value).toBe(10) + expect(second.data.map((d: {name: string}) => d.name)).toEqual(['d', 'e']) + }) + + it('splits by value threshold', () => { + const [main, second] = plots(ofPie({ofPieSplit: {by: 'val', pos: 10}})) + expect(second.data.map((d: {name: string}) => d.name)).toEqual(['d', 'e']) + expect(main.data[3].value).toBe(10) + }) + + it('splits by percentage of the total', () => { + // Total is 100, so a 7% threshold moves the 6 and the 4. + const [, second] = plots(ofPie({ofPieSplit: {by: 'percent', pos: 7}})) + expect(second.data.map((d: {name: string}) => d.name)).toEqual(['d', 'e']) + }) + + it('defaults to the last two points when no split is authored', () => { + const [, second] = plots(ofPie()) + expect(second.data.map((d: {name: string}) => d.name)).toEqual(['d', 'e']) + }) + + it('draws the second plot as a stacked column for barOfPie', () => { + const series = plots( + ofPie({chartType: 'barOfPie', ofPieSplit: {by: 'pos', pos: 2}}) + ) + expect(series[0].type).toBe('pie') + expect(series.slice(1).every((s) => s.type === 'bar')).toBe(true) + expect(series.slice(1).every((s) => s.stack === 'other')).toBe(true) + // The column needs a grid; the pie half sits beside it. + expect(mapChartToOption( + ofPie({chartType: 'barOfPie', ofPieSplit: {by: 'pos', pos: 2}}) + ).grid).toBeDefined() + }) +}) + +describe('mapChartToOption — surface', () => { + const surface = (type: 'surface' | 'surface3d' = 'surface') => + mapChartToOption( + model({ + chartType: type, + categories: ['x1', 'x2'], + series: [ + {name: 'r1', values: [1, 4]}, + {name: 'r2', values: [9, null]}, + ], + }) + ) + + it('lays the grid out as [col, row, value] heatmap cells', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = (surface().series as any[])[0] + expect(s.type).toBe('heatmap') + // The null is a hole in the grid, not a zero. + expect(s.data).toEqual([ + [0, 0, 1], + [1, 0, 4], + [0, 1, 9], + ]) + }) + + it('scales the colour map to the data', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = (surface() as any).visualMap + expect(vm.min).toBe(1) + expect(vm.max).toBe(9) + }) + + it('labels the axes with the categories and the series names', () => { + const option = surface() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.xAxis as any).data).toEqual(['x1', 'x2']) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.yAxis as any).data).toEqual(['r1', 'r2']) + }) + + it('draws the 3-D form the same flat way', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((surface('surface3d').series as any[])[0].type).toBe('heatmap') + }) + + it('survives an all-empty grid', () => { + const option = mapChartToOption( + model({chartType: 'surface', series: [{name: 'r', values: [null]}]}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vm = (option as any).visualMap + expect(Number.isFinite(vm.min) && Number.isFinite(vm.max)).toBe(true) + }) +}) + +describe('mapChartToOption — 3-D kinds', () => { + it('draws each 3-D kind as its flat equivalent', () => { + const cases: Array<[string, string]> = [ + ['col3d', 'bar'], + ['bar3d', 'bar'], + ['line3d', 'line'], + ['area3d', 'line'], + ] + for (const [type, expected] of cases) { + const option = mapChartToOption( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + model({chartType: type as any}) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.series as any[])[0].type).toBe(expected) + } + }) + + it('keeps the flat kind’s orientation and fill', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bar3d = mapChartToOption(model({chartType: 'bar3d' as any})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((bar3d.yAxis as any).type).toBe('category') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const area3d = mapChartToOption(model({chartType: 'area3d' as any})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((area3d.series as any[])[0].areaStyle).toBeDefined() + }) + + it('draws a 3-D pie as a pie, with no grid', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const option = mapChartToOption(model({chartType: 'pie3d' as any})) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.series as any[])[0].type).toBe('pie') + expect(option.grid).toBeUndefined() + }) +}) + +describe('mapChartToOption — combo', () => { + const combo = (over = {}) => + mapChartToOption( + model({ + chartType: 'col', + series: [ + {name: 'Revenue', values: [1, 2]}, + {name: 'Margin', values: [3, 4], seriesType: 'line'}, + {name: 'Churn', values: [5, 6], seriesType: 'area'}, + ], + ...over, + }) + ) + + it('gives each series the kind it asks for', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const series = combo().series as any[] + expect(series.map((s) => s.type)).toEqual(['bar', 'line', 'line']) + // Area is a line with a fill. + expect(series[1].areaStyle).toBeUndefined() + expect(series[2].areaStyle).toBeDefined() + }) + + it('stacks only the series that follow the chart’s own kind', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const series = combo({stacked: true}).series as any[] + expect(series[0].stack).toBe('total') + expect(series[1].stack).toBeUndefined() + expect(series[2].stack).toBeUndefined() + }) + + it('leaves a chart with no overrides unchanged', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const series = mapChartToOption(model({stacked: true})).series as any[] + expect(series.every((s) => s.type === 'bar')).toBe(true) + expect(series.every((s) => s.stack === 'total')).toBe(true) + }) + + it('flattens a 3-D override to its drawable kind', () => { + const option = mapChartToOption( + model({ + chartType: 'col', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + series: [{name: 'a', values: [1], seriesType: 'line3d' as any}], + }) + ) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((option.series as any[])[0].type).toBe('line') + }) +}) + +describe('formatAxisNumber', () => { + it('applies separators and fixed decimals', () => { + expect(formatAxisNumber('#,##0.00', 1234.5)).toBe('1,234.50') + expect(formatAxisNumber('0.0', 1234.56)).toBe('1234.6') + }) + + it('scales percentages', () => { + expect(formatAxisNumber('0%', 0.256)).toBe('26%') + expect(formatAxisNumber('0.0%', 0.256)).toBe('25.6%') + }) + + it('keeps a currency prefix', () => { + expect(formatAxisNumber('$#,##0', 1500)).toBe('$1,500') + }) + + it('uses the positive section of a multi-section code', () => { + expect(formatAxisNumber('#,##0.00;[Red]-#,##0.00', 12.3)).toBe('12.30') + }) + + it('passes the number through for General, dates and no format', () => { + expect(formatAxisNumber(undefined, 12.5)).toBe('12.5') + expect(formatAxisNumber('General', 12.5)).toBe('12.5') + expect(formatAxisNumber('yyyy-mm-dd', 45000)).toBe('45000') + }) +}) diff --git a/packages/engine/src/lib/chart/to-option.ts b/packages/engine/src/lib/chart/to-option.ts index 395253de..17b94e16 100644 --- a/packages/engine/src/lib/chart/to-option.ts +++ b/packages/engine/src/lib/chart/to-option.ts @@ -6,10 +6,18 @@ * renderer only touches this file plus `ChartView.svelte`. */ import type {EChartsOption} from 'echarts' -import type {ChartModel, ChartSeries, LegendPosition} from './types' +import type { + AxisScale, + ChartModel, + ChartSeries, + ChartType, + DataLabels, + LegendPosition, +} from './types' +import {formatAxisNumber} from './num-format' /** Normalize an OOXML-style ARGB/RGB hex to a CSS color, or undefined. */ -function toCssColor(color?: string): string | undefined { +export function toCssColor(color?: string): string | undefined { if (!color) return undefined const hex = color.trim().replace(/^#/, '') // ARGB (8 hex) → drop the leading alpha; keep RGB (6 hex) as-is. @@ -40,35 +48,159 @@ function seriesColor(s: ChartSeries) { return c ? {itemStyle: {color: c}} : {} } +/** OOXML label positions → the nearest ECharts anchor, per series kind. */ +function labelPosition( + pos: string | undefined, + kind: 'bar' | 'line' | 'pie' +): 'inside' | 'insideTop' | 'insideBottom' | 'top' | 'outside' { + if (kind === 'pie') + return pos === 'inEnd' || pos === 'ctr' ? 'inside' : 'outside' + switch (pos) { + case 'ctr': + return 'inside' + case 'inEnd': + return 'insideTop' + case 'inBase': + return 'insideBottom' + // `outEnd` and anything unrecognized sit just past the point, which is + // also Excel's default for a bar/line label. + default: + return 'top' + } +} + +/** + * The label spec for one series. The value text comes pre-formatted from the + * core (`formattedValues`), so a label matches how the sheet renders the same + * number; the category / series-name parts are joined the way Excel does, with + * commas. + */ +function seriesLabel( + model: ChartModel, + s: ChartSeries, + kind: 'bar' | 'line' | 'pie' +) { + const labels = model.dataLabels + if (!labels || !labelsOn(labels)) return {label: {show: false}} + return { + label: { + show: true, + position: labelPosition(labels.position, kind), + formatter: (params: {dataIndex: number; percent?: number}) => { + const parts: string[] = [] + if (labels.series && s.name) parts.push(s.name) + if (labels.category) { + const c = model.categories[params.dataIndex] + if (c !== undefined) parts.push(String(c)) + } + if (labels.value) { + const text = s.formattedValues?.[params.dataIndex] + const raw = s.values[params.dataIndex] + parts.push(text ?? (raw == null ? '' : String(raw))) + } + if (labels.percent && params.percent !== undefined) + parts.push(`${params.percent}%`) + return parts.filter((p) => p !== '').join(', ') + }, + }, + } +} + +function labelsOn(l: DataLabels): boolean { + return l.value || l.category || l.series || l.percent +} + +/** Value-axis ticks rendered with the chart's number format, when it has one. */ +function valueAxisLabel(model: ChartModel) { + if (!model.valAxisNumFmt) return {} + return { + axisLabel: { + formatter: (v: number) => formatAxisNumber(model.valAxisNumFmt, v), + }, + } +} + +/** + * A fixed scale, where the chart sets one. Anything left undefined stays on + * ECharts' own automatic bounds and tick spacing, which is what an "auto" axis + * means in Excel too. A log axis needs `type: 'log'` rather than a bound. + */ +function axisScale(scale: AxisScale | undefined) { + if (!scale) return {} + return { + ...(scale.min !== undefined ? {min: scale.min} : {}), + ...(scale.max !== undefined ? {max: scale.max} : {}), + ...(scale.majorUnit !== undefined ? {interval: scale.majorUnit} : {}), + ...(scale.minorUnit !== undefined + ? {minorTick: {show: true, splitNumber: 2}} + : {}), + ...(scale.logBase !== undefined + ? {type: 'log' as const, logBase: scale.logBase} + : {}), + ...(scale.reversed ? {inverse: true} : {}), + } +} + +/** + * The flat kind a 3-D one is drawn as. Depth is preserved in the file but not + * rendered — drawing it would mean pulling in `echarts-gl`. + */ +const FLATTENED: Partial> = { + col3d: 'col', + bar3d: 'bar', + line3d: 'line', + area3d: 'area', + pie3d: 'pie', + surface3d: 'surface', +} + +function flatten(type: ChartType): ChartType { + return FLATTENED[type] ?? type +} + /** Cartesian charts: column, bar, line, area (all share axes + a series list). */ function cartesianOption(model: ChartModel): EChartsOption { - const {chartType} = model + const chartType = flatten(model.chartType) const horizontal = chartType === 'bar' const categoryAxis = { type: 'category' as const, data: model.categories.map((c) => String(c)), name: model.catAxisTitle, + // A category axis has no numeric bounds; only its direction applies. + ...(model.catAxisScale?.reversed ? {inverse: true} : {}), } const valueAxis = { type: 'value' as const, name: model.valAxisTitle, + ...valueAxisLabel(model), + ...axisScale(model.valAxisScale), } const series = model.series.map((s) => { - const isLine = chartType === 'line' || chartType === 'area' + // A combo chart's series may each name their own kind; the chart's own + // kind is the default. Only the flat cartesian kinds can differ. + const kind = flatten(s.seriesType ?? chartType) + const isLine = kind === 'line' || kind === 'area' return { type: (isLine ? 'line' : 'bar') as 'line' | 'bar', name: s.name, data: s.values, - stack: model.stacked ? 'total' : undefined, - areaStyle: chartType === 'area' ? {} : undefined, + // Stacking belongs to the chart's own kind, so an overridden + // series is drawn beside the stack rather than in it. + stack: + model.stacked && kind === chartType ? 'total' : undefined, + areaStyle: kind === 'area' ? {} : undefined, ...seriesColor(s), + ...seriesLabel(model, s, isLine ? 'line' : 'bar'), } }) + // A log scale changes the axis' `type`, which ECharts models as a + // discriminated union — the assembled object is cast, the same way the + // series list is. return { - xAxis: horizontal ? valueAxis : categoryAxis, - yAxis: horizontal ? categoryAxis : valueAxis, + xAxis: (horizontal ? valueAxis : categoryAxis) as EChartsOption['xAxis'], + yAxis: (horizontal ? categoryAxis : valueAxis) as EChartsOption['yAxis'], series: series as EChartsOption['series'], } } @@ -87,16 +219,280 @@ function pieOption(model: ChartModel): EChartsOption { name: s?.name, radius: model.chartType === 'doughnut' ? ['45%', '70%'] : '70%', data, + ...(s ? seriesLabel(model, s, 'pie') : {}), }, ], } } +/** + * Radar: one spoke per category, shared by every series. ECharts wants a single + * max across the indicators, so it is taken from the data — leaving it + * undefined makes each spoke self-scale, which misreads the shape. + */ +function radarOption(model: ChartModel): EChartsOption { + const values = model.series.flatMap((s) => + s.values.filter((v): v is number => v != null) + ) + const max = values.length ? Math.max(...values) : undefined + return { + radar: { + indicator: model.categories.map((c) => ({name: String(c), max})), + }, + series: [ + { + type: 'radar', + data: model.series.map((s) => ({ + name: s.name, + value: s.values.map((v) => v ?? 0), + ...(toCssColor(s.color) + ? {itemStyle: {color: toCssColor(s.color)}} + : {}), + })), + }, + ], + } +} + +/** + * Bubble: a scatter whose points carry a third value as their radius. Excel + * sizes a bubble by *area*, so the radius goes as the square root; the largest + * bubble in the chart is normalized to a fixed pixel size so one huge value + * cannot swallow the plot. + */ +function bubbleOption(model: ChartModel): EChartsOption { + const MAX_RADIUS = 40 + const DEFAULT_RADIUS = 10 + const largest = Math.max( + 0, + ...model.series.flatMap((s) => + (s.sizes ?? []).map((v) => (v == null ? 0 : Math.abs(v))) + ) + ) + const radius = (size: number | null | undefined) => { + if (size == null || largest <= 0) return DEFAULT_RADIUS + return Math.sqrt(Math.abs(size) / largest) * MAX_RADIUS + } + return { + xAxis: { + type: 'value', + name: model.catAxisTitle, + ...axisScale(model.catAxisScale), + } as EChartsOption['xAxis'], + yAxis: { + type: 'value', + name: model.valAxisTitle, + ...valueAxisLabel(model), + ...axisScale(model.valAxisScale), + } as EChartsOption['yAxis'], + series: model.series.map((s) => ({ + type: 'scatter' as const, + name: s.name, + // [x, y, size] — the third slot is what `symbolSize` reads. + data: s.values.map((v, i) => [ + Number(model.categories[i] ?? i), + v ?? null, + s.sizes?.[i] ?? null, + ]), + symbolSize: (d: Array) => radius(d[2]), + ...seriesColor(s), + ...seriesLabel(model, s, 'line'), + })) as EChartsOption['series'], + } +} + +/** + * Stock: the series *are* the price components, so they are read positionally + * the way Excel does — four series is open/high/low/close, three is + * high/low/close. ECharts wants [open, close, low, high] per point; an HLC + * chart has no open, so open is set to the close, which draws the high-low + * range with a tick at the close. Any other series count is not a stock shape + * at all, so it falls back to plain lines rather than inventing prices. + */ +function stockOption(model: ChartModel): EChartsOption { + const n = model.series.length + if (n !== 3 && n !== 4) return cartesianOption(model) + const [open, high, low, close] = + n === 4 + ? [model.series[0], model.series[1], model.series[2], model.series[3]] + : [model.series[2], model.series[0], model.series[1], model.series[2]] + + const at = (s: ChartSeries, i: number) => s.values[i] ?? null + const data = model.categories.map((_, i) => [ + at(open, i), + at(close, i), + at(low, i), + at(high, i), + ]) + return { + xAxis: { + type: 'category', + data: model.categories.map((c) => String(c)), + name: model.catAxisTitle, + } as EChartsOption['xAxis'], + yAxis: { + type: 'value', + name: model.valAxisTitle, + // Prices rarely start at zero, so let the axis frame the data. + scale: true, + ...valueAxisLabel(model), + ...axisScale(model.valAxisScale), + } as EChartsOption['yAxis'], + series: [{type: 'candlestick', name: model.series[0]?.name, data}], + } +} + +/** + * Split an of-pie chart's single series into the main plot and the second one, + * the way `c:splitType` describes. Returns indices, so both plots can label + * their slices from `categories`. + */ +function ofPieSplit(model: ChartModel): {main: number[]; second: number[]} { + const values = model.series[0]?.values ?? [] + const all = values.map((_, i) => i) + const split = model.ofPieSplit + const pos = split?.pos + let second: number[] = [] + switch (split?.by) { + case 'pos': + // The last N points, by position. + second = pos ? all.slice(Math.max(0, all.length - pos)) : [] + break + case 'val': + second = pos == null ? [] : all.filter((i) => (values[i] ?? 0) < pos) + break + case 'percent': { + const total = values.reduce((a, v) => a + (v ?? 0), 0) + second = + pos == null || total === 0 + ? [] + : all.filter((i) => ((values[i] ?? 0) / total) * 100 < pos) + break + } + default: + // `auto` (and `cust`, whose per-point assignment we do not model): + // Excel's default is the last two points. + second = all.slice(Math.max(0, all.length - 2)) + } + const inSecond = new Set(second) + return {main: all.filter((i) => !inSecond.has(i)), second} +} + +/** + * Pie of pie / bar of pie: the main plot shows the points that were not split + * off plus one slice standing for the rest, and the second plot breaks that + * rest down. `barOfPie` draws the second plot as a stacked column instead. + */ +function ofPieOption(model: ChartModel): EChartsOption { + const s = model.series[0] + const values = s?.values ?? [] + const {main, second} = ofPieSplit(model) + const label = (i: number) => String(model.categories[i] ?? i + 1) + const value = (i: number) => values[i] ?? 0 + const rest = second.reduce((a, i) => a + value(i), 0) + + const mainData = main.map((i) => ({name: label(i), value: value(i)})) + if (second.length) mainData.push({name: 'Other', value: rest}) + + const asBar = model.chartType === 'barOfPie' + // The second plot is sized relative to the first, as Excel does. + const secondRadius = `${Math.min(60, ((model.ofPieSplit?.secondSize ?? 75) / 100) * 45)}%` + + const secondPlot = asBar + ? second.map((i) => ({ + type: 'bar' as const, + name: label(i), + stack: 'other', + data: [value(i)], + })) + : [ + { + type: 'pie' as const, + name: 'Other', + center: ['78%', '50%'], + radius: secondRadius, + data: second.map((i) => ({name: label(i), value: value(i)})), + }, + ] + + return { + // Only the bar form needs a cartesian frame, and only for its column. + ...(asBar + ? { + grid: {left: '58%', right: '6%', top: 24, bottom: 24}, + xAxis: {type: 'category', data: ['Other'], show: false}, + yAxis: {type: 'value', show: false}, + } + : {}), + series: [ + { + type: 'pie', + name: s?.name, + center: asBar ? ['28%', '50%'] : ['26%', '50%'], + radius: '55%', + data: mainData, + }, + ...secondPlot, + ] as EChartsOption['series'], + } +} + +/** + * Surface: a value grid, one series per row. ECharts core cannot draw a 3-D + * surface (that needs echarts-gl, which this package deliberately does not + * pull in), so it is rendered as a heatmap of the same grid — the flat contour + * form of the same chart. `surface3d` is drawn the same way. + */ +function surfaceOption(model: ChartModel): EChartsOption { + const data: Array<[number, number, number]> = [] + let min = Infinity + let max = -Infinity + model.series.forEach((s, row) => { + s.values.forEach((v, col) => { + if (v == null) return + data.push([col, row, v]) + if (v < min) min = v + if (v > max) max = v + }) + }) + const finite = Number.isFinite(min) && Number.isFinite(max) + return { + xAxis: { + type: 'category', + data: model.categories.map((c) => String(c)), + name: model.catAxisTitle, + } as EChartsOption['xAxis'], + yAxis: { + type: 'category', + data: model.series.map((s, i) => s.name ?? String(i + 1)), + name: model.valAxisTitle, + } as EChartsOption['yAxis'], + visualMap: { + min: finite ? min : 0, + max: finite ? max : 1, + calculable: true, + orient: 'horizontal', + left: 'center', + bottom: 0, + }, + series: [{type: 'heatmap', name: model.title, data}], + } +} + /** Scatter: each series is a set of (x, y) pairs zipping categories × values. */ function scatterOption(model: ChartModel): EChartsOption { return { - xAxis: {type: 'value', name: model.catAxisTitle}, - yAxis: {type: 'value', name: model.valAxisTitle}, + xAxis: { + type: 'value', + name: model.catAxisTitle, + ...axisScale(model.catAxisScale), + } as EChartsOption['xAxis'], + yAxis: { + type: 'value', + name: model.valAxisTitle, + ...valueAxisLabel(model), + ...axisScale(model.valAxisScale), + } as EChartsOption['yAxis'], series: model.series.map((s) => ({ type: 'scatter' as const, name: s.name, @@ -105,13 +501,14 @@ function scatterOption(model: ChartModel): EChartsOption { v ?? null, ]), ...seriesColor(s), + ...seriesLabel(model, s, 'line'), })) as EChartsOption['series'], } } export function mapChartToOption(model: ChartModel): EChartsOption { let body: EChartsOption - switch (model.chartType) { + switch (flatten(model.chartType)) { case 'pie': case 'doughnut': body = pieOption(model) @@ -119,6 +516,23 @@ export function mapChartToOption(model: ChartModel): EChartsOption { case 'scatter': body = scatterOption(model) break + case 'bubble': + body = bubbleOption(model) + break + case 'radar': + body = radarOption(model) + break + case 'stock': + body = stockOption(model) + break + case 'ofPie': + case 'barOfPie': + body = ofPieOption(model) + break + case 'surface': + case 'surface3d': + body = surfaceOption(model) + break default: body = cartesianOption(model) } @@ -126,15 +540,37 @@ export function mapChartToOption(model: ChartModel): EChartsOption { const showLegend = !!model.legendPosition && model.legendPosition !== 'none' + // Only the cartesian kinds sit in a `grid`; pie, radar and the XY kinds + // place themselves, and only the axis-based ones get an axis tooltip. + // `ofPie` places its own plots; `barOfPie` brings its own grid for the + // column half, so neither takes the shared one. + const flat = flatten(model.chartType) + const gridless = [ + 'pie', + 'doughnut', + 'radar', + 'ofPie', + 'barOfPie', + 'surface', + ].includes(flat) + const itemTooltip = [ + 'scatter', + 'bubble', + 'radar', + 'pie', + 'doughnut', + 'ofPie', + 'barOfPie', + 'surface', + ].includes(flat) return { title: model.title ? {text: model.title, left: 'center'} : undefined, legend: legendOption(model.legendPosition), - tooltip: {trigger: model.chartType === 'scatter' ? 'item' : 'axis'}, + tooltip: {trigger: itemTooltip ? 'item' : 'axis'}, // Leave room for title/legend without hand-tuning per chart. - grid: - model.chartType === 'pie' || model.chartType === 'doughnut' - ? undefined - : { + grid: gridless + ? undefined + : { top: model.title ? 40 : 16, bottom: showLegend ? 40 : 24, left: 48, diff --git a/packages/engine/src/lib/chart/types.ts b/packages/engine/src/lib/chart/types.ts index b540ace4..175921be 100644 --- a/packages/engine/src/lib/chart/types.ts +++ b/packages/engine/src/lib/chart/types.ts @@ -13,9 +13,24 @@ * here. */ +/** + * The patch an editor sends to reconfigure an existing chart: every field of + * the core's `UpdateChart` payload except the addressing ones, which the host + * fills in. Anything left out keeps its current value. + */ +export type ChartUpdate = Omit< + import('logisheets-web').UpdateChart, + 'sheetIdx' | 'chartId' +> + /** * The chart kinds we render. `col`/`bar` differ only in orientation; `area` is - * a filled line; `doughnut` is a pie with a hole. Scatter uses (x, y) pairs. + * a filled line; `doughnut` is a pie with a hole. Scatter uses (x, y) pairs and + * `bubble` adds a third value sizing each point. `radar` plots one spoke per + * category. `stock`'s series are the price components. `ofPie`/`barOfPie` split + * one series across two plots. `surface`/`surface3d` are a value grid. The + * `*3d` kinds render as their flat equivalents — the depth is preserved for + * Excel but not drawn here. */ export type ChartType = | 'col' @@ -25,6 +40,18 @@ export type ChartType = | 'pie' | 'doughnut' | 'scatter' + | 'radar' + | 'bubble' + | 'stock' + | 'ofPie' + | 'barOfPie' + | 'surface' + | 'surface3d' + | 'col3d' + | 'bar3d' + | 'line3d' + | 'area3d' + | 'pie3d' export type LegendPosition = 'top' | 'bottom' | 'left' | 'right' | 'none' @@ -36,11 +63,59 @@ export interface ChartSeries { * that are empty / non-numeric resolve to `null` so gaps render as gaps. */ values: Array + /** + * `values` already rendered with the label's number format, index-aligned. + * Excel format codes are evaluated by the core (which owns the formatter), + * so a data label shows these strings rather than re-deriving them. + */ + formattedValues?: Array /** * ARGB/RGB hex (e.g. "FF4472C4" or "4472C4"). Undefined → library default * palette. */ color?: string + /** The series' source range, e.g. `Sheet1!$B$2:$E$2`. */ + valRef?: string + /** + * Draw this series as a different kind than the chart's own — what makes a + * combo chart. Undefined follows {@link ChartModel.chartType}. + */ + seriesType?: ChartType + /** + * Bubble sizes, index-aligned with `values`. Only a bubble chart has them; + * missing or non-numeric entries fall back to a default radius. + */ + sizes?: Array + /** The bubble-size source range. */ + sizeRef?: string +} + +/** + * An axis' scale. Everything undefined (and `reversed` false) is a fully + * automatic axis, which is what most charts have. + */ +export interface AxisScale { + min?: number + max?: number + /** Log-scale base; undefined is a linear axis. */ + logBase?: number + reversed: boolean + /** Spacing between major ticks / gridlines. */ + majorUnit?: number + minorUnit?: number +} + +/** + * What is drawn next to each data point. `show` false (the default) means no + * labels at all; the other flags pick which parts make up the label text. + */ +export interface DataLabels { + value: boolean + category: boolean + series: boolean + percent: boolean + /** OOXML `c:dLblPos`: ctr | inEnd | outEnd | inBase | bestFit. */ + position?: string } export interface ChartModel { @@ -60,4 +135,31 @@ export interface ChartModel { stacked?: boolean catAxisTitle?: string valAxisTitle?: string + /** Point labels. Omitted / all-false means the chart shows none. */ + dataLabels?: DataLabels + /** + * Excel number-format code for the value axis ticks. Ticks are picked by + * the renderer, so unlike data labels they are formatted host-side (see + * `formatAxisNumber`, which covers the common numeric codes). + */ + valAxisNumFmt?: string + /** The category (X) source range, e.g. `Sheet1!$A$2:$A$5`. */ + catRef?: string + /** Value-axis scale. Ignored by pie and doughnut, which have no axes. */ + valAxisScale?: AxisScale + catAxisScale?: AxisScale + /** How an of-pie chart divides its series between the two plots. */ + ofPieSplit?: OfPieSplit +} + +/** + * The division between an of-pie chart's two plots, as authored. `by` picks how + * `pos` is read: `pos` is a count of trailing points, `val` a threshold, + * `percent` a share of the total. + */ +export interface OfPieSplit { + by?: string + pos?: number + /** The second plot's size as a percentage of the first. */ + secondSize?: number } diff --git a/packages/engine/src/lib/components/Spreadsheet.svelte b/packages/engine/src/lib/components/Spreadsheet.svelte index bd9087cb..9574fc44 100644 --- a/packages/engine/src/lib/components/Spreadsheet.svelte +++ b/packages/engine/src/lib/components/Spreadsheet.svelte @@ -2,10 +2,13 @@ import { onMount } from 'svelte' import type { Grid, CellLayout, EngineConfig, ZoomOrigin } from '$types/index' import { DEFAULT_ENGINE_CONFIG } from '$types/index' - import type { SelectedData, SheetInfo, Transaction, EditPayload, StyleUpdateType, ChartInfo } from 'logisheets-web' + import type { SelectedData, SheetInfo, Transaction, EditPayload, StyleUpdateType, ChartInfo, UpdateChart } from 'logisheets-web' import { isErrorMessage } from 'logisheets-web' import { DataService } from '$lib/clients/service' import ChartView from '$lib/chart/ChartView.svelte' + import ChartSettings from '$lib/chart/ChartSettings.svelte' + import {chartDataRefsFromSelection} from '$lib/chart/from-selection' + import {chartSourceRanges, isRangeVisible} from '$lib/chart/source-ranges' import { mapChartToOption, chartInfoToModel } from '$lib/chart' // Inlined (base64 blob) so the published bundle is self-contained — see // the matching import in engine.ts for why a separate worker asset breaks @@ -15,6 +18,8 @@ match, xForColStart, xForColEnd, + xForColStartUnclamped, + yForRowStartUnclamped, yForRowStart, yForRowEnd, getSelectedCellRange, @@ -209,10 +214,20 @@ let isDragging = false; // True while user is drag-selecting const g = grid if (!g) return [] return charts.map((c) => { - const x0 = xForColStart(c.fromCol, g) + c.fromColOff / EMU_PER_PX - const y0 = yForRowStart(c.fromRow, g) + c.fromRowOff / EMU_PER_PX - const x1 = xForColStart(c.toCol, g) + c.toColOff / EMU_PER_PX - const y1 = yForRowStart(c.toRow, g) + c.toRowOff / EMU_PER_PX + // Anchors are positioned unclamped: a chart scrolled past the top + // must slide away with its cells, not flatten against the edge. + const x0 = xForColStartUnclamped(c.fromCol, g) + c.fromColOff / EMU_PER_PX + const y0 = yForRowStartUnclamped(c.fromRow, g) + c.fromRowOff / EMU_PER_PX + // A size-anchored chart (`oneCellAnchor`) repeats its `from` cell + // as `to` and carries the frame size in `extCx`/`extCy` instead — + // deriving the box from the corners would give it zero size. + const sized = c.extCx != null && c.extCy != null + const x1 = sized + ? x0 + c.extCx! / EMU_PER_PX + : xForColStartUnclamped(c.toCol, g) + c.toColOff / EMU_PER_PX + const y1 = sized + ? y0 + c.extCy! / EMU_PER_PX + : yForRowStartUnclamped(c.toRow, g) + c.toRowOff / EMU_PER_PX return { id: c.chartId, left: x0, @@ -225,10 +240,81 @@ let isDragging = false; // True while user is drag-selecting }) }) - const CHART_TYPES = ['col', 'bar', 'line', 'area', 'pie', 'doughnut', 'scatter'] + // The quick picker on a selected chart. Kept in step with the fuller, + // labelled list in ChartSettings.svelte. + const CHART_TYPES = [ + 'col', + 'bar', + 'line', + 'area', + 'pie', + 'doughnut', + 'scatter', + 'radar', + 'bubble', + 'stock', + 'ofPie', + 'barOfPie', + 'surface', + 'surface3d', + 'col3d', + 'bar3d', + 'line3d', + 'area3d', + 'pie3d', + ] // ---- Chart selection, move, resize, delete ------------------------- let selectedChartId: string | null = $state(null) + + /** + * The source ranges of the selected chart, as boxes in the same + * canvas-data space the chart boxes use — so selecting a chart outlines + * the cells it plots, the way Excel does. Only ranges on the sheet being + * shown can be drawn; one pointing at another sheet is listed in the + * editor instead. + */ + const chartSourceBoxes = $derived.by(() => { + const g = grid + const chart = charts.find((c) => c.chartId === selectedChartId) + if (!g || !chart) return [] + const sheetName = sheets[activeSheet]?.name + // The grid only knows the size of the rows and columns it has laid + // out, so a range outside that window has no drawable position. + const window = { + firstRow: g.rows[0]?.idx ?? 0, + lastRow: g.rows[g.rows.length - 1]?.idx ?? 0, + firstCol: g.columns[0]?.idx ?? 0, + lastCol: g.columns[g.columns.length - 1]?.idx ?? 0, + } + return chartSourceRanges(chart) + .filter((src) => src.sheet === undefined || src.sheet === sheetName) + .filter((src) => isRangeVisible(src.range, window)) + .map((src, i) => { + const {startRow, startCol, endRow, endCol} = src.range + // Unclamped like the chart boxes, so a range that is only + // half in view keeps its real edges and is clipped by the + // layer rather than drawing a false border at the viewport's + // edge. A cell's far edge is the next one's near edge. + const left = xForColStartUnclamped(startCol, g) + const top = yForRowStartUnclamped(startRow, g) + return { + key: `${src.kind}-${i}`, + kind: src.kind, + color: src.color, + left, + top, + width: Math.max(0, xForColStartUnclamped(endCol + 1, g) - left), + height: Math.max(0, yForRowStartUnclamped(endRow + 1, g) - top), + } + }) + .filter((b) => b.width > 0 && b.height > 0) + }) + // The settings popover is opened per selection, from the gear button. + let chartSettingsOpen = $state(false) + const selectedChart = $derived( + charts.find((c) => c.chartId === selectedChartId) + ) let chartDrag: {id: string; startX: number; startY: number; dx: number; dy: number} | null = $state(null) let chartResize: @@ -335,6 +421,7 @@ let isDragging = false; // True while user is drag-selecting function onChartMouseDown(e: MouseEvent, id: string) { e.stopPropagation() e.preventDefault() + if (selectedChartId !== id) chartSettingsOpen = false selectedChartId = id chartDrag = {id, startX: e.clientX, startY: e.clientY, dx: 0, dy: 0} const move = (ev: MouseEvent) => { @@ -408,6 +495,10 @@ let isDragging = false; // True while user is drag-selecting toColOff: to.colOff, toRow: to.row, toRowOff: to.rowOff, + // MoveChart always states two corners, so a formerly + // size-anchored chart is now cell-anchored. + extCx: undefined, + extCy: undefined, } : c ) @@ -446,23 +537,18 @@ let isDragging = false; // True while user is drag-selecting }) } - // Reconfigure an existing chart (type/title). UpdateChart changes no cell - // values, so await the transaction then refetch (like insertChart). + // Reconfigure an existing chart. UpdateChart changes no cell values, so + // await the transaction then refetch (like insertChart). export async function updateChart( id: string, - opts: {chartType?: string; title?: string} + opts: Omit ) { if (!dataService) return await dataService.handleTransaction({ payloads: [ { type: 'updateChart', - value: { - sheetIdx: activeSheet, - chartId: id, - chartType: opts.chartType, - title: opts.title, - }, + value: {...opts, sheetIdx: activeSheet, chartId: id}, }, ], undoable: true, @@ -1090,6 +1176,7 @@ let isDragging = false; // True while user is drag-selecting // Clicking the grid deselects any selected chart. selectedChartId = null + chartSettingsOpen = false if (e.button !== 0) return // Only left click if (!grid || !canvasEl) return @@ -2294,27 +2381,35 @@ let isDragging = false; // True while user is drag-selecting // ======================================================================== /** - * Insert a chart from the current selection. Each selected column becomes a - * series (values down the rows); the chart is anchored just below the - * selection. `chartType` is col|bar|line|area|pie|doughnut|scatter. + * Insert a chart from the current selection, inferring its shape the way + * Excel does (see `chartDataRefsFromSelection`). The chart is anchored just + * below the selection. */ export async function insertChart(chartType: string = 'col') { if (!dataService) return const range = getSelectedCellRange(selectedData) if (!range) return - const startRow = Math.min(range.startRow, range.endRow) const endRow = Math.max(range.startRow, range.endRow) const startCol = Math.min(range.startCol, range.endCol) - const endCol = Math.max(range.startCol, range.endCol) const sheetName = sheets[activeSheet]?.name ?? 'Sheet1' - const qs = quoteSheetName(sheetName) - const series: {name: string | undefined; valueRef: string}[] = [] - for (let c = startCol; c <= endCol; c++) { - const col = toA1notation(c) - const valueRef = `${qs}!$${col}$${startRow + 1}:$${col}$${endRow + 1}` - series.push({name: undefined, valueRef}) - } + const {categoriesRef, series} = await chartDataRefsFromSelection( + chartType, + range, + sheetName, + { + isText: async (r, c) => { + const info = await dataService!.getCellInfo(activeSheet, r, c) + if (isErrorMessage(info)) return false + const v = info.toCellInfo().value + return v !== 'empty' && v.type === 'str' && v.value !== '' + }, + textAt: async (r, c) => { + const info = await dataService!.getCellInfo(activeSheet, r, c) + return isErrorMessage(info) ? undefined : info.getText() || undefined + }, + } + ) // Anchor below the selection, spanning ~8 cols × 15 rows. const fromRow = endRow + 2 @@ -2336,7 +2431,7 @@ let isDragging = false; // True while user is drag-selecting toColOff: 0, toRowOff: 0, title: undefined, - categoriesRef: undefined, + categoriesRef, series, }, }, @@ -2548,6 +2643,14 @@ let isDragging = false; // True while user is drag-selecting class="chart-layer" style="left: {LeftTop.width}px; top: {LeftTop.height}px; width: calc(100% - {LeftTop.width}px - {showScrollbars ? cfg.scrollbarSize : 0}px); height: calc(100% - {LeftTop.height}px - {showScrollbars ? cfg.scrollbarSize : 0}px);" > + + {#each chartSourceBoxes as src (src.key)} +
+ {/each} {#each chartBoxes as box (box.id)} {@const r = displayChartRect(box)}
onChartMouseDown(e, box.id)} >
{#if selectedChartId === box.id} - + + + + {#if chartSettingsOpen && selectedChart} + + updateChart(box.id, patch)} + onClose={() => (chartSettingsOpen = false)} + /> + {/if} {#each ['nw', 'ne', 'sw', 'se'] as corner (corner)}
= {}): Grid { + return { + rows: Array.from({length: 5}, (_, i) => ({idx: 10 + i, height: 20})), + columns: Array.from({length: 4}, (_, i) => ({idx: 5 + i, width: 100})), + subOffsetX: 30, + subOffsetY: 5, + ...over, + } as unknown as Grid +} + +describe('yForRowStartUnclamped', () => { + it('agrees with the clamped helper inside the window', () => { + const g = grid() + for (const row of [10, 11, 14]) { + expect(yForRowStartUnclamped(row, g)).toBe(yForRowStart(row, g)) + } + }) + + it('extrapolates above the window instead of pinning to the edge', () => { + const g = grid() + // Row 10's top edge is -5 (it is scrolled 5px off). Row 9 is one row + // higher, row 5 is five rows higher. + expect(yForRowStartUnclamped(10, g)).toBe(-5) + expect(yForRowStartUnclamped(9, g)).toBe(-25) + expect(yForRowStartUnclamped(5, g)).toBe(-105) + }) + + it('extrapolates below the window', () => { + const g = grid() + // Rows 10..14 occupy -5..95, so row 15 starts at 95. + expect(yForRowStartUnclamped(15, g)).toBe(95) + expect(yForRowStartUnclamped(18, g)).toBe(155) + }) + + it('keeps a distance that the clamped helper would collapse', () => { + const g = grid() + // The anchors of something spanning rows 2..6, entirely above the + // window: clamped, both edges land on the window edge and it has no + // height at all. + expect(yForRowStart(6, g) - yForRowStart(2, g)).toBe(0) + expect( + yForRowStartUnclamped(6, g) - yForRowStartUnclamped(2, g) + ).toBe(80) + }) + + it('uses the size of the row nearest the edge it passes', () => { + // A tall first row and a short last one: each side extrapolates with + // its own neighbour rather than one global guess. + const g = grid({ + rows: [ + {idx: 10, height: 50}, + {idx: 11, height: 20}, + {idx: 12, height: 8}, + ], + } as Partial) + expect(yForRowStartUnclamped(9, g)).toBe(-5 - 50) + // Rows 10..12 occupy -5..73, so row 13 starts at 73 and 14 is 8 later. + expect(yForRowStartUnclamped(13, g)).toBe(73) + expect(yForRowStartUnclamped(14, g)).toBe(81) + }) + + it('returns 0 when the grid has no rows laid out yet', () => { + expect(yForRowStartUnclamped(3, grid({rows: []} as Partial))).toBe(0) + }) +}) + +describe('xForColStartUnclamped', () => { + it('agrees with the clamped helper inside the window', () => { + const g = grid() + for (const col of [5, 6, 8]) { + expect(xForColStartUnclamped(col, g)).toBe(xForColStart(col, g)) + } + }) + + it('extrapolates on both sides', () => { + const g = grid() + expect(xForColStartUnclamped(5, g)).toBe(-30) + expect(xForColStartUnclamped(4, g)).toBe(-130) + expect(xForColStartUnclamped(2, g)).toBe(-330) + // Columns 5..8 occupy -30..370, so column 9 starts at 370. + expect(xForColStartUnclamped(9, g)).toBe(370) + expect(xForColStartUnclamped(11, g)).toBe(570) + }) + + it('returns 0 when the grid has no columns laid out yet', () => { + expect(xForColStartUnclamped(3, grid({columns: []} as Partial))).toBe( + 0 + ) + }) +}) diff --git a/packages/engine/src/lib/components/utils.ts b/packages/engine/src/lib/components/utils.ts index 80c190fa..b451d201 100644 --- a/packages/engine/src/lib/components/utils.ts +++ b/packages/engine/src/lib/components/utils.ts @@ -49,6 +49,36 @@ export const yForRowEnd = (rowIdx: number, grid: Grid): number => { return acc; }; +// The four helpers above answer only for rows and columns the grid has laid +// out; asked about one that is scrolled away they return the window's own +// edge. That is right for hit-testing (nothing off-screen can be hit) but +// wrong for positioning something anchored to a cell — an overlay whose anchor +// has scrolled past collapses against the edge instead of scrolling away with +// it. The two below extrapolate instead, at the size of the row/column nearest +// the edge, which is exact whenever the ones outside match it. + +export const xForColStartUnclamped = (colIdx: number, grid: Grid): number => { + const first = grid.columns[0]; + const last = grid.columns[grid.columns.length - 1]; + if (!first || !last) return 0; + if (colIdx < first.idx) + return xForColStart(first.idx, grid) - (first.idx - colIdx) * first.width; + if (colIdx > last.idx) + return xForColEnd(last.idx, grid) + (colIdx - last.idx - 1) * last.width; + return xForColStart(colIdx, grid); +}; + +export const yForRowStartUnclamped = (rowIdx: number, grid: Grid): number => { + const first = grid.rows[0]; + const last = grid.rows[grid.rows.length - 1]; + if (!first || !last) return 0; + if (rowIdx < first.idx) + return yForRowStart(first.idx, grid) - (first.idx - rowIdx) * first.height; + if (rowIdx > last.idx) + return yForRowEnd(last.idx, grid) + (rowIdx - last.idx - 1) * last.height; + return yForRowStart(rowIdx, grid); +}; + export interface CellRect { x: number; y: number; diff --git a/packages/engine/src/lib/engine.ts b/packages/engine/src/lib/engine.ts index 3ba550f2..e150a89e 100644 --- a/packages/engine/src/lib/engine.ts +++ b/packages/engine/src/lib/engine.ts @@ -19,6 +19,7 @@ */ import type { SheetInfo, SelectedData, CellLayout } from "logisheets-web"; +import type {ChartUpdate} from "./chart/types"; import { isErrorMessage } from "logisheets-web"; import { DataService, type BeforeLoadWorkbook } from "./clients/service"; import { WorkbookClient } from "./clients/workbook"; @@ -277,7 +278,7 @@ export class Engine { } /** Reconfigure an existing chart (type and/or title). */ - updateChart(id: string, opts: {chartType?: string; title?: string}): void { + updateChart(id: string, opts: ChartUpdate): void { this._ensureReady(); this.getDefaultSession().updateChart(id, opts); } diff --git a/packages/engine/src/lib/index.ts b/packages/engine/src/lib/index.ts index 1d7086d4..010b3c7d 100644 --- a/packages/engine/src/lib/index.ts +++ b/packages/engine/src/lib/index.ts @@ -83,9 +83,12 @@ export { Range as RangeClass, Cell as CellClass } from "$types/index"; // mapChartToOption is the model→ECharts translation layer. export { ChartView, mapChartToOption } from "./chart"; export type { + AxisScale, ChartModel, ChartSeries, ChartType, + ChartUpdate, + DataLabels, LegendPosition, } from "./chart"; diff --git a/packages/engine/src/lib/session.ts b/packages/engine/src/lib/session.ts index f089f2e2..1cccfaf4 100644 --- a/packages/engine/src/lib/session.ts +++ b/packages/engine/src/lib/session.ts @@ -17,6 +17,7 @@ */ import type { SheetInfo, SelectedData, CellLayout } from "logisheets-web"; +import type {ChartUpdate} from "./chart/types"; import { isErrorMessage } from "logisheets-web"; import type { DataService } from "./clients/service"; import { isLoadCancelled } from "./clients/service"; @@ -330,9 +331,11 @@ export class Session { } /** - * Reconfigure an existing chart (type and/or title). Requires a mounted UI. + * Reconfigure an existing chart: type, title, legend, axis titles, data + * labels, number format, or the data references themselves. Requires a + * mounted UI. */ - updateChart(id: string, opts: {chartType?: string; title?: string}): void { + updateChart(id: string, opts: ChartUpdate): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mounted = this._mountedComponent as any; if (mounted && typeof mounted.updateChart === "function") { diff --git a/src/components/toolbar/index.tsx b/src/components/toolbar/index.tsx index cfd81c88..9221fb93 100644 --- a/src/components/toolbar/index.tsx +++ b/src/components/toolbar/index.tsx @@ -97,6 +97,33 @@ const FONT_FAMILIES = [ '楷体', ] +/** The chart kinds the engine can create from a selection. */ +const CHART_TYPES = [ + {value: 'col', label: 'Column'}, + {value: 'bar', label: 'Bar'}, + {value: 'line', label: 'Line'}, + {value: 'area', label: 'Area'}, + {value: 'pie', label: 'Pie'}, + {value: 'doughnut', label: 'Doughnut'}, + {value: 'scatter', label: 'Scatter'}, + {value: 'radar', label: 'Radar'}, + // Reads three columns from the selection: X, Y, then bubble size. + {value: 'bubble', label: 'Bubble'}, + // Stock reads its series positionally: 4 columns is open/high/low/close, + // 3 is high/low/close. + {value: 'stock', label: 'Stock'}, + {value: 'ofPie', label: 'Pie of pie'}, + {value: 'barOfPie', label: 'Bar of pie'}, + {value: 'surface', label: 'Surface'}, + {value: 'surface3d', label: 'Surface (3-D)'}, + // The 3-D forms round-trip to Excel as 3-D but are drawn flat here. + {value: 'col3d', label: 'Column (3-D)'}, + {value: 'bar3d', label: 'Bar (3-D)'}, + {value: 'line3d', label: 'Line (3-D)'}, + {value: 'area3d', label: 'Area (3-D)'}, + {value: 'pie3d', label: 'Pie (3-D)'}, +] + export interface ToolbarProps { setGrid: (grid: Grid | null) => void setActiveSheet: (idx: number) => void @@ -267,6 +294,7 @@ export const Toolbar = observer( // Alignment popover const [alignAnchor, setAlignAnchor] = useState(null) + const [chartAnchor, setChartAnchor] = useState(null) const [alignment, setAlignment] = useState(null) const [wrapText, setWrapText] = useState(false) const [bookName, setBookName] = useState('Untitled') @@ -1268,17 +1296,36 @@ export const Toolbar = observer( {/* Insert / create */}
- + engine.insertChart('col')} + onClick={(e) => + setChartAnchor(e.currentTarget) + } disabled={!hasSelectedData} > + setChartAnchor(null)} + > + {CHART_TYPES.map((t) => ( + { + setChartAnchor(null) + engine.insertChart(t.value) + }} + > + {t.label} + + ))} +