diff --git a/src/helpers/figures/charts/chart_common.ts b/src/helpers/figures/charts/chart_common.ts index 604f6e1000..bd278204f8 100644 --- a/src/helpers/figures/charts/chart_common.ts +++ b/src/helpers/figures/charts/chart_common.ts @@ -24,9 +24,11 @@ import { DataSet, DatasetValues, ExcelChartDataset, + ExcelChartTrendConfiguration, GenericDefinition, } from "../../../types/chart/chart"; import { CellErrorType } from "../../../types/errors"; +import { CHART_TRENDLINE_TYPE_CONVERSION_MAP_REVERSE } from "../../../xlsx/conversion"; import { ColorGenerator, relativeLuminance } from "../../color"; import { formatValue } from "../../format/format"; import { isDefined, largeMax } from "../../misc"; @@ -35,6 +37,7 @@ import { rangeReference } from "../../references"; import { getZoneArea, isFullRow, toUnboundedZone, zoneToDimension, zoneToXc } from "../../zones"; export const TREND_LINE_XAXIS_ID = "x1"; +export const MAX_EXCEL_POLYNOMIAL_DEGREE = 6; /** * This file contains helpers that are common to different charts (mainly @@ -192,6 +195,7 @@ export function createDataSets( backgroundColor: dataSet.backgroundColor, rightYAxis: dataSet.yAxisId === "y1", customLabel: dataSet.label, + trend: dataSet.trend, }); } } else { @@ -213,6 +217,7 @@ export function createDataSets( backgroundColor: dataSet.backgroundColor, rightYAxis: dataSet.yAxisId === "y1", customLabel: dataSet.label, + trend: dataSet.trend, }); } } @@ -272,6 +277,49 @@ export function toExcelDataset(getters: CoreGetters, ds: DataSet): ExcelChartDat }; } + let trend: ExcelChartTrendConfiguration | undefined = undefined; + if (ds?.trend?.type) { + trend = { + type: CHART_TRENDLINE_TYPE_CONVERSION_MAP_REVERSE[ds.trend.type], + }; + if (ds.trend.color) { + trend = { + ...trend, + color: ds.trend.color, + }; + } + if (ds.trend.type === "polynomial" && ds.trend.order) { + if (ds.trend.order === 1) { + trend = { + ...trend, + type: "linear", + }; + } else { + trend = { + ...trend, + order: + ds.trend.order > MAX_EXCEL_POLYNOMIAL_DEGREE + ? MAX_EXCEL_POLYNOMIAL_DEGREE + : ds.trend.order, + }; + } + } + if (ds.trend.type === "trailingMovingAverage" && ds.trend.window) { + trend = { + ...trend, + window: ds.trend.window, + }; + } + } + if (trend) { + return { + label, + range: getters.getRangeString(dataRange, "forceSheetReference", { useFixedReference: true }), + backgroundColor: ds.backgroundColor, + rightYAxis: ds.rightYAxis, + trend, + }; + } return { label, range: getters.getRangeString(dataRange, "forceSheetReference", { useFixedReference: true }), diff --git a/src/types/chart/chart.ts b/src/types/chart/chart.ts index da1195f6c0..4095fc920a 100644 --- a/src/types/chart/chart.ts +++ b/src/types/chart/chart.ts @@ -116,14 +116,25 @@ export interface DataSet { readonly rightYAxis?: boolean; // if the dataset should be on the right Y axis readonly backgroundColor?: Color; readonly customLabel?: string; + readonly trend?: TrendConfiguration; } export interface ExcelChartDataset { readonly label?: { text?: string } | { reference?: string }; readonly range: string; readonly backgroundColor?: Color; readonly rightYAxis?: boolean; + readonly trend?: ExcelChartTrendConfiguration; } +export interface ExcelChartTrendConfiguration { + readonly type?: ExcelTrendlineType; + readonly order?: number; + readonly color?: Color; + readonly window?: number; +} + +export type ExcelTrendlineType = "poly" | "exp" | "log" | "movingAvg" | "linear"; + export type ExcelChartType = "line" | "bar" | "pie" | "combo" | "scatter" | "radar"; export interface ExcelChartDefinition { diff --git a/src/xlsx/conversion/conversion_maps.ts b/src/xlsx/conversion/conversion_maps.ts index 53c6e9fad2..768ac2e1b7 100644 --- a/src/xlsx/conversion/conversion_maps.ts +++ b/src/xlsx/conversion/conversion_maps.ts @@ -208,6 +208,20 @@ export const CHART_TYPE_CONVERSION_MAP: Record actual function*/ export const SUBTOTAL_FUNCTION_CONVERSION_MAP: Record = { "1": "AVERAGE", diff --git a/src/xlsx/conversion/figure_conversion.ts b/src/xlsx/conversion/figure_conversion.ts index 2256ce2f63..56b7a5b59d 100644 --- a/src/xlsx/conversion/figure_conversion.ts +++ b/src/xlsx/conversion/figure_conversion.ts @@ -5,12 +5,20 @@ import { toUnboundedZone, zoneToXc, } from "../../helpers"; -import { ChartDefinition, ExcelChartDefinition, FigureData } from "../../types"; +import { + ChartDefinition, + ExcelChartDefinition, + ExcelChartTrendConfiguration, + ExcelTrendlineType, + FigureData, + TrendConfiguration, +} from "../../types"; import { ExcelImage } from "../../types/image"; import { XLSXFigure, XLSXWorksheet } from "../../types/xlsx"; import { convertEMUToDotValue, getColPosition, getRowPosition } from "../helpers/content_helpers"; import { XLSXFigureAnchor } from "./../../types/xlsx"; import { convertColor } from "./color_conversion"; +import { CHART_TRENDLINE_TYPE_CONVERSION_MAP } from "./conversion_maps"; export function convertFigures(sheetData: XLSXWorksheet): FigureData[] { let id = 1; @@ -84,6 +92,7 @@ function convertChartData(chartData: ExcelChartDefinition): ChartDefinition | un dataRange: convertExcelRangeToSheetXC(data.range, dataSetsHaveTitle), label, backgroundColor: data.backgroundColor, + trend: convertExcelTrendline(data.trend), }; }); // For doughnut charts, in chartJS first dataset = outer dataset, in excel first dataset = inner dataset @@ -121,6 +130,30 @@ function convertExcelRangeToSheetXC(range: string, dataSetsHaveTitle: boolean): return getFullReference(sheetName, dataXC); } +function convertExcelTrendline( + trend: ExcelChartTrendConfiguration | undefined +): TrendConfiguration | undefined { + if (!trend) { + return undefined; + } + if (trend.type === "linear") { + return { + type: "polynomial", + order: 1, + color: trend.color, + window: trend.window, + display: true, + }; + } + return { + type: CHART_TRENDLINE_TYPE_CONVERSION_MAP[trend.type as ExcelTrendlineType], + order: trend.order, + color: trend.color, + window: trend.window, + display: true, + }; +} + function getPositionFromAnchor( anchor: XLSXFigureAnchor, sheetData: XLSXWorksheet diff --git a/src/xlsx/extraction/chart_extractor.ts b/src/xlsx/extraction/chart_extractor.ts index b4e8c902e2..1ebe7e89ab 100644 --- a/src/xlsx/extraction/chart_extractor.ts +++ b/src/xlsx/extraction/chart_extractor.ts @@ -1,5 +1,10 @@ import { toHex } from "../../helpers"; -import { ExcelChartDataset, ExcelChartDefinition } from "../../types"; +import { + ExcelChartDataset, + ExcelChartDefinition, + ExcelChartTrendConfiguration, + ExcelTrendlineType, +} from "../../types"; import { XLSXChartType, XLSX_CHART_TYPES } from "../../types/xlsx"; import { CHART_TYPE_CONVERSION_MAP, DRAWING_LEGEND_POSITION_CONVERSION_MAP } from "../conversion"; import { removeTagEscapedNamespaces } from "../helpers/xml_helpers"; @@ -139,6 +144,7 @@ export class XlsxChartExtractor extends XlsxBaseExtractor { required: true, })!, backgroundColor: color ? `${toHex(color.asString())}` : undefined, + trend: this.extractChartTrendline(chartDataElement), }; } ); @@ -146,6 +152,40 @@ export class XlsxChartExtractor extends XlsxBaseExtractor { .flat(); } + private extractChartTrendline( + chartDataElement: Element + ): ExcelChartTrendConfiguration | undefined { + const trendlineElement = this.querySelector(chartDataElement, "c:trendline"); + if (!trendlineElement) { + return undefined; + } + const trendlineType = this.extractChildAttr(trendlineElement, "c:trendlineType", "val"); + const trendlineColor = this.extractChildAttr(trendlineElement, "a:solidFill a:srgbClr", "val"); + let trendlineConfig: ExcelChartTrendConfiguration = { + type: trendlineType ? (trendlineType.asString() as ExcelTrendlineType) : undefined, + color: trendlineColor ? `${toHex(trendlineColor.asString())}` : undefined, + }; + if (trendlineConfig.type === "poly") { + const trendlineOrder = this.extractChildAttr(trendlineElement, "c:order", "val"); + if (trendlineOrder) { + trendlineConfig = { + ...trendlineConfig, + order: trendlineOrder.asNum(), + }; + } + } + if (trendlineConfig.type === "movingAvg") { + const trendlineWindow = this.extractChildAttr(trendlineElement, "c:period", "val"); + if (trendlineWindow) { + trendlineConfig = { + ...trendlineConfig, + window: trendlineWindow.asNum(), + }; + } + } + return trendlineConfig; + } + private extractScatterChartDatasets(chartElement: Element): ExcelChartDataset[] { return this.mapOnElements( { parent: chartElement, query: "c:ser" }, diff --git a/src/xlsx/functions/charts.ts b/src/xlsx/functions/charts.ts index efdd7ada9f..b5050612f2 100644 --- a/src/xlsx/functions/charts.ts +++ b/src/xlsx/functions/charts.ts @@ -211,6 +211,63 @@ function insertTextProperties( `; } +function extractTrendline(trend: ExcelChartDataset["trend"]) { + if (!trend) { + return ""; + } + if (trend.type === "poly" && trend.order) { + if (trend.order > 1) { + return escapeXml/*xml*/ ` + + ${extractTrendlineCommonAttributes(trend)} + + + + `; + } + return escapeXml/*xml*/ ` + + ${extractTrendlineCommonAttributes(trend)} + + + `; + } + if (trend.type === "movingAvg") { + return escapeXml/*xml*/ ` + + ${extractTrendlineCommonAttributes(trend)} + + + + `; + } + return escapeXml/*xml*/ ` + + ${extractTrendlineCommonAttributes(trend)} + + + `; +} + +function extractTrendlineCommonAttributes(trend: ExcelChartDataset["trend"]) { + if (!trend) { + return ""; + } + return escapeXml/*xml*/ ` + + + + + + + + + + + + `; +} + function extractDataSetLabel(label: ExcelChartDataset["label"]): XMLString { if (!label) { return escapeXml/*xml*/ ``; @@ -252,6 +309,7 @@ function addBarChart(chart: ExcelChartDefinition): XMLString { + ${extractTrendline(dataset.trend)} ${extractDataSetLabel(dataset.label)} ${dataShapeProperty} ${ @@ -344,6 +402,7 @@ function addComboChart(chart: ExcelChartDefinition): XMLString { + ${extractTrendline(dataSet.trend)} ${extractDataSetLabel(dataSet.label)} ${shapeProperty({ backgroundColor: firstColor, @@ -500,6 +559,7 @@ function addLineChart(chart: ExcelChartDefinition): XMLString { ${shapeProperty({ backgroundColor: color, line: { color } })} + ${extractTrendline(dataset.trend)} ${extractDataSetLabel(dataset.label)} ${dataShapeProperty} ${ @@ -594,6 +654,7 @@ function addScatterChart(chart: ExcelChartDefinition): XMLString { ${shapeProperty({ backgroundColor: color, line: { color } })} + ${extractTrendline(dataset.trend)} ${extractDataSetLabel(dataset.label)} ${ chart.labelRange diff --git a/tests/__xlsx__/xlsx_demo_data.xlsx b/tests/__xlsx__/xlsx_demo_data.xlsx index 86ab7c1483..19c0c0d0cd 100644 Binary files a/tests/__xlsx__/xlsx_demo_data.xlsx and b/tests/__xlsx__/xlsx_demo_data.xlsx differ diff --git a/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap b/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap index 924364d40b..c5f270bac1 100644 --- a/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap +++ b/tests/xlsx/__snapshots__/xlsx_export.test.ts.snap @@ -33553,6 +33553,21 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + + @@ -33604,6 +33619,20 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + @@ -33848,6 +33877,20 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + @@ -33886,6 +33929,20 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + @@ -35881,6 +35938,21 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + + @@ -35932,6 +36004,21 @@ exports[`Test XLSX export references with headers should be converted to referen + + + + + + + + + + + + + + + diff --git a/tests/xlsx/xlsx_export.test.ts b/tests/xlsx/xlsx_export.test.ts index 9f9a5bac03..e2c0b253b3 100644 --- a/tests/xlsx/xlsx_export.test.ts +++ b/tests/xlsx/xlsx_export.test.ts @@ -783,7 +783,10 @@ describe("Test XLSX export", () => { createChart( model, { - dataSets: [{ dataRange: "Sheet1!B2:B" }, { dataRange: "Sheet1!C4:4" }], + dataSets: [ + { dataRange: "Sheet1!B2:B", trend: { type: "polynomial", order: 2, display: true } }, + { dataRange: "Sheet1!C4:4", trend: { type: "polynomial", order: 1, display: true } }, + ], labelRange: "Sheet1!A2:A", type: "line", }, @@ -792,7 +795,10 @@ describe("Test XLSX export", () => { createChart( model, { - dataSets: [{ dataRange: "Sheet1!B2:B" }, { dataRange: "Sheet1!C4:4" }], + dataSets: [ + { dataRange: "Sheet1!B2:B", trend: { type: "exponential", display: true } }, + { dataRange: "Sheet1!C4:4", trend: { type: "logarithmic", display: true } }, + ], labelRange: "Sheet1!A2:A", type: "bar", }, @@ -811,7 +817,16 @@ describe("Test XLSX export", () => { createChart( model, { - dataSets: [{ dataRange: "Sheet1!B2:B" }, { dataRange: "Sheet1!C4:4" }], + dataSets: [ + { + dataRange: "Sheet1!B2:B", + trend: { type: "trailingMovingAverage", window: 3, display: true }, + }, + { + dataRange: "Sheet1!C4:4", + trend: { type: "polynomial", order: 7, display: true }, + }, + ], labelRange: "Sheet1!A2:A", type: "scatter", }, diff --git a/tests/xlsx/xlsx_import.test.ts b/tests/xlsx/xlsx_import.test.ts index 5541c54767..b8751563e9 100644 --- a/tests/xlsx/xlsx_import.test.ts +++ b/tests/xlsx/xlsx_import.test.ts @@ -721,14 +721,22 @@ describe("Import xlsx data", () => { [ "line", [ - { dataRange: "Sheet1!B26:B35", backgroundColor: "#7030A0" }, + { + dataRange: "Sheet1!B26:B35", + backgroundColor: "#7030A0", + trend: { type: "polynomial", order: 2, display: true }, + }, { dataRange: "Sheet1!C26:C35", backgroundColor: "#C65911" }, ], ], [ "bar", [ - { dataRange: "Sheet1!B27:B35", backgroundColor: "#7030A0" }, + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#7030A0", + trend: { type: "exponential", display: true }, + }, { dataRange: "Sheet1!C27:C35", backgroundColor: "#C65911" }, ], ], @@ -739,13 +747,69 @@ describe("Import xlsx data", () => { expect(chartData.dataSets).toEqual(chartDatasets); }); + test.each([ + [ + "line", + [ + { + dataRange: "Sheet1!B26:B35", + backgroundColor: "#7030A0", + trend: { type: "polynomial", order: 2, display: true }, + }, + { dataRange: "Sheet1!C26:C35", backgroundColor: "#C65911" }, + ], + ], + [ + "bar", + [ + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#7030A0", + trend: { type: "exponential", display: true }, + }, + { dataRange: "Sheet1!C27:C35", backgroundColor: "#C65911" }, + ], + ], + [ + "bar", + [ + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#7030A0", + trend: { type: "exponential", display: true }, + }, + { dataRange: "Sheet1!C27:C35", backgroundColor: "#C65911" }, + ], + ], + [ + "combo", + [ + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#1F77B4", + trend: { type: "trailingMovingAverage", window: 3, display: true }, + }, + { dataRange: "Sheet1!C27:C35", backgroundColor: "#FF7F0E" }, + ], + ], + ])("Can import charts %s with dataset trendlines", (chartType, chartDatasets) => { + const testSheet = getWorkbookSheet("jestCharts", convertedData)!; + const figure = testSheet.figures.find((figure) => figure.data.type === chartType); + const chartData = figure!.data as LineChartDefinition | BarChartDefinition; + expect(chartData.dataSets).toEqual(chartDatasets); + }); + test.each([ [ "bar chart", "bar", "#fff", [ - { dataRange: "Sheet1!B27:B35", backgroundColor: "#7030A0" }, + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#7030A0", + trend: { type: "exponential", display: true }, + }, { dataRange: "Sheet1!C27:C35", backgroundColor: "#C65911" }, ], ], @@ -754,7 +818,11 @@ describe("Import xlsx data", () => { "combo", "#fff", [ - { dataRange: "Sheet1!B27:B35", backgroundColor: "#1F77B4" }, + { + dataRange: "Sheet1!B27:B35", + backgroundColor: "#1F77B4", + trend: { type: "trailingMovingAverage", window: 3, display: true }, + }, { dataRange: "Sheet1!C27:C35", backgroundColor: "#FF7F0E" }, ], ], @@ -789,7 +857,11 @@ describe("Import xlsx data", () => { "line", "#CECECE", [ - { dataRange: "Sheet1!B26:B35", backgroundColor: "#7030A0" }, + { + dataRange: "Sheet1!B26:B35", + backgroundColor: "#7030A0", + trend: { type: "polynomial", order: 2, display: true }, + }, { dataRange: "Sheet1!C26:C35", backgroundColor: "#C65911" }, ], ], diff --git a/tests/xlsx/xlsx_import_export.test.ts b/tests/xlsx/xlsx_import_export.test.ts index a5c3b280b8..841c49500f 100644 --- a/tests/xlsx/xlsx_import_export.test.ts +++ b/tests/xlsx/xlsx_import_export.test.ts @@ -274,6 +274,24 @@ describe("Export data to xlsx then import it", () => { }, "1" ); + createChart( + model, + { + dataSets: [{ dataRange: "Sheet1!B2:B4" }, { dataRange: "Sheet1!C4:4" }], + labelRange: "Sheet1!B2:B4", + type: "bar", + }, + "2" + ); + createChart( + model, + { + dataSets: [{ dataRange: "Sheet1!C2:C4" }], + labelRange: "Sheet1!C2:C4", + type: "scatter", + }, + "4" + ); const figure = model.getters.getFigures(sheetId)[0]; const importedModel = exportToXlsxThenImport(model); const importedFigure = importedModel.getters.getFigures(sheetId)[0]; @@ -287,7 +305,10 @@ describe("Export data to xlsx then import it", () => { test.each([ { title: { text: "demo chart" }, - dataSets: [{ dataRange: "Sheet1!B26:B35" }, { dataRange: "Sheet1!C26:C35" }], + dataSets: [ + { dataRange: "Sheet1!B26:B35", trend: { type: "polynomial", order: 2, display: true } }, + { dataRange: "Sheet1!C26:C35", trend: { type: "polynomial", order: 1, display: true } }, + ], labelRange: "Sheet1!A27:A35", type: "line" as const, dataSetsHaveTitle: false, @@ -298,7 +319,10 @@ describe("Export data to xlsx then import it", () => { }, { title: { text: "demo chart 2" }, - dataSets: [{ dataRange: "Sheet1!B27:B35" }, { dataRange: "Sheet1!C27:C35" }], + dataSets: [ + { dataRange: "Sheet1!B27:B35", trend: { type: "exponential", display: true } }, + { dataRange: "Sheet1!C27:C35", trend: { type: "logarithmic", display: true } }, + ], labelRange: "Sheet1!A27:A35", type: "bar" as const, dataSetsHaveTitle: false, @@ -329,7 +353,13 @@ describe("Export data to xlsx then import it", () => { }, { title: { text: "demo chart 5" }, - dataSets: [{ dataRange: "Sheet1!B27:B35" }, { dataRange: "Sheet1!C27:C35" }], + dataSets: [ + { + dataRange: "Sheet1!B27:B35", + trend: { type: "trailingMovingAverage", window: 3, display: true }, + }, + { dataRange: "Sheet1!C27:C35" }, + ], labelRange: "Sheet1!A27:A35", type: "combo" as const, dataSetsHaveTitle: false,