From 422a53ed567bbe77b3df6c8d1b6c187ab2b0d585 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Tue, 21 Oct 2025 20:34:22 -0400 Subject: [PATCH 01/17] feat: add Excel Charts --- docs/TOC.md | 5 +- docs/inserting-charts.md | 171 +++++++ packages/demo/src/app-routing.ts | 2 + packages/demo/src/examples/example18.html | 63 +++ packages/demo/src/examples/example18.ts | 94 ++++ packages/demo/src/main.ts | 1 - .../dist/index.d.ts | 343 +++++++------ .../src/Excel/Drawing/Chart.ts | 338 ++++++++++++- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 466 +++++++++++++++++- .../src/Excel/Drawings.ts | 25 +- .../src/Excel/Workbook.ts | 23 + .../src/Excel/__tests__/Drawings.spec.ts | 18 + .../src/Excel/__tests__/Workbook.spec.ts | 49 +- .../excel-builder-vanilla/src/interfaces.ts | 42 ++ .../src/utilities/__tests__/escape.spec.ts | 40 ++ .../src/utilities/__tests__/isTypeOf.spec.ts | 7 + 16 files changed, 1532 insertions(+), 155 deletions(-) create mode 100644 docs/inserting-charts.md create mode 100644 packages/demo/src/examples/example18.html create mode 100644 packages/demo/src/examples/example18.ts create mode 100644 packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts diff --git a/docs/TOC.md b/docs/TOC.md index 0ae2b53..bcfedda 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -17,6 +17,7 @@ - [Tables](tables.md) - [Theming Tables](theming-tables.md) - [Tables Summaries](tables-summaries.md) -- [Adding Headers and Footers to a Worksheet](worksheet-headers-footers.md) -- [Inserting images into spreadsheets](inserting-pictures.md) +- [Adding Headers/Footers to a Worksheet](worksheet-headers-footers.md) +- [Inserting Images](inserting-pictures.md) +- [Inserting Charts](inserting-charts.md) - [Streaming Export API](streaming.md) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md new file mode 100644 index 0000000..7fe0294 --- /dev/null +++ b/docs/inserting-charts.md @@ -0,0 +1,171 @@ +## Inserting charts + +Add charts to a workbook: create data, create a chart, add it, position it. That's all—just practical usage. + +### Supported types +`bar` (clustered column), `line`, `pie`, `scatter` + +### Core steps +1. Create a workbook & worksheet +2. Add data rows +3. Create a chart (ranges or fallback arrays) +4. Call `wb.addChart(chart)` +5. Anchor it (e.g. `twoCellAnchor`) +6. Generate files + +### Option summary (ChartOptions) +| Option | Purpose | Notes | +|--------|---------|-------| +| type | `bar` | `line` | `pie` | `scatter` | Defaults to `bar` | +| title | Chart title | Omit for none | +| xAxisTitle | X axis label | Ignored for pie | +| yAxisTitle | Y axis label | Ignored for pie | +| width / height | Size override | Defaults used if omitted | +| categoriesRange | Category labels range | Skip for scatter when using `xValuesRange` | +| series | Array of `{ name, valuesRange }` | 2+ series => legend | +| series[].xValuesRange | Scatter X values range | Only for scatter | +| sheetName + categories + values | Fallback single series | Arrays instead of ranges | + + +### Quick start (multi‑series bar chart) +```ts +const wb = createWorkbook(); +const ws = wb.createWorksheet({ name: 'Sales' }); +wb.addWorksheet(ws); + +ws.addRow(['Month', 'Q1', 'Q2']); +ws.addRow(['Jan', 10, 15]); +ws.addRow(['Feb', 20, 25]); +ws.addRow(['Mar', 30, 35]); + +const chart = new Chart({ + type: 'bar', + title: 'Quarterly Sales', + xAxisTitle: 'Month', + yAxisTitle: 'Revenue', + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, + ], + categoriesRange: 'Sales!$A$2:$A$4', +}); +wb.addChart(chart); + +chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } }); +ws.addDrawings(drawings.addDrawing(chart)); // or add drawings first then the chart + +await wb.generateFiles(); +``` + +### Line chart (with axis titles) +```ts +const lineChart = new Chart({ + type: 'line', + title: 'Revenue Trend', + xAxisTitle: 'Month', + yAxisTitle: 'Total', + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(lineChart); +``` + +### Pie chart +```ts +const pie = new Chart({ + type: 'pie', + title: 'Share by Region', + series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], + categoriesRange: 'Regions!$A$2:$A$6', +}); +wb.addChart(pie); +``` + +### Scatter chart +Provide both X and Y value ranges (numeric): (less common, placed last) +```ts +const scatter = new Chart({ + type: 'scatter', + title: 'Distance vs Speed', + xAxisTitle: 'Distance', + yAxisTitle: 'Speed', + series: [{ + name: 'Run A', + xValuesRange: 'Runs!$A$2:$A$11', + valuesRange: 'Runs!$B$2:$B$11', + }], +}); +wb.addChart(scatter); +``` + +### Fallback single series (arrays) +```ts +const autoChart = new Chart({ + sheetName: 'AutoData', + categories: ['Jan', 'Feb', 'Mar'], + values: [10, 20, 30], + title: 'Auto Series', +}); +wb.addChart(autoChart); // legend omitted (only one series) +``` + +## Resizing (width & height) +```ts +new Chart({ + title: 'Wide Chart', + width: 6_000_000, + height: 2_000_000, + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }], + categoriesRange: 'Sales!$A$2:$A$4', +}); +``` + +## Positioning +Use a two-cell anchor: +```ts +chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } }); +``` +Values are column/row indices (0-based). + +### Legend +The legend only appears when the chart has two or more series. + +- 1 series: legend is omitted automatically. +- 2+ series: legend lists each `series.name`. + +Notes: +- Pie: if you add multiple series you get multiple pies; the legend shows the series names. +- Fallback (arrays) path creates only one series, so no legend. + +Example (legend will show 2 entries): +```ts +new Chart({ + type: 'bar', + title: 'Year Comparison', + series: [ + { name: '2024', valuesRange: 'Sales!$B$2:$B$5' }, + { name: '2025', valuesRange: 'Sales!$C$2:$C$5' }, + ], + categoriesRange: 'Sales!$A$2:$A$5', +}); +``` + +### Troubleshooting +| Problem | Cause | Fix | +|---------|-------|-----| +| Missing chart | Not added to workbook | Call `wb.addChart(chart)` | +| No legend | Only one series | Add a second series | +| Axis titles missing | Using pie chart | Pie charts have no axes | +| Wrong data | Typo in range string | Check sheet name & `$A$1` format | + +### Minimal example +```ts +const simple = new Chart({ + type: 'bar', + series: [{ name: 'Sales', valuesRange: 'Sales!$B$2:$B$4' }], + categoriesRange: 'Sales!$A$2:$A$4', +}); +wb.addChart(simple); +``` + +That's it — build your workbook and open in Excel. diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index be95935..4e41eab 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -15,6 +15,7 @@ import Example14 from './examples/example14.js'; import Example15 from './examples/example15.js'; import Example16 from './examples/example16.js'; import Example17 from './examples/example17.js'; +import Example18 from './examples/example18.js'; import GettingStarted from './getting-started.js'; export const navbarRouting = [ @@ -48,6 +49,7 @@ export const exampleRouting = [ { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' }, { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: '16- Streaming Features Demo' }, { name: 'example17', view: '/src/examples/example17.html', viewModel: Example17, title: '17- Streaming Export with Images' }, + { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Chart Demo' }, ], }, ]; diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html new file mode 100644 index 0000000..41ea71c --- /dev/null +++ b/packages/demo/src/examples/example18.html @@ -0,0 +1,63 @@ +
+
+
+

+ Example 18: Create Chart + + Code + + html + | + ts + + +

+
Create a simple bar chart and export as Excel file.
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
MonthQ1Q2
Jan120180
Feb150160
Mar170200
+
+
+
+
diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts new file mode 100644 index 0000000..f98c15b --- /dev/null +++ b/packages/demo/src/examples/example18.ts @@ -0,0 +1,94 @@ +import { downloadExcelFile, Workbook, Chart, Drawings } from 'excel-builder-vanilla'; + +export default class Example18 { + exportBtnElm!: HTMLButtonElement; + + mount() { + this.exportBtnElm = document.querySelector('#export-chart') as HTMLButtonElement; + this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + } + + unmount() { + this.exportBtnElm.removeEventListener('click', this.startProcess.bind(this)); + } + + async startProcess() { + // Base data (will be duplicated into each chart sheet) + const months = ['Jan', 'Feb', 'Mar']; + const q1 = [120, 150, 170]; + const q2 = [180, 160, 200]; + + const wb = new Workbook(); + + // Helper: create a sheet that includes its own data table & a chart of given type + const createChartSheetWithLocalData = (type: 'bar' | 'line' | 'pie' | 'scatter', sheetName: string) => { + const ws = wb.createWorksheet({ name: sheetName }); + let categoriesRange: string | undefined; + let seriesDefs: { name: string; valuesRange: string; xValuesRange?: string }[] = []; + + if (type === 'scatter') { + // Provide a richer numeric dataset for scatter (X,Y pairs) with 8 points + const xVals = [10, 20, 30, 40, 55, 65, 80, 95]; + const yVals = [12, 18, 34, 33, 50, 58, 72, 90]; + ws.setData([['X', 'Y'], ...xVals.map((x, i) => [x, yVals[i]])]); + wb.addWorksheet(ws); + const xRange = `${sheetName}!$A$2:$A$${xVals.length + 1}`; + const yRange = `${sheetName}!$B$2:$B$${yVals.length + 1}`; + seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, xValuesRange: xRange }]; + } else { + // Use month/Q1/Q2 table for non-scatter charts + ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${sheetName}!$A$2:$A$${months.length + 1}`; + const q1Range = `${sheetName}!$B$2:$B$${months.length + 1}`; + const q2Range = `${sheetName}!$C$2:$C$${months.length + 1}`; + switch (type) { + case 'pie': + seriesDefs = [ + { name: 'Q1', valuesRange: q1Range }, + { name: 'Q2', valuesRange: q2Range }, + ]; + break; + default: + seriesDefs = [ + { name: 'Q1', valuesRange: q1Range }, + { name: 'Q2', valuesRange: q2Range }, + ]; + break; + } + } + + const drawings = new Drawings(); + const chart = new Chart({ + type, + title: `${sheetName} (${type}) Chart`, + xAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', + yAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : 'Values', + width: 640 * 9525, + height: 400 * 9525, + sheetName, + categoriesRange, + series: seriesDefs, + }); + + const anchor = chart.createAnchor('twoCellAnchor', { + from: { x: 4, y: 6 }, // start at internal column index 4 (visually appears around Excel column E due to default widths) + to: { x: 15, y: 30 }, // end column chosen to preserve approximate chart width + }); + chart.anchor = anchor; + drawings.addDrawing(chart); + ws.addDrawings(drawings); + wb.addDrawings(drawings); + wb.addChart(chart); + }; + + // Create one sheet per chart type with its own data + createChartSheetWithLocalData('bar', 'Bar'); + createChartSheetWithLocalData('line', 'Line'); + createChartSheetWithLocalData('pie', 'Pie'); + createChartSheetWithLocalData('scatter', 'Scatter'); + + // Export workbook (chart will be included if supported) + downloadExcelFile(wb, 'Multiple-Charts.xlsx'); + } +} diff --git a/packages/demo/src/main.ts b/packages/demo/src/main.ts index 0c44172..0f9d4fd 100644 --- a/packages/demo/src/main.ts +++ b/packages/demo/src/main.ts @@ -127,7 +127,6 @@ class Main { } if (foundRouter?.view) { this.currentRouter = foundRouter; - // const html = await import(/*@vite-ignore*/ `${foundRouter.view}?raw`).default; document.querySelector('.panel-wm-content')!.innerHTML = pageLayoutGlobs[foundRouter.view] as string; const vm = new foundRouter.viewModel() as ViewModel; this.currentModel = vm; diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 381365b..3344fff 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -149,7 +149,178 @@ export declare class AbsoluteAnchor { setDimensions(width: number, height: number): void; toXML(xmlDoc: XMLDOM, content: any): XMLNode; } -export declare class Chart { +/** + * Excel Color in ARGB format, for color aren't transparent just use "FF" as prefix. + * For example if the color you want to add is a blue with HTML color "#0000FF", then the excel color we need to add is "FF0000FF" + * Online tool: https://www.myfixguide.com/color-converter/ + */ +export type ExcelColorStyle = string | { + theme: number; +}; +export interface ExcelAlignmentStyle { + horizontal?: "center" | "fill" | "general" | "justify" | "left" | "right"; + justifyLastLine?: boolean; + readingOrder?: string; + relativeIndent?: boolean; + shrinkToFit?: boolean; + textRotation?: string | number; + vertical?: "bottom" | "distributed" | "center" | "justify" | "top"; + wrapText?: boolean; +} +export type ExcelBorderLineStyle = "continuous" | "dash" | "dashDot" | "dashDotDot" | "dotted" | "double" | "lineStyleNone" | "medium" | "slantDashDot" | "thin" | "thick"; +export interface ExcelBorderStyle { + bottom?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + top?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + left?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + right?: { + color?: ExcelColorStyle; + style?: ExcelBorderLineStyle; + }; + diagonal?: any; + outline?: boolean; + diagonalUp?: boolean; + diagonalDown?: boolean; +} +export interface ExcelColumn { + bestFit?: boolean; + collapsed?: boolean; + customWidth?: number; + hidden?: boolean; + max?: number; + min?: number; + outlineLevel?: number; + phonetic?: boolean; + style?: number; + width?: number; +} +export interface ExcelTableColumn { + name: string; + dataCellStyle?: any; + dataDxfId?: number; + headerRowCellStyle?: ExcelStyleInstruction; + headerRowDxfId?: number; + totalsRowCellStyle?: ExcelStyleInstruction; + totalsRowDxfId?: number; + totalsRowFunction?: any; + totalsRowLabel?: string; + columnFormula?: string; + columnFormulaIsArrayType?: boolean; + totalFormula?: string; + totalFormulaIsArrayType?: boolean; +} +export interface ExcelFillStyle { + type?: "gradient" | "pattern"; + patternType?: string; + degree?: number; + fgColor?: ExcelColorStyle; + start?: ExcelColorStyle; + end?: { + pureAt?: number; + color?: ExcelColorStyle; + }; +} +export interface ExcelFontStyle { + bold?: boolean; + color?: ExcelColorStyle; + fontName?: string; + italic?: boolean; + outline?: boolean; + size?: number; + shadow?: boolean; + strike?: boolean; + subscript?: boolean; + superscript?: boolean; + underline?: boolean | "single" | "double" | "singleAccounting" | "doubleAccounting"; +} +export interface ExcelMetadata { + type?: string; + style?: number; +} +export interface ExcelColumnMetadata { + value: any; + metadata?: ExcelMetadata; +} +export interface ExcelMargin { + top: number; + bottom: number; + left: number; + right: number; + header: number; + footer: number; +} +export interface ExcelSortState { + caseSensitive?: boolean; + dataRange?: any; + columnSort?: boolean; + sortDirection?: "ascending" | "descending"; + sortRange?: any; +} +/** Excel custom formatting that will be applied to a column */ +export interface ExcelStyleInstruction { + id?: number; + alignment?: ExcelAlignmentStyle; + border?: ExcelBorderStyle | number; + borderId?: number; + fill?: ExcelFillStyle | number; + fillId?: number; + font?: ExcelFontStyle | number; + fontId?: number; + format?: string | number; + height?: number; + numFmt?: string; + numFmtId?: number; + width?: number; + xfId?: number; + protection?: { + locked?: boolean; + hidden?: boolean; + }; + /** style id */ + style?: number; +} +export type ChartType = "bar" | "line" | "pie" | "scatter"; +export interface ChartSeriesRef { + /** Series display name */ + name: string; + /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ + valuesRange: string; + /** Hex ARGB or RGB color (e.g. FF0000 or FF0000FF) - currently cosmetic placeholder */ + color?: string; + /** For scatter charts: X axis values range */ + xValuesRange?: string; +} +export interface ChartOptions { + /** Chart type (bar default if omitted for backward compatibility) */ + type?: ChartType; + /** Chart title shown above plot area */ + title?: string; + /** Category axis title (ignored for pie) */ + xAxisTitle?: string; + /** Value axis title (ignored for pie) */ + yAxisTitle?: string; + /** Width in EMUs */ + width?: number; + /** Height in EMUs */ + height?: number; + /** Worksheet name containing referenced ranges */ + sheetName?: string; + /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ + categoriesRange?: string; + /** Multi-series cell references */ + series?: ChartSeriesRef[]; + /** Legacy single-series fallback: categories literal */ + categories?: string[]; + /** Legacy single-series fallback: values literal */ + values?: number[]; } /** * @module Excel/Util @@ -220,6 +391,34 @@ export declare class Util { hyperlink: string; }; } +/** + * Minimal Chart implementation (clustered column) required for Excel to render without repair. + * This produces 2 parts: + * 1) Drawing graphicFrame (returned by toXML for inclusion in /xl/drawings/drawingN.xml) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in /xl/charts/chartN.xml) + * Relationships: + * drawingN.xml.rels -> ../charts/chartN.xml (Type chart) + */ +export declare class Chart extends Drawing { + relId: string | null; + index: number | null; + target: string | null; + options: ChartOptions; + constructor(options: ChartOptions); + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string): void; + /** Return relationship type for this drawing */ + getMediaType(): keyof typeof Util.schemas; + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame; + /** Drawing part representation (inside an anchor) */ + toXML(xmlDoc: XMLDOM): XMLNode; + private _nextAxisIdBase; + /** Chart part XML: /xl/charts/chartN.xml */ + toChartSpaceXML(): XMLDOM; + /** Create a c:title node with minimal rich text required for Excel to render */ + private _createTitleNode; +} export type Relation = { [id: string]: { id: string; @@ -259,7 +458,7 @@ export declare class RelationshipManager { * @module Excel/Drawings */ export declare class Drawings { - drawings: (Drawing | Picture)[]; + drawings: Drawing[]; relations: RelationshipManager; id: string; /** @@ -294,144 +493,6 @@ export declare class SharedStrings { }; toXML(): XMLDOM; } -/** - * Excel Color in ARGB format, for color aren't transparent just use "FF" as prefix. - * For example if the color you want to add is a blue with HTML color "#0000FF", then the excel color we need to add is "FF0000FF" - * Online tool: https://www.myfixguide.com/color-converter/ - */ -export type ExcelColorStyle = string | { - theme: number; -}; -export interface ExcelAlignmentStyle { - horizontal?: "center" | "fill" | "general" | "justify" | "left" | "right"; - justifyLastLine?: boolean; - readingOrder?: string; - relativeIndent?: boolean; - shrinkToFit?: boolean; - textRotation?: string | number; - vertical?: "bottom" | "distributed" | "center" | "justify" | "top"; - wrapText?: boolean; -} -export type ExcelBorderLineStyle = "continuous" | "dash" | "dashDot" | "dashDotDot" | "dotted" | "double" | "lineStyleNone" | "medium" | "slantDashDot" | "thin" | "thick"; -export interface ExcelBorderStyle { - bottom?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - top?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - left?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - right?: { - color?: ExcelColorStyle; - style?: ExcelBorderLineStyle; - }; - diagonal?: any; - outline?: boolean; - diagonalUp?: boolean; - diagonalDown?: boolean; -} -export interface ExcelColumn { - bestFit?: boolean; - collapsed?: boolean; - customWidth?: number; - hidden?: boolean; - max?: number; - min?: number; - outlineLevel?: number; - phonetic?: boolean; - style?: number; - width?: number; -} -export interface ExcelTableColumn { - name: string; - dataCellStyle?: any; - dataDxfId?: number; - headerRowCellStyle?: ExcelStyleInstruction; - headerRowDxfId?: number; - totalsRowCellStyle?: ExcelStyleInstruction; - totalsRowDxfId?: number; - totalsRowFunction?: any; - totalsRowLabel?: string; - columnFormula?: string; - columnFormulaIsArrayType?: boolean; - totalFormula?: string; - totalFormulaIsArrayType?: boolean; -} -export interface ExcelFillStyle { - type?: "gradient" | "pattern"; - patternType?: string; - degree?: number; - fgColor?: ExcelColorStyle; - start?: ExcelColorStyle; - end?: { - pureAt?: number; - color?: ExcelColorStyle; - }; -} -export interface ExcelFontStyle { - bold?: boolean; - color?: ExcelColorStyle; - fontName?: string; - italic?: boolean; - outline?: boolean; - size?: number; - shadow?: boolean; - strike?: boolean; - subscript?: boolean; - superscript?: boolean; - underline?: boolean | "single" | "double" | "singleAccounting" | "doubleAccounting"; -} -export interface ExcelMetadata { - type?: string; - style?: number; -} -export interface ExcelColumnMetadata { - value: any; - metadata?: ExcelMetadata; -} -export interface ExcelMargin { - top: number; - bottom: number; - left: number; - right: number; - header: number; - footer: number; -} -export interface ExcelSortState { - caseSensitive?: boolean; - dataRange?: any; - columnSort?: boolean; - sortDirection?: "ascending" | "descending"; - sortRange?: any; -} -/** Excel custom formatting that will be applied to a column */ -export interface ExcelStyleInstruction { - id?: number; - alignment?: ExcelAlignmentStyle; - border?: ExcelBorderStyle | number; - borderId?: number; - fill?: ExcelFillStyle | number; - fillId?: number; - font?: ExcelFontStyle | number; - fontId?: number; - format?: string | number; - height?: number; - numFmt?: string; - numFmtId?: number; - width?: number; - xfId?: number; - protection?: { - locked?: boolean; - hidden?: boolean; - }; - /** style id */ - style?: number; -} /** * @module Excel/StyleSheet */ @@ -949,6 +1010,7 @@ export declare class Workbook { sharedStrings: SharedStrings; relations: RelationshipManager; worksheets: Worksheet[]; + charts: Chart[]; tables: Table[]; drawings: Drawings[]; media: { @@ -961,6 +1023,7 @@ export declare class Workbook { getStyleSheet(): StyleSheet$1; addTable(table: Table): void; addDrawings(drawings: Drawings): void; + addChart(chart: Chart): void; /** * Set number of rows to repeat for this sheet. * diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 1ecbb4a..1beaa37 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -1 +1,337 @@ -export class Chart {} +import type { ChartOptions } from '../../interfaces.js'; +import { Util } from '../Util.js'; +import type { XMLDOM, XMLNode } from '../XMLDOM.js'; +import { Drawing } from './Drawing.js'; + +/** + * Minimal Chart implementation (clustered column) required for Excel to render without repair. + * This produces 2 parts: + * 1) Drawing graphicFrame (returned by toXML for inclusion in /xl/drawings/drawingN.xml) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in /xl/charts/chartN.xml) + * Relationships: + * drawingN.xml.rels -> ../charts/chartN.xml (Type chart) + */ +export class Chart extends Drawing { + relId: string | null = null; // relationship id from drawing rels + index: number | null = null; // 1-based index assigned by workbook + target: string | null = null; // relative target path (../charts/chartN.xml) + options: ChartOptions; + + constructor(options: ChartOptions) { + super(); + this.options = options; + } + + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string) { + this.relId = rId; + } + + /** Return relationship type for this drawing */ + getMediaType(): keyof typeof Util.schemas { + return 'chart'; + } + + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame(xmlDoc: XMLDOM) { + const graphicFrame = Util.createElement(xmlDoc, 'xdr:graphicFrame'); + + const nvGraphicFramePr = Util.createElement(xmlDoc, 'xdr:nvGraphicFramePr'); + nvGraphicFramePr.appendChild( + Util.createElement(xmlDoc, 'xdr:cNvPr', [ + ['id', String(this.index || 1)], + ['name', this.options.title || 'Chart'], + ]), + ); + nvGraphicFramePr.appendChild(Util.createElement(xmlDoc, 'xdr:cNvGraphicFramePr')); + graphicFrame.appendChild(nvGraphicFramePr); + + // basic transform (off + ext) – values are arbitrary but required structure + const xfrm = Util.createElement(xmlDoc, 'xdr:xfrm'); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:off', [ + ['x', '0'], + ['y', '0'], + ]), + ); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:ext', [ + ['cx', String(this.options.width || 4000000)], + ['cy', String(this.options.height || 3000000)], + ]), + ); + graphicFrame.appendChild(xfrm); + + const graphic = Util.createElement(xmlDoc, 'a:graphic'); + const graphicData = Util.createElement(xmlDoc, 'a:graphicData', [['uri', 'http://schemas.openxmlformats.org/drawingml/2006/chart']]); + graphicData.appendChild( + Util.createElement(xmlDoc, 'c:chart', [ + ['xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'], + ['xmlns:r', Util.schemas.relationships], + ['r:id', this.relId || ''], + ]), + ); + graphic.appendChild(graphicData); + graphicFrame.appendChild(graphic); + + return graphicFrame; + } + + /** Drawing part representation (inside an anchor) */ + toXML(xmlDoc: XMLDOM) { + return this.anchor.toXML(xmlDoc, this.createGraphicFrame(xmlDoc)); + } + + private _nextAxisIdBase(): number { + // Simple axis id base using index plus a constant offset + return (this.index || 1) * 1000; + } + + /** Chart part XML: /xl/charts/chartN.xml */ + toChartSpaceXML(): XMLDOM { + const doc = Util.createXmlDoc('http://schemas.openxmlformats.org/drawingml/2006/chart', 'c:chartSpace'); + const chartSpace = doc.documentElement; + chartSpace.setAttribute('xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'); + chartSpace.setAttribute('xmlns:a', Util.schemas.drawing); + chartSpace.setAttribute('xmlns:r', Util.schemas.relationships); + + const chart = Util.createElement(doc, 'c:chart'); + // Title (only if provided). autoTitleDeleted must be 0 or omitted when we set a title. + if (this.options.title) { + chart.appendChild(this._createTitleNode(doc, this.options.title)); + chart.appendChild(Util.createElement(doc, 'c:autoTitleDeleted', [['val', '0']])); + } else { + chart.appendChild(Util.createElement(doc, 'c:autoTitleDeleted', [['val', '1']])); + } + + const plotArea = Util.createElement(doc, 'c:plotArea'); + const axisBase = this._nextAxisIdBase(); + const axIdCat = axisBase + 1; + const axIdVal = axisBase + 2; + + const type = this.options.type || 'bar'; + let primaryChartNode: XMLNode; + switch (type) { + case 'line': + primaryChartNode = Util.createElement(doc, 'c:lineChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'standard']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + case 'pie': + primaryChartNode = Util.createElement(doc, 'c:pieChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); + break; + case 'scatter': + primaryChartNode = Util.createElement(doc, 'c:scatterChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:scatterStyle', [['val', 'marker']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + case 'bar': + default: + primaryChartNode = Util.createElement(doc, 'c:barChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'col']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + + // Build series (multi or single fallback) + const seriesDefs = this.options.series?.length + ? this.options.series + : [ + { + name: this.options.title || 'Series 1', + valuesRange: + this.options.values?.length && this.options.sheetName && this.options.categories?.length + ? `${this.options.sheetName}!$B$2:$B$${this.options.categories.length + 1}` + : '', + }, + ]; + + const categoriesRange = + this.options.categoriesRange || + (this.options.sheetName && this.options.categories?.length + ? `${this.options.sheetName}!$A$2:$A$${this.options.categories.length + 1}` + : ''); + + seriesDefs.forEach((s, idx) => { + const ser = Util.createElement(doc, 'c:ser'); + ser.appendChild(Util.createElement(doc, 'c:idx', [['val', String(idx)]])); + ser.appendChild(Util.createElement(doc, 'c:order', [['val', String(idx)]])); + + // Series title literal + const tx = Util.createElement(doc, 'c:tx'); + const txV = Util.createElement(doc, 'c:v'); + txV.appendChild(doc.createTextNode(s.name)); + tx.appendChild(txV); + ser.appendChild(tx); + + if (type === 'scatter') { + // Scatter uses xVal & yVal + const xVal = Util.createElement(doc, 'c:xVal'); + if (s.xValuesRange) { + const numRefX = Util.createElement(doc, 'c:numRef'); + const fNodeX = Util.createElement(doc, 'c:f'); + fNodeX.appendChild(doc.createTextNode(s.xValuesRange)); + numRefX.appendChild(fNodeX); + xVal.appendChild(numRefX); + } else { + // fallback generate indices + const numLitX = Util.createElement(doc, 'c:numLit'); + const count = this.options.categories?.length || 0; + numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(count)]])); + for (let i = 0; i < count; i++) { + const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); + const vNode = Util.createElement(doc, 'c:v'); + vNode.appendChild(doc.createTextNode(String(i))); + pt.appendChild(vNode); + numLitX.appendChild(pt); + } + xVal.appendChild(numLitX); + } + ser.appendChild(xVal); + const yVal = Util.createElement(doc, 'c:yVal'); + const numRefY = Util.createElement(doc, 'c:numRef'); + const fNodeY = Util.createElement(doc, 'c:f'); + fNodeY.appendChild(doc.createTextNode(s.valuesRange)); + numRefY.appendChild(fNodeY); + yVal.appendChild(numRefY); + ser.appendChild(yVal); + } else { + // Categories (shared across all series) + if (categoriesRange) { + const cat = Util.createElement(doc, 'c:cat'); + const strRef = Util.createElement(doc, 'c:strRef'); + const fNodeCat = Util.createElement(doc, 'c:f'); + fNodeCat.appendChild(doc.createTextNode(categoriesRange)); + strRef.appendChild(fNodeCat); + cat.appendChild(strRef); + ser.appendChild(cat); + } + // Values + if (s.valuesRange) { + const val = Util.createElement(doc, 'c:val'); + const numRef = Util.createElement(doc, 'c:numRef'); + const fNodeVal = Util.createElement(doc, 'c:f'); + fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); + numRef.appendChild(fNodeVal); + val.appendChild(numRef); + ser.appendChild(val); + } + } + + primaryChartNode.appendChild(ser); + }); + + // Axis IDs (except pie which has no axes) + if (type !== 'pie') { + primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); + } + plotArea.appendChild(primaryChartNode); + + if (type !== 'pie') { + if (type === 'scatter') { + // Scatter requires two value axes (X and Y), not a category axis. + const xValAx = Util.createElement(doc, 'c:valAx'); + xValAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); + const xScaling = Util.createElement(doc, 'c:scaling'); + xScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + xValAx.appendChild(xScaling); + xValAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + xValAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); + xValAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdVal)]])); + xValAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + xValAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (this.options.xAxisTitle) { + xValAx.appendChild(this._createTitleNode(doc, this.options.xAxisTitle)); + } + plotArea.appendChild(xValAx); + + const yValAx = Util.createElement(doc, 'c:valAx'); + yValAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); + const yScaling = Util.createElement(doc, 'c:scaling'); + yScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + yValAx.appendChild(yScaling); + yValAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + yValAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'l']])); + yValAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdCat)]])); + yValAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + yValAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (this.options.yAxisTitle) { + yValAx.appendChild(this._createTitleNode(doc, this.options.yAxisTitle)); + } + plotArea.appendChild(yValAx); + } else { + // Non-scatter (bar/line) use category axis + value axis. + const catAx = Util.createElement(doc, 'c:catAx'); + catAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); + const catScaling = Util.createElement(doc, 'c:scaling'); + catScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + catAx.appendChild(catScaling); + catAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + catAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); + catAx.appendChild(Util.createElement(doc, 'c:tickLblPos', [['val', 'nextTo']])); + catAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdVal)]])); + catAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + if (this.options.xAxisTitle) { + catAx.appendChild(this._createTitleNode(doc, this.options.xAxisTitle)); + } + plotArea.appendChild(catAx); + + const valAx = Util.createElement(doc, 'c:valAx'); + valAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); + const valScaling = Util.createElement(doc, 'c:scaling'); + valScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + valAx.appendChild(valScaling); + valAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + valAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'l']])); + valAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdCat)]])); + valAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + valAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (this.options.yAxisTitle) { + valAx.appendChild(this._createTitleNode(doc, this.options.yAxisTitle)); + } + plotArea.appendChild(valAx); + } + } + + // Legend if multiple series + if (seriesDefs.length > 1) { + const legend = Util.createElement(doc, 'c:legend'); + legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', 'r']])); + legend.appendChild(Util.createElement(doc, 'c:layout')); + chart.appendChild(legend); + } + + chart.appendChild(plotArea); + chart.appendChild(Util.createElement(doc, 'c:plotVisOnly', [['val', '1']])); + chartSpace.appendChild(chart); + chartSpace.appendChild(Util.createElement(doc, 'c:printSettings')); + return doc; + } + + /** Create a c:title node with minimal rich text required for Excel to render */ + private _createTitleNode(doc: XMLDOM, text: string): XMLNode { + const title = Util.createElement(doc, 'c:title'); + const tx = Util.createElement(doc, 'c:tx'); + const rich = Util.createElement(doc, 'c:rich'); + rich.appendChild(Util.createElement(doc, 'a:bodyPr')); + rich.appendChild(Util.createElement(doc, 'a:lstStyle')); + const p = Util.createElement(doc, 'a:p'); + const r = Util.createElement(doc, 'a:r'); + const rPr = Util.createElement(doc, 'a:rPr', [['lang', 'en-US']]); + r.appendChild(rPr); + const t = Util.createElement(doc, 'a:t'); + t.appendChild(doc.createTextNode(text)); + r.appendChild(t); + p.appendChild(r); + p.appendChild(Util.createElement(doc, 'a:endParaRPr', [['lang', 'en-US']])); + rich.appendChild(p); + tx.appendChild(rich); + title.appendChild(tx); + title.appendChild(Util.createElement(doc, 'c:layout')); + title.appendChild(Util.createElement(doc, 'c:overlay', [['val', '0']])); + return title; + } +} diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 2d9efa1..40b0875 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -1,10 +1,470 @@ import { describe, expect, it } from 'vitest'; +import { Util } from '../../Util.js'; import { Chart } from '../Chart.js'; +function buildChart(opts: any) { + const chart = new Chart(opts); + // simulate workbook assigning index to make axis ids stable-ish + chart.index = 1; + const xml = chart.toChartSpaceXML().toString(); + return { chart, xml }; +} + describe('Chart', () => { - it('can be instantiated', () => { - const chart = new Chart(); - expect(chart).toBeInstanceOf(Chart); + it('emits barChart node for bar type', () => { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const chart = new Chart({ + type: 'bar', + title: 'Defaults', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 3; + chart.setRelationshipId('rId99'); + chart.createAnchor('twoCellAnchor', { from: { x: 1, y: 1, height: 1, width: 1 }, to: { x: 5, y: 20, height: 1, width: 1 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const drawingNode = chart.toXML(drawingDoc).toString(); + // Attribute order (cx/cy) isn't guaranteed; accept either order. + expect(drawingNode).toMatch(/ { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter Chart', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', xValuesRange: 'Sheet!$A$2:$A$4' }], + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + title: 'Custom Title', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain('Custom Title'); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line', + xAxisTitle: 'Months', + yAxisTitle: 'Values', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Expect two axis title occurrences plus main chart title (3 total c:title nodes) + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(3); + expect(xml).toContain('Months'); + expect(xml).toContain('Values'); + }); + + it('does not include axis titles for pie even if provided', () => { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie', + xAxisTitle: 'ShouldNotShow', + yAxisTitle: 'ShouldNotShow', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Should only have chart-level title + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(1); + expect(xml).not.toContain('ShouldNotShow'); + }); + + it('emits multiple series with correct idx/order', () => { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + // Two c:ser nodes + const serCount = xml.split('').length - 1; + expect(serCount).toBe(2); + expect(xml).toContain(' { + const { xml } = buildChart({ + title: 'Implicit Bar', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Single', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('includes legend when more than one series', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Multi', + series: [ + { name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(''); + }); + + it('generates fallback single series ranges when categories & values provided without series', () => { + const { xml } = buildChart({ sheetName: 'Data', title: 'Fallback', categories: ['A', 'B', 'C'], values: [1, 2, 3] }); + // Expect generated series referencing B column (values) and categories referencing A column + expect(xml).toContain('Data!$B$2:$B$4'); + expect(xml).toContain('Data!$A$2:$A$4'); + }); + + it('scatter falls back to numLit xVal when xValuesRange missing', () => { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter Fallback', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categories: ['Jan', 'Feb', 'Mar'], + values: [10, 20, 30], + sheetName: 'Sheet', + }); + expect(xml).toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + title: 'Overlay Check', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ type: 'line', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4' }); + // Only chart title autoDeleted present, no axis title nodes + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(0); + }); + + it('scatter axis titles render on both value axes when provided', () => { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter With Axis Titles', + xAxisTitle: 'X Axis', + yAxisTitle: 'Y Axis', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', xValuesRange: 'Sheet!$A$2:$A$4' }], + }); + // Expect 3 title nodes: chart + x axis + y axis + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(3); + expect(xml).toContain('X Axis'); + expect(xml).toContain('Y Axis'); + }); + + it('getMediaType returns chart', () => { + const chart = new Chart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(chart.getMediaType()).toBe('chart'); + }); + + it('bar chart specific attributes present', () => { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Line Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'scatter', + title: 'Scatter Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', xValuesRange: 'S!$A$2:$A$4' }], + }); + expect(xml).toContain(' { + const chart1 = new Chart({ + type: 'line', + title: 'C1', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart1.index = 1; + const xml1 = chart1.toChartSpaceXML().toString(); + expect(xml1).toContain(' { + const { xml } = buildChart({ + type: 'bar', + title: 'Bar Single X', + xAxisTitle: 'Only X', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(2); // chart + x axis + expect(xml).toContain('Only X'); + }); + + it('single yAxisTitle only adds chart + y axis title nodes', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Line Single Y', + yAxisTitle: 'Only Y', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + const titleNodeCount = xml.split('').length - 1; + expect(titleNodeCount).toBe(2); // chart + y axis + expect(xml).toContain('Only Y'); + }); + + it('custom width/height override graphicFrame ext', () => { + const chart = new Chart({ + type: 'bar', + title: 'Sized', + width: 5000000, + height: 1000000, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 5; + chart.setRelationshipId('rId50'); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 3, y: 10 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + expect(xml).toMatch(/ { + const { xml } = buildChart({ + type: 'line', + title: 'Legend Struct', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { + const { xml } = buildChart({ + sheetName: 'Data', + title: 'Empty Series', + categories: ['A', 'B', 'C'], + values: [1, 2, 3], + series: [], + }); + expect(xml).toContain('Data!$B$2:$B$4'); + expect(xml).toContain('Data!$A$2:$A$4'); + expect(xml).not.toContain(''); + }); + + it('scatter numLit fallback ptCount is 0 when no categories provided', () => { + const chart = new Chart({ + type: 'scatter', + title: 'Zero Scatter', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + }); + chart.index = 3; + const xml = chart.toChartSpaceXML().toString(); + expect(xml).toContain(''); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Vis Only', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const chart = new Chart({ + type: 'bar', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 7; + chart.setRelationshipId('rId707'); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 2, y: 8 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // Attribute order isn't guaranteed; accept either order. + expect(xml).toMatch(/ { + const chart = new Chart({ + type: 'line', + title: 'Has Rel', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 4; + chart.setRelationshipId('rId404'); + chart.createAnchor('twoCellAnchor', { from: { x: 1, y: 1 }, to: { x: 4, y: 12 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // Ensure r:id attribute present pointing to relationship id + expect(xml).toMatch(/]*r:id="rId404"/); + }); + + it('axis IDs reflect index multiplier base for higher index value', () => { + const chart = new Chart({ + type: 'bar', + title: 'Axis Base High', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 5; // expect 5001 & 5002 + const xml = chart.toChartSpaceXML().toString(); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Title Layout Overlay', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Verify both layout and overlay appear inside title block + const titleSegment = xml.match(/[\s\S]*?<\/c:title>/); + expect(titleSegment?.[0]).toContain(' { + const chart = new Chart({ + type: 'bar', + title: 'No Rel', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + chart.index = 9; + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 3, y: 6 } }); + const drawingDoc = Util.createXmlDoc(Util.schemas.spreadsheetDrawing, 'xdr:wsDr'); + const xml = chart.toXML(drawingDoc).toString(); + // r:id attribute present but empty string value + expect(xml).toMatch(/]*r:id=""/); }); }); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawings.ts b/packages/excel-builder-vanilla/src/Excel/Drawings.ts index 13959b6..e895f82 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawings.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawings.ts @@ -1,6 +1,7 @@ import { uniqueId } from '../utilities/uniqueId.js'; +import { Chart } from './Drawing/Chart.js'; import type { Drawing } from './Drawing/Drawing.js'; -import type { Picture } from './Drawing/Picture.js'; +import { Picture } from './Drawing/Picture.js'; import { RelationshipManager } from './RelationshipManager.js'; import { Util } from './Util.js'; @@ -9,7 +10,7 @@ import { Util } from './Util.js'; */ export class Drawings { - drawings: (Drawing | Picture)[] = []; + drawings: Drawing[] = []; relations = new RelationshipManager(); id = uniqueId('Drawings'); @@ -35,12 +36,22 @@ export class Drawings { drawings.setAttribute('xmlns:xdr', Util.schemas.spreadsheetDrawing); for (let i = 0, l = this.drawings.length; i < l; i++) { - let rId = this.relations.getRelationshipId((this.drawings[i] as Picture).getMediaData()); - if (!rId) { - rId = this.relations.addRelation((this.drawings[i] as Picture).getMediaData(), (this.drawings[i] as Picture).getMediaType()); //chart + const item = this.drawings[i]; + if (item instanceof Picture) { + let rId = this.relations.getRelationshipId(item.getMediaData()); + if (!rId) { + rId = this.relations.addRelation(item.getMediaData(), item.getMediaType()); + } + item.setRelationshipId(rId); + drawings.appendChild(item.toXML(doc)); + } else if (item instanceof Chart) { + let rId = this.relations.getRelationshipId(item); + if (!rId) { + rId = this.relations.addRelation(item, item.getMediaType()); + } + item.setRelationshipId(rId); + drawings.appendChild(item.toXML(doc)); } - (this.drawings[i] as Picture).setRelationshipId(rId); - drawings.appendChild((this.drawings[i] as Picture).toXML(doc)); } return doc; } diff --git a/packages/excel-builder-vanilla/src/Excel/Workbook.ts b/packages/excel-builder-vanilla/src/Excel/Workbook.ts index f972625..69446b3 100644 --- a/packages/excel-builder-vanilla/src/Excel/Workbook.ts +++ b/packages/excel-builder-vanilla/src/Excel/Workbook.ts @@ -1,4 +1,5 @@ import { uniqueId } from '../utilities/uniqueId.js'; +import type { Chart } from './Drawing/Chart.js'; import type { Drawings } from './Drawings.js'; import { Paths } from './Paths.js'; import { RelationshipManager } from './RelationshipManager.js'; @@ -28,6 +29,7 @@ export class Workbook { sharedStrings = new SharedStrings(); relations = new RelationshipManager(); worksheets: Worksheet[] = []; + charts: Chart[] = []; tables: Table[] = []; drawings: Drawings[] = []; media: { [filename: string]: MediaMeta } = {}; @@ -63,6 +65,13 @@ export class Workbook { this.drawings.push(drawings); } + addChart(chart: Chart) { + // Assign 1-based index & relative target for drawing relationship + chart.index = this.charts.length + 1; + chart.target = `../charts/chart${chart.index}.xml`; + this.charts.push(chart); + } + /** * Set number of rows to repeat for this sheet. * @@ -217,6 +226,15 @@ export class Workbook { ); } + for (i = 0, l = this.charts.length; i < l; i++) { + types.appendChild( + Util.createElement(doc, 'Override', [ + ['PartName', `/xl/charts/chart${i + 1}.xml`], + ['ContentType', 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml'], + ]), + ); + } + return doc; } @@ -313,6 +331,11 @@ export class Workbook { Paths[this.drawings[i].id] = `/xl/drawings/drawing${i + 1}.xml`; files[`/xl/drawings/_rels/drawing${i + 1}.xml.rels`] = this.drawings[i].relations.toXML(); } + + for (i = 0, l = this.charts.length; i < l; i++) { + files[`/xl/charts/chart${i + 1}.xml`] = this.charts[i].toChartSpaceXML(); + Paths[this.charts[i].id] = `/xl/charts/chart${i + 1}.xml`; + } } _prepareFilesForPackaging(files: { [path: string]: XMLDOM | string }) { diff --git a/packages/excel-builder-vanilla/src/Excel/__tests__/Drawings.spec.ts b/packages/excel-builder-vanilla/src/Excel/__tests__/Drawings.spec.ts index 43d5c48..f815356 100644 --- a/packages/excel-builder-vanilla/src/Excel/__tests__/Drawings.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/__tests__/Drawings.spec.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { createWorkbook } from '../../factory.js'; import { Picture } from '../Drawing/Picture.js'; import { Drawings } from '../Drawings.js'; +import { Chart } from '../Drawing/Chart.js'; import { Positioning } from '../Positioning.js'; describe('Drawings', () => { @@ -90,4 +91,21 @@ describe('Drawings', () => { d.relations = { getRelationshipId: () => null, addRelation: () => 'rId1' } as any; expect(() => d.toXML()).not.toThrow(); }); + + test('toXML chart branch assigns relationship and appends XML', () => { + const d = new Drawings(); + const chart = new Chart({ + type: 'bar', + title: 'ChartRel', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + chart.createAnchor('twoCellAnchor', { from: { x: 0, y: 0 }, to: { x: 2, y: 5 } }); + d.addDrawing(chart); + const xmlDoc = d.toXML(); + expect(chart.relId).toMatch(/^rId\d+$/); + const xmlStr = xmlDoc.toString(); + expect(xmlStr).toContain('ChartRel'); + expect(xmlStr).toContain(' { it('should initialize with default properties', () => { @@ -130,4 +131,50 @@ describe('Workbook', () => { delete (globalThis as any).window; }); }); + + describe('chart-related branches', () => { + it('addChart assigns index and target', () => { + const wb = new Workbook(); + const chart = new Chart({ + type: 'bar', + title: 'C1', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + wb.addChart(chart); + expect(chart.index).toBe(1); + expect(chart.target).toBe('../charts/chart1.xml'); + }); + + it('_generateCorePaths adds chart XML and path', () => { + const wb = new Workbook(); + const chart = new Chart({ + type: 'line', + title: 'LineChart', + series: [{ name: 'S1', valuesRange: 'Sheet!$A$1:$A$1' }], + categoriesRange: 'Sheet!$A$1:$A$1', + }); + wb.addChart(chart); + const files: any = {}; + wb._generateCorePaths(files); + expect(files['/xl/charts/chart1.xml']).toBeTruthy(); + expect(Paths[chart.id]).toBe('/xl/charts/chart1.xml'); + }); + + it('generateFiles includes worksheet rel file and chart file', async () => { + const wb = new Workbook(); + const ws = wb.createWorksheet({ name: 'Data' }); + wb.addWorksheet(ws); + const chart = new Chart({ + type: 'pie', + title: 'PieChart', + series: [{ name: 'S1', valuesRange: 'Data!$A$1:$A$1' }], + categoriesRange: 'Data!$A$1:$A$1', + }); + wb.addChart(chart); + const files = await wb.generateFiles(); + expect(files['/xl/worksheets/_rels/sheet1.xml.rels']).toBeTruthy(); + expect(files['/xl/charts/chart1.xml']).toBeTruthy(); + }); + }); }); diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index d69edf4..5f889c8 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -4,6 +4,7 @@ * Online tool: https://www.myfixguide.com/color-converter/ */ export type ExcelColorStyle = string | { theme: number }; + export interface ExcelAlignmentStyle { horizontal?: 'center' | 'fill' | 'general' | 'justify' | 'left' | 'right'; justifyLastLine?: boolean; @@ -140,3 +141,44 @@ export interface ExcelStyleInstruction { /** style id */ style?: number; } + +// --------------------------- +// Chart related interfaces +// --------------------------- +export type ChartType = 'bar' | 'line' | 'pie' | 'scatter'; + +export interface ChartSeriesRef { + /** Series display name */ + name: string; + /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ + valuesRange: string; + /** Hex ARGB or RGB color (e.g. FF0000 or FF0000FF) - currently cosmetic placeholder */ + color?: string; + /** For scatter charts: X axis values range */ + xValuesRange?: string; +} + +export interface ChartOptions { + /** Chart type (bar default if omitted for backward compatibility) */ + type?: ChartType; + /** Chart title shown above plot area */ + title?: string; + /** Category axis title (ignored for pie) */ + xAxisTitle?: string; + /** Value axis title (ignored for pie) */ + yAxisTitle?: string; + /** Width in EMUs */ + width?: number; + /** Height in EMUs */ + height?: number; + /** Worksheet name containing referenced ranges */ + sheetName?: string; + /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ + categoriesRange?: string; + /** Multi-series cell references */ + series?: ChartSeriesRef[]; + /** Legacy single-series fallback: categories literal */ + categories?: string[]; + /** Legacy single-series fallback: values literal */ + values?: number[]; +} diff --git a/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts b/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts new file mode 100644 index 0000000..aabbf36 --- /dev/null +++ b/packages/excel-builder-vanilla/src/utilities/__tests__/escape.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { htmlEscape } from '../escape.js'; + +describe('htmlEscape', () => { + it('should escape special HTML characters', () => { + expect(htmlEscape('&')).toBe('&'); + expect(htmlEscape('<')).toBe('<'); + expect(htmlEscape('>')).toBe('>'); + expect(htmlEscape('"')).toBe('"'); + expect(htmlEscape("'")).toBe('''); + }); + + it('should escape multiple special characters in a string', () => { + expect(htmlEscape('fred, barney, & pebbles')).toBe('fred, barney, & pebbles'); + expect(htmlEscape('')).toBe('<script>alert("XSS");</script>'); + }); + + it('should convert non-string inputs to strings', () => { + expect(htmlEscape(123 as any)).toBe('123'); + expect(htmlEscape(null as any)).toBe('null'); + expect(htmlEscape(undefined as any)).toBe('undefined'); + expect(htmlEscape(true as any)).toBe('true'); + }); + + it('should not modify strings without special characters', () => { + expect(htmlEscape('normal text')).toBe('normal text'); + expect(htmlEscape('')).toBe(''); + }); + + it('should handle mixed special and normal characters', () => { + expect(htmlEscape('Tom & Jerry < cartoon > "quote" \'test\'')).toBe( + 'Tom & Jerry < cartoon > "quote" 'test'', + ); + }); + + it('should work with repeated special characters', () => { + expect(htmlEscape('&&<<>>""\'\'')).toBe('&&<<>>""'''); + }); +}); diff --git a/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts b/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts index eaec560..fa0f412 100644 --- a/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts +++ b/packages/excel-builder-vanilla/src/utilities/__tests__/isTypeOf.spec.ts @@ -34,6 +34,13 @@ describe('isPlainObject() method', () => { const output = isPlainObject(null); expect(output).toBeFalsy(); }); + + it('should return truthy when object has a null prototype', () => { + const obj = Object.create(null); + (obj as any).foo = 'bar'; + const output = isPlainObject(obj); + expect(output).toBeTruthy(); + }); }); describe('isString() method', () => { From 952dd09904f2adb99867e2c57da0ab6e09d13a01 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 00:12:54 -0400 Subject: [PATCH 02/17] chore: move Chart to start at E2 cell --- packages/demo/src/examples/example18.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index f98c15b..9cc5c56 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -72,7 +72,7 @@ export default class Example18 { }); const anchor = chart.createAnchor('twoCellAnchor', { - from: { x: 4, y: 6 }, // start at internal column index 4 (visually appears around Excel column E due to default widths) + from: { x: 4, y: 1 }, // start Chart at E2 cell to: { x: 15, y: 30 }, // end column chosen to preserve approximate chart width }); chart.anchor = anchor; From b96c643c4b6ba58bc87d325c7a7bb3e313e27aea Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 16:28:43 -0400 Subject: [PATCH 03/17] chore: add Column Chart (vertical) and rename Bar Chart (vertical) --- docs/inserting-charts.md | 22 ++++++++-- packages/demo/src/examples/example18.ts | 12 +++--- .../dist/index.d.ts | 4 +- .../src/Excel/Drawing/Chart.ts | 11 ++++- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 40 +++++++++++++------ .../excel-builder-vanilla/src/interfaces.ts | 4 +- 6 files changed, 66 insertions(+), 27 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 7fe0294..81f2417 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -3,7 +3,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That's all—just practical usage. ### Supported types -`bar` (clustered column), `line`, `pie`, `scatter` +`column` (vertical clustered), `bar` (horizontal), `line`, `pie`, `scatter` ### Core steps 1. Create a workbook & worksheet @@ -16,7 +16,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That ### Option summary (ChartOptions) | Option | Purpose | Notes | |--------|---------|-------| -| type | `bar` | `line` | `pie` | `scatter` | Defaults to `bar` | +| type | `column` | `bar` | `line` | `pie` | `scatter` | Defaults to `column` | | title | Chart title | Omit for none | | xAxisTitle | X axis label | Ignored for pie | | yAxisTitle | Y axis label | Ignored for pie | @@ -27,7 +27,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That | sheetName + categories + values | Fallback single series | Arrays instead of ranges | -### Quick start (multi‑series bar chart) +### Quick start (multi‑series column chart) ```ts const wb = createWorkbook(); const ws = wb.createWorksheet({ name: 'Sales' }); @@ -39,7 +39,7 @@ ws.addRow(['Feb', 20, 25]); ws.addRow(['Mar', 30, 35]); const chart = new Chart({ - type: 'bar', + type: 'column', title: 'Quarterly Sales', xAxisTitle: 'Month', yAxisTitle: 'Revenue', @@ -57,6 +57,20 @@ ws.addDrawings(drawings.addDrawing(chart)); // or add drawings first then the ch await wb.generateFiles(); ``` +### Horizontal bar chart +```ts +const barChart = new Chart({ + type: 'bar', + title: 'Revenue (Horizontal Bar)', + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, + ], + categoriesRange: 'Sales!$A$2:$A$4', +}); +wb.addChart(barChart); +``` + ### Line chart (with axis titles) ```ts const lineChart = new Chart({ diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index 9cc5c56..2e7fc51 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -1,4 +1,4 @@ -import { downloadExcelFile, Workbook, Chart, Drawings } from 'excel-builder-vanilla'; +import { Chart, Drawings, downloadExcelFile, Workbook } from 'excel-builder-vanilla'; export default class Example18 { exportBtnElm!: HTMLButtonElement; @@ -21,7 +21,7 @@ export default class Example18 { const wb = new Workbook(); // Helper: create a sheet that includes its own data table & a chart of given type - const createChartSheetWithLocalData = (type: 'bar' | 'line' | 'pie' | 'scatter', sheetName: string) => { + const createChartSheetWithLocalData = (type: 'column' | 'bar' | 'line' | 'pie' | 'scatter', sheetName: string) => { const ws = wb.createWorksheet({ name: sheetName }); let categoriesRange: string | undefined; let seriesDefs: { name: string; valuesRange: string; xValuesRange?: string }[] = []; @@ -64,8 +64,9 @@ export default class Example18 { title: `${sheetName} (${type}) Chart`, xAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', yAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : 'Values', - width: 640 * 9525, - height: 400 * 9525, + // Reduced to ~80% of previous size (640x400 -> 512x320) + width: 512 * 9525, + height: 320 * 9525, sheetName, categoriesRange, series: seriesDefs, @@ -83,7 +84,8 @@ export default class Example18 { }; // Create one sheet per chart type with its own data - createChartSheetWithLocalData('bar', 'Bar'); + createChartSheetWithLocalData('column', 'Column'); // vertical column chart + createChartSheetWithLocalData('bar', 'Bar'); // horizontal bar chart createChartSheetWithLocalData('line', 'Line'); createChartSheetWithLocalData('pie', 'Pie'); createChartSheetWithLocalData('scatter', 'Scatter'); diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 3344fff..84c659e 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -287,7 +287,7 @@ export interface ExcelStyleInstruction { /** style id */ style?: number; } -export type ChartType = "bar" | "line" | "pie" | "scatter"; +export type ChartType = "column" | "bar" | "line" | "pie" | "scatter"; export interface ChartSeriesRef { /** Series display name */ name: string; @@ -299,7 +299,7 @@ export interface ChartSeriesRef { xValuesRange?: string; } export interface ChartOptions { - /** Chart type (bar default if omitted for backward compatibility) */ + /** Chart type (defaults to 'column' if omitted) */ type?: ChartType; /** Chart title shown above plot area */ title?: string; diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 1beaa37..397f6db 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -109,7 +109,8 @@ export class Chart extends Drawing { const axIdCat = axisBase + 1; const axIdVal = axisBase + 2; - const type = this.options.type || 'bar'; + // Default to vertical column chart if type omitted (Excel naming consistency). + const type = this.options.type || 'column'; let primaryChartNode: XMLNode; switch (type) { case 'line': @@ -127,7 +128,15 @@ export class Chart extends Drawing { primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); break; case 'bar': + // Horizontal bar chart (Excel's Bar chart) + primaryChartNode = Util.createElement(doc, 'c:barChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'bar']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + case 'column': default: + // Vertical column chart (previous 'bar' behavior) primaryChartNode = Util.createElement(doc, 'c:barChart'); primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'col']])); primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 40b0875..413164f 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -12,7 +12,7 @@ function buildChart(opts: any) { } describe('Chart', () => { - it('emits barChart node for bar type', () => { + it('emits barChart node for horizontal bar type (barDir bar)', () => { const { xml } = buildChart({ type: 'bar', title: 'Bar Chart', @@ -20,6 +20,7 @@ describe('Chart', () => { categoriesRange: 'Sheet!$A$2:$A$4', }); expect(xml).toContain(' { }); it('drawing graphicFrame uses default ext when Chart options width/height omitted', () => { const chart = new Chart({ - type: 'bar', + type: 'column', title: 'Defaults', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', @@ -64,7 +65,7 @@ describe('Chart', () => { it('includes chart title when provided and sets autoTitleDeleted=0', () => { const { xml } = buildChart({ - type: 'bar', + type: 'column', title: 'Custom Title', series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], categoriesRange: 'Sheet!$A$2:$A$4', @@ -75,7 +76,7 @@ describe('Chart', () => { it('omits chart title when not provided and sets autoTitleDeleted=1', () => { const { xml } = buildChart({ - type: 'bar', + type: 'column', series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], categoriesRange: 'Sheet!$A$2:$A$4', }); @@ -116,7 +117,7 @@ describe('Chart', () => { it('emits multiple series with correct idx/order', () => { const { xml } = buildChart({ - type: 'bar', + type: 'column', title: 'Bar', series: [ { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, @@ -133,13 +134,14 @@ describe('Chart', () => { expect(xml).toContain(' { + it('defaults to column (vertical) when type omitted', () => { const { xml } = buildChart({ - title: 'Implicit Bar', + title: 'Implicit Column', series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], categoriesRange: 'Sheet!$A$2:$A$4', }); expect(xml).toContain(' { @@ -187,7 +189,7 @@ describe('Chart', () => { it('chart title overlay value is set to 0', () => { const { xml } = buildChart({ - type: 'bar', + type: 'column', title: 'Overlay Check', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', @@ -233,6 +235,18 @@ describe('Chart', () => { series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Column Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); expect(xml).toContain(' { it('single xAxisTitle only adds chart + x axis title nodes', () => { const { xml } = buildChart({ - type: 'bar', + type: 'column', title: 'Bar Single X', xAxisTitle: 'Only X', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], @@ -322,7 +336,7 @@ describe('Chart', () => { it('custom width/height override graphicFrame ext', () => { const chart = new Chart({ - type: 'bar', + type: 'column', title: 'Sized', width: 5000000, height: 1000000, @@ -398,7 +412,7 @@ describe('Chart', () => { it('graphicFrame name defaults to "Chart" when title omitted', () => { const chart = new Chart({ - type: 'bar', + type: 'column', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', }); @@ -429,7 +443,7 @@ describe('Chart', () => { it('axis IDs reflect index multiplier base for higher index value', () => { const chart = new Chart({ - type: 'bar', + type: 'column', title: 'Axis Base High', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', @@ -455,7 +469,7 @@ describe('Chart', () => { it('graphicFrame r:id is empty when relationship not yet set', () => { const chart = new Chart({ - type: 'bar', + type: 'column', title: 'No Rel', series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index 5f889c8..93f0d8e 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -145,7 +145,7 @@ export interface ExcelStyleInstruction { // --------------------------- // Chart related interfaces // --------------------------- -export type ChartType = 'bar' | 'line' | 'pie' | 'scatter'; +export type ChartType = 'column' | 'bar' | 'line' | 'pie' | 'scatter'; export interface ChartSeriesRef { /** Series display name */ @@ -159,7 +159,7 @@ export interface ChartSeriesRef { } export interface ChartOptions { - /** Chart type (bar default if omitted for backward compatibility) */ + /** Chart type (defaults to 'column' if omitted) */ type?: ChartType; /** Chart title shown above plot area */ title?: string; From fccbfaa42fae8603bf554dc8c9d8422b6193700a Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 16:34:47 -0400 Subject: [PATCH 04/17] chore(charts): add optional data snapshot caching and coverage tests --- .../src/Excel/Drawing/Chart.ts | 42 ++++++++++++ .../src/Excel/Drawing/__tests__/Chart.spec.ts | 67 +++++++++++++++++++ .../excel-builder-vanilla/src/interfaces.ts | 2 + 3 files changed, 111 insertions(+) diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 397f6db..b48a901 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -163,6 +163,36 @@ export class Chart extends Drawing { ? `${this.options.sheetName}!$A$2:$A$${this.options.categories.length + 1}` : ''); + const includeCache = this.options.includeDataCache !== false; // default true + + const buildStrCache = (values: string[]): XMLNode => { + const cache = Util.createElement(doc, 'c:strCache'); + cache.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(values.length)]])); + values.forEach((v, i) => { + const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); + const vNode = Util.createElement(doc, 'c:v'); + vNode.appendChild(doc.createTextNode(v)); + pt.appendChild(vNode); + cache.appendChild(pt); + }); + return cache; + }; + + const buildNumCache = (values: (string | number)[]): XMLNode => { + const cache = Util.createElement(doc, 'c:numCache'); + // Excel typically includes formatCode; using General as safe default + cache.appendChild(Util.createElement(doc, 'c:formatCode', [['val', 'General']])); + cache.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(values.length)]])); + values.forEach((v, i) => { + const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); + const vNode = Util.createElement(doc, 'c:v'); + vNode.appendChild(doc.createTextNode(String(v))); + pt.appendChild(vNode); + cache.appendChild(pt); + }); + return cache; + }; + seriesDefs.forEach((s, idx) => { const ser = Util.createElement(doc, 'c:ser'); ser.appendChild(Util.createElement(doc, 'c:idx', [['val', String(idx)]])); @@ -183,6 +213,9 @@ export class Chart extends Drawing { const fNodeX = Util.createElement(doc, 'c:f'); fNodeX.appendChild(doc.createTextNode(s.xValuesRange)); numRefX.appendChild(fNodeX); + if (includeCache && this.options.categories?.length) { + numRefX.appendChild(buildNumCache(this.options.categories.map((_, i) => i))); // categories indices act as X when explicit xValuesRange used? Keep empty unless fallback. + } xVal.appendChild(numRefX); } else { // fallback generate indices @@ -204,6 +237,9 @@ export class Chart extends Drawing { const fNodeY = Util.createElement(doc, 'c:f'); fNodeY.appendChild(doc.createTextNode(s.valuesRange)); numRefY.appendChild(fNodeY); + if (includeCache && this.options.values?.length) { + numRefY.appendChild(buildNumCache(this.options.values)); + } yVal.appendChild(numRefY); ser.appendChild(yVal); } else { @@ -214,6 +250,9 @@ export class Chart extends Drawing { const fNodeCat = Util.createElement(doc, 'c:f'); fNodeCat.appendChild(doc.createTextNode(categoriesRange)); strRef.appendChild(fNodeCat); + if (includeCache && this.options.categories?.length) { + strRef.appendChild(buildStrCache(this.options.categories)); + } cat.appendChild(strRef); ser.appendChild(cat); } @@ -224,6 +263,9 @@ export class Chart extends Drawing { const fNodeVal = Util.createElement(doc, 'c:f'); fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); numRef.appendChild(fNodeVal); + if (includeCache && this.options.values?.length) { + numRef.appendChild(buildNumCache(this.options.values)); + } val.appendChild(numRef); ser.appendChild(val); } diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 413164f..9a28c29 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -481,4 +481,71 @@ describe('Chart', () => { // r:id attribute present but empty string value expect(xml).toMatch(/]*r:id=""/); }); + + // ----------------- + // Data cache tests + // ----------------- + it('includes strCache and numCache for fallback single series when categories & values provided', () => { + const { xml } = buildChart({ + sheetName: 'Data', + title: 'Cached Fallback', + categories: ['North', 'South', 'East'], + values: [10, 20, 30], + }); + expect(xml).toContain(''); + expect(xml).toContain(''); + // ptCount should match length 3 + const ptCountMatches = xml.match(/ { + const chart = new Chart({ + sheetName: 'Data', + title: 'No Cache', + categories: ['A', 'B', 'C', 'D'], + values: [5, 15, 25, 35], + includeDataCache: false, + }); + chart.index = 2; + const xml = chart.toChartSpaceXML().toString(); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); + + it('scatter chart includes numCache blocks for xVal and yVal when categories & values arrays provided', () => { + const chart = new Chart({ + type: 'scatter', + sheetName: 'Data', + title: 'Scatter Cache', + categories: ['Jan', 'Feb', 'Mar', 'Apr'], // used for x fallback indices cache + values: [5, 15, 25, 35], + series: [{ name: 'S1', valuesRange: 'Data!$B$2:$B$5', xValuesRange: 'Data!$A$2:$A$5' }], + }); + chart.index = 3; + const xml = chart.toChartSpaceXML().toString(); + // Expect two numCache occurrences (xVal and yVal) + const numCacheCount = xml.split('').length - 1; + expect(numCacheCount).toBe(2); + // Each should have ptCount val="4" + const ptCounts = xml.match(/ { + const { xml } = buildChart({ + sheetName: 'Data', + title: 'Contiguous Cache', + categories: ['X', 'Y', 'Z', 'W'], + values: [1, 2, 3, 4], + }); + // Ensure indices 0..3 present and no gap (basic check) + for (let i = 0; i < 4; i++) { + expect(xml).toContain(` Date: Wed, 22 Oct 2025 16:58:18 -0400 Subject: [PATCH 05/17] chore: remove any legacy fallback categories/series stuff --- docs/inserting-charts.md | 15 +-- .../dist/index.d.ts | 4 - .../src/Excel/Drawing/Chart.ts | 83 ++-------------- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 99 +++---------------- .../excel-builder-vanilla/src/interfaces.ts | 6 -- 5 files changed, 26 insertions(+), 181 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 81f2417..0a37320 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -8,7 +8,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That ### Core steps 1. Create a workbook & worksheet 2. Add data rows -3. Create a chart (ranges or fallback arrays) +3. Create a chart (using cell ranges) 4. Call `wb.addChart(chart)` 5. Anchor it (e.g. `twoCellAnchor`) 6. Generate files @@ -24,7 +24,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That | categoriesRange | Category labels range | Skip for scatter when using `xValuesRange` | | series | Array of `{ name, valuesRange }` | 2+ series => legend | | series[].xValuesRange | Scatter X values range | Only for scatter | -| sheetName + categories + values | Fallback single series | Arrays instead of ranges | +| sheetName | Name used when building range strings | Optional (used for convenience or clarity) | ### Quick start (multi‑series column chart) @@ -112,16 +112,6 @@ const scatter = new Chart({ wb.addChart(scatter); ``` -### Fallback single series (arrays) -```ts -const autoChart = new Chart({ - sheetName: 'AutoData', - categories: ['Jan', 'Feb', 'Mar'], - values: [10, 20, 30], - title: 'Auto Series', -}); -wb.addChart(autoChart); // legend omitted (only one series) -``` ## Resizing (width & height) ```ts @@ -149,7 +139,6 @@ The legend only appears when the chart has two or more series. Notes: - Pie: if you add multiple series you get multiple pies; the legend shows the series names. -- Fallback (arrays) path creates only one series, so no legend. Example (legend will show 2 entries): ```ts diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 84c659e..6272c39 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -317,10 +317,6 @@ export interface ChartOptions { categoriesRange?: string; /** Multi-series cell references */ series?: ChartSeriesRef[]; - /** Legacy single-series fallback: categories literal */ - categories?: string[]; - /** Legacy single-series fallback: values literal */ - values?: number[]; } /** * @module Excel/Util diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index b48a901..dafba70 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -111,6 +111,8 @@ export class Chart extends Drawing { // Default to vertical column chart if type omitted (Excel naming consistency). const type = this.options.type || 'column'; + // Categories range (shared across all non-scatter series when provided) + const categoriesRange = this.options.categoriesRange || ''; let primaryChartNode: XMLNode; switch (type) { case 'line': @@ -120,6 +122,8 @@ export class Chart extends Drawing { break; case 'pie': primaryChartNode = Util.createElement(doc, 'c:pieChart'); + primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + // Pie charts typically set varyColors=1 so each slice gets a distinct fill. primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); break; case 'scatter': @@ -128,7 +132,7 @@ export class Chart extends Drawing { primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); break; case 'bar': - // Horizontal bar chart (Excel's Bar chart) + // Horizontal bar chart primaryChartNode = Util.createElement(doc, 'c:barChart'); primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'bar']])); primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); @@ -144,56 +148,9 @@ export class Chart extends Drawing { break; } - // Build series (multi or single fallback) - const seriesDefs = this.options.series?.length - ? this.options.series - : [ - { - name: this.options.title || 'Series 1', - valuesRange: - this.options.values?.length && this.options.sheetName && this.options.categories?.length - ? `${this.options.sheetName}!$B$2:$B$${this.options.categories.length + 1}` - : '', - }, - ]; - - const categoriesRange = - this.options.categoriesRange || - (this.options.sheetName && this.options.categories?.length - ? `${this.options.sheetName}!$A$2:$A$${this.options.categories.length + 1}` - : ''); - - const includeCache = this.options.includeDataCache !== false; // default true + // Lean chart XML (no fallback shorthand or data cache snapshots) - const buildStrCache = (values: string[]): XMLNode => { - const cache = Util.createElement(doc, 'c:strCache'); - cache.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(values.length)]])); - values.forEach((v, i) => { - const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); - const vNode = Util.createElement(doc, 'c:v'); - vNode.appendChild(doc.createTextNode(v)); - pt.appendChild(vNode); - cache.appendChild(pt); - }); - return cache; - }; - - const buildNumCache = (values: (string | number)[]): XMLNode => { - const cache = Util.createElement(doc, 'c:numCache'); - // Excel typically includes formatCode; using General as safe default - cache.appendChild(Util.createElement(doc, 'c:formatCode', [['val', 'General']])); - cache.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(values.length)]])); - values.forEach((v, i) => { - const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); - const vNode = Util.createElement(doc, 'c:v'); - vNode.appendChild(doc.createTextNode(String(v))); - pt.appendChild(vNode); - cache.appendChild(pt); - }); - return cache; - }; - - seriesDefs.forEach((s, idx) => { + (this.options.series || []).forEach((s, idx) => { const ser = Util.createElement(doc, 'c:ser'); ser.appendChild(Util.createElement(doc, 'c:idx', [['val', String(idx)]])); ser.appendChild(Util.createElement(doc, 'c:order', [['val', String(idx)]])); @@ -213,22 +170,11 @@ export class Chart extends Drawing { const fNodeX = Util.createElement(doc, 'c:f'); fNodeX.appendChild(doc.createTextNode(s.xValuesRange)); numRefX.appendChild(fNodeX); - if (includeCache && this.options.categories?.length) { - numRefX.appendChild(buildNumCache(this.options.categories.map((_, i) => i))); // categories indices act as X when explicit xValuesRange used? Keep empty unless fallback. - } xVal.appendChild(numRefX); } else { - // fallback generate indices + // Minimal empty numLit fallback const numLitX = Util.createElement(doc, 'c:numLit'); - const count = this.options.categories?.length || 0; - numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', String(count)]])); - for (let i = 0; i < count; i++) { - const pt = Util.createElement(doc, 'c:pt', [['idx', String(i)]]); - const vNode = Util.createElement(doc, 'c:v'); - vNode.appendChild(doc.createTextNode(String(i))); - pt.appendChild(vNode); - numLitX.appendChild(pt); - } + numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', '0']])); xVal.appendChild(numLitX); } ser.appendChild(xVal); @@ -237,9 +183,6 @@ export class Chart extends Drawing { const fNodeY = Util.createElement(doc, 'c:f'); fNodeY.appendChild(doc.createTextNode(s.valuesRange)); numRefY.appendChild(fNodeY); - if (includeCache && this.options.values?.length) { - numRefY.appendChild(buildNumCache(this.options.values)); - } yVal.appendChild(numRefY); ser.appendChild(yVal); } else { @@ -250,9 +193,6 @@ export class Chart extends Drawing { const fNodeCat = Util.createElement(doc, 'c:f'); fNodeCat.appendChild(doc.createTextNode(categoriesRange)); strRef.appendChild(fNodeCat); - if (includeCache && this.options.categories?.length) { - strRef.appendChild(buildStrCache(this.options.categories)); - } cat.appendChild(strRef); ser.appendChild(cat); } @@ -263,9 +203,6 @@ export class Chart extends Drawing { const fNodeVal = Util.createElement(doc, 'c:f'); fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); numRef.appendChild(fNodeVal); - if (includeCache && this.options.values?.length) { - numRef.appendChild(buildNumCache(this.options.values)); - } val.appendChild(numRef); ser.appendChild(val); } @@ -348,7 +285,7 @@ export class Chart extends Drawing { } // Legend if multiple series - if (seriesDefs.length > 1) { + if ((this.options.series || []).length > 1) { const legend = Util.createElement(doc, 'c:legend'); legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', 'r']])); legend.appendChild(Util.createElement(doc, 'c:layout')); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 9a28c29..1b77256 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -167,24 +167,21 @@ describe('Chart', () => { expect(xml).toContain(''); }); - it('generates fallback single series ranges when categories & values provided without series', () => { - const { xml } = buildChart({ sheetName: 'Data', title: 'Fallback', categories: ['A', 'B', 'C'], values: [1, 2, 3] }); - // Expect generated series referencing B column (values) and categories referencing A column - expect(xml).toContain('Data!$B$2:$B$4'); - expect(xml).toContain('Data!$A$2:$A$4'); + it('generates single series ranges when categories & values provided without series', () => { + // Removed fallback behavior (no implicit series creation). Test now asserts absence of old markers. + const { xml } = buildChart({ sheetName: 'Data', title: 'Fallback' }); + expect(xml).not.toContain('Data!$B$2:$B$4'); + expect(xml).not.toContain('Data!$A$2:$A$4'); }); - it('scatter falls back to numLit xVal when xValuesRange missing', () => { + it('scatter emits empty numLit xVal when xValuesRange missing', () => { const { xml } = buildChart({ type: 'scatter', - title: 'Scatter Fallback', + title: 'Scatter No X Range', series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], - categories: ['Jan', 'Feb', 'Mar'], - values: [10, 20, 30], - sheetName: 'Sheet', }); expect(xml).toContain(''); - expect(xml).toContain(' { @@ -374,20 +371,14 @@ describe('Chart', () => { expect(xml).not.toContain(' { - const { xml } = buildChart({ - sheetName: 'Data', - title: 'Empty Series', - categories: ['A', 'B', 'C'], - values: [1, 2, 3], - series: [], - }); - expect(xml).toContain('Data!$B$2:$B$4'); - expect(xml).toContain('Data!$A$2:$A$4'); + it('empty series array generates single series and no legend', () => { + const { xml } = buildChart({ sheetName: 'Data', title: 'Empty Series', series: [] }); + // No series emitted, no legend expected + expect(xml).not.toContain(''); expect(xml).not.toContain(''); }); - it('scatter numLit fallback ptCount is 0 when no categories provided', () => { + it('scatter numLit ptCount is 0 when no categories provided', () => { const chart = new Chart({ type: 'scatter', title: 'Zero Scatter', @@ -485,67 +476,5 @@ describe('Chart', () => { // ----------------- // Data cache tests // ----------------- - it('includes strCache and numCache for fallback single series when categories & values provided', () => { - const { xml } = buildChart({ - sheetName: 'Data', - title: 'Cached Fallback', - categories: ['North', 'South', 'East'], - values: [10, 20, 30], - }); - expect(xml).toContain(''); - expect(xml).toContain(''); - // ptCount should match length 3 - const ptCountMatches = xml.match(/ { - const chart = new Chart({ - sheetName: 'Data', - title: 'No Cache', - categories: ['A', 'B', 'C', 'D'], - values: [5, 15, 25, 35], - includeDataCache: false, - }); - chart.index = 2; - const xml = chart.toChartSpaceXML().toString(); - expect(xml).not.toContain(''); - expect(xml).not.toContain(''); - }); - - it('scatter chart includes numCache blocks for xVal and yVal when categories & values arrays provided', () => { - const chart = new Chart({ - type: 'scatter', - sheetName: 'Data', - title: 'Scatter Cache', - categories: ['Jan', 'Feb', 'Mar', 'Apr'], // used for x fallback indices cache - values: [5, 15, 25, 35], - series: [{ name: 'S1', valuesRange: 'Data!$B$2:$B$5', xValuesRange: 'Data!$A$2:$A$5' }], - }); - chart.index = 3; - const xml = chart.toChartSpaceXML().toString(); - // Expect two numCache occurrences (xVal and yVal) - const numCacheCount = xml.split('').length - 1; - expect(numCacheCount).toBe(2); - // Each should have ptCount val="4" - const ptCounts = xml.match(/ { - const { xml } = buildChart({ - sheetName: 'Data', - title: 'Contiguous Cache', - categories: ['X', 'Y', 'Z', 'W'], - values: [1, 2, 3, 4], - }); - // Ensure indices 0..3 present and no gap (basic check) - for (let i = 0; i < 4; i++) { - expect(xml).toContain(` Date: Wed, 22 Oct 2025 17:01:24 -0400 Subject: [PATCH 06/17] chore: remove unused sheetName prop from ChartOptions for leaner API --- docs/inserting-charts.md | 1 - packages/demo/src/examples/example18.ts | 1 - .../src/Excel/Drawing/__tests__/Chart.spec.ts | 13 +++++-------- packages/excel-builder-vanilla/src/interfaces.ts | 2 -- 4 files changed, 5 insertions(+), 12 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 0a37320..1529d0b 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -24,7 +24,6 @@ Add charts to a workbook: create data, create a chart, add it, position it. That | categoriesRange | Category labels range | Skip for scatter when using `xValuesRange` | | series | Array of `{ name, valuesRange }` | 2+ series => legend | | series[].xValuesRange | Scatter X values range | Only for scatter | -| sheetName | Name used when building range strings | Optional (used for convenience or clarity) | ### Quick start (multi‑series column chart) diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index 2e7fc51..1558ba3 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -67,7 +67,6 @@ export default class Example18 { // Reduced to ~80% of previous size (640x400 -> 512x320) width: 512 * 9525, height: 320 * 9525, - sheetName, categoriesRange, series: seriesDefs, }); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 1b77256..db0695b 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -167,11 +167,9 @@ describe('Chart', () => { expect(xml).toContain(''); }); - it('generates single series ranges when categories & values provided without series', () => { - // Removed fallback behavior (no implicit series creation). Test now asserts absence of old markers. - const { xml } = buildChart({ sheetName: 'Data', title: 'Fallback' }); - expect(xml).not.toContain('Data!$B$2:$B$4'); - expect(xml).not.toContain('Data!$A$2:$A$4'); + it('generates no implicit series when only title provided', () => { + const { xml } = buildChart({ title: 'Fallback' }); + expect(xml).not.toContain(''); }); it('scatter emits empty numLit xVal when xValuesRange missing', () => { @@ -371,9 +369,8 @@ describe('Chart', () => { expect(xml).not.toContain(' { - const { xml } = buildChart({ sheetName: 'Data', title: 'Empty Series', series: [] }); - // No series emitted, no legend expected + it('empty series array emits no series and no legend', () => { + const { xml } = buildChart({ title: 'Empty Series', series: [] }); expect(xml).not.toContain(''); expect(xml).not.toContain(''); }); diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index 97cf9f0..02d3688 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -171,8 +171,6 @@ export interface ChartOptions { width?: number; /** Height in EMUs */ height?: number; - /** Worksheet name containing referenced ranges */ - sheetName?: string; /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ categoriesRange?: string; /** Multi-series cell references */ From 7283c634a389ddaa8d19562de5f56e8d860e6755 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 17:35:56 -0400 Subject: [PATCH 07/17] chore: optimize code to be a bit more DRY --- .../dist/index.d.ts | 6 +- .../src/Excel/Drawing/Chart.ts | 96 +++++++------------ 2 files changed, 40 insertions(+), 62 deletions(-) diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 6272c39..17b6938 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -311,8 +311,6 @@ export interface ChartOptions { width?: number; /** Height in EMUs */ height?: number; - /** Worksheet name containing referenced ranges */ - sheetName?: string; /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ categoriesRange?: string; /** Multi-series cell references */ @@ -414,6 +412,10 @@ export declare class Chart extends Drawing { toChartSpaceXML(): XMLDOM; /** Create a c:title node with minimal rich text required for Excel to render */ private _createTitleNode; + /** Create a category axis (catAx) */ + private _createCategoryAxis; + /** Create a value axis (valAx) */ + private _createValueAxis; } export type Relation = { [id: string]: { diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index dafba70..d349656 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -220,67 +220,11 @@ export class Chart extends Drawing { if (type !== 'pie') { if (type === 'scatter') { - // Scatter requires two value axes (X and Y), not a category axis. - const xValAx = Util.createElement(doc, 'c:valAx'); - xValAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); - const xScaling = Util.createElement(doc, 'c:scaling'); - xScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); - xValAx.appendChild(xScaling); - xValAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); - xValAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); - xValAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdVal)]])); - xValAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); - xValAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); - if (this.options.xAxisTitle) { - xValAx.appendChild(this._createTitleNode(doc, this.options.xAxisTitle)); - } - plotArea.appendChild(xValAx); - - const yValAx = Util.createElement(doc, 'c:valAx'); - yValAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); - const yScaling = Util.createElement(doc, 'c:scaling'); - yScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); - yValAx.appendChild(yScaling); - yValAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); - yValAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'l']])); - yValAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdCat)]])); - yValAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); - yValAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); - if (this.options.yAxisTitle) { - yValAx.appendChild(this._createTitleNode(doc, this.options.yAxisTitle)); - } - plotArea.appendChild(yValAx); + plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', this.options.xAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', this.options.yAxisTitle)); } else { - // Non-scatter (bar/line) use category axis + value axis. - const catAx = Util.createElement(doc, 'c:catAx'); - catAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); - const catScaling = Util.createElement(doc, 'c:scaling'); - catScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); - catAx.appendChild(catScaling); - catAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); - catAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); - catAx.appendChild(Util.createElement(doc, 'c:tickLblPos', [['val', 'nextTo']])); - catAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdVal)]])); - catAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); - if (this.options.xAxisTitle) { - catAx.appendChild(this._createTitleNode(doc, this.options.xAxisTitle)); - } - plotArea.appendChild(catAx); - - const valAx = Util.createElement(doc, 'c:valAx'); - valAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); - const valScaling = Util.createElement(doc, 'c:scaling'); - valScaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); - valAx.appendChild(valScaling); - valAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); - valAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'l']])); - valAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(axIdCat)]])); - valAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); - valAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); - if (this.options.yAxisTitle) { - valAx.appendChild(this._createTitleNode(doc, this.options.yAxisTitle)); - } - plotArea.appendChild(valAx); + plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, this.options.xAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', this.options.yAxisTitle)); } } @@ -322,4 +266,36 @@ export class Chart extends Drawing { title.appendChild(Util.createElement(doc, 'c:overlay', [['val', '0']])); return title; } + + /** Create a category axis (catAx) */ + private _createCategoryAxis(doc: XMLDOM, axId: number, crossAx: number, title?: string): XMLNode { + const catAx = Util.createElement(doc, 'c:catAx'); + catAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); + const scaling = Util.createElement(doc, 'c:scaling'); + scaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + catAx.appendChild(scaling); + catAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + catAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', 'b']])); + catAx.appendChild(Util.createElement(doc, 'c:tickLblPos', [['val', 'nextTo']])); + catAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); + catAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + if (title) catAx.appendChild(this._createTitleNode(doc, title)); + return catAx; + } + + /** Create a value axis (valAx) */ + private _createValueAxis(doc: XMLDOM, axId: number, crossAx: number, pos: 'l' | 'b', title?: string): XMLNode { + const valAx = Util.createElement(doc, 'c:valAx'); + valAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); + const scaling = Util.createElement(doc, 'c:scaling'); + scaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + valAx.appendChild(scaling); + valAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); + valAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', pos]])); + valAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); + valAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + valAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (title) valAx.appendChild(this._createTitleNode(doc, title)); + return valAx; + } } From eb2ed6b966da7784931a594a9f7f78dc0f58e9f3 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 17:41:11 -0400 Subject: [PATCH 08/17] chore: refactor axis props and add Stacked Charts (column/bar/line) --- packages/demo/src/examples/example18.ts | 39 ++++-- .../dist/index.d.ts | 28 ++++- .../src/Excel/Drawing/Chart.ts | 112 ++++++++++++------ .../src/Excel/Drawing/__tests__/Chart.spec.ts | 106 +++++++++++++++-- .../excel-builder-vanilla/src/interfaces.ts | 25 +++- 5 files changed, 245 insertions(+), 65 deletions(-) diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index 1558ba3..80d2d55 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -21,7 +21,13 @@ export default class Example18 { const wb = new Workbook(); // Helper: create a sheet that includes its own data table & a chart of given type - const createChartSheetWithLocalData = (type: 'column' | 'bar' | 'line' | 'pie' | 'scatter', sheetName: string) => { + const createChartSheetWithLocalData = ( + type: 'column' | 'bar' | 'line' | 'pie' | 'scatter', + sheetName: string, + stacking?: 'stacked' | 'percent', + ) => { + // Excel range sheet names with spaces or special chars must be quoted (e.g. 'Column Stacked'!$A$1) + const qSheet = /[\s%]/.test(sheetName) ? `'${sheetName}'` : sheetName; const ws = wb.createWorksheet({ name: sheetName }); let categoriesRange: string | undefined; let seriesDefs: { name: string; valuesRange: string; xValuesRange?: string }[] = []; @@ -32,16 +38,16 @@ export default class Example18 { const yVals = [12, 18, 34, 33, 50, 58, 72, 90]; ws.setData([['X', 'Y'], ...xVals.map((x, i) => [x, yVals[i]])]); wb.addWorksheet(ws); - const xRange = `${sheetName}!$A$2:$A$${xVals.length + 1}`; - const yRange = `${sheetName}!$B$2:$B$${yVals.length + 1}`; + const xRange = `${qSheet}!$A$2:$A$${xVals.length + 1}`; + const yRange = `${qSheet}!$B$2:$B$${yVals.length + 1}`; seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, xValuesRange: xRange }]; } else { // Use month/Q1/Q2 table for non-scatter charts ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); wb.addWorksheet(ws); - categoriesRange = `${sheetName}!$A$2:$A$${months.length + 1}`; - const q1Range = `${sheetName}!$B$2:$B$${months.length + 1}`; - const q2Range = `${sheetName}!$C$2:$C$${months.length + 1}`; + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + const q2Range = `${qSheet}!$C$2:$C$${months.length + 1}`; switch (type) { case 'pie': seriesDefs = [ @@ -61,9 +67,12 @@ export default class Example18 { const drawings = new Drawings(); const chart = new Chart({ type, - title: `${sheetName} (${type}) Chart`, - xAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', - yAxisTitle: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : 'Values', + stacking, + title: `${sheetName} (${type}${stacking ? ' ' + stacking : ''}) Chart`, + axis: { + x: { title: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month' }, + y: { title: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : 'Values' }, + }, // Reduced to ~80% of previous size (640x400 -> 512x320) width: 512 * 9525, height: 320 * 9525, @@ -82,13 +91,23 @@ export default class Example18 { wb.addChart(chart); }; - // Create one sheet per chart type with its own data + // Base chart types createChartSheetWithLocalData('column', 'Column'); // vertical column chart createChartSheetWithLocalData('bar', 'Bar'); // horizontal bar chart createChartSheetWithLocalData('line', 'Line'); createChartSheetWithLocalData('pie', 'Pie'); createChartSheetWithLocalData('scatter', 'Scatter'); + // Stacked variants (multi-series required for meaningful stack) + createChartSheetWithLocalData('column', 'Column Stacked', 'stacked'); + createChartSheetWithLocalData('bar', 'Bar Stacked', 'stacked'); + createChartSheetWithLocalData('line', 'Line Stacked', 'stacked'); + + // Percent stacked variants + createChartSheetWithLocalData('column', 'Column % Stacked', 'percent'); + createChartSheetWithLocalData('bar', 'Bar % Stacked', 'percent'); + createChartSheetWithLocalData('line', 'Line % Stacked', 'percent'); + // Export workbook (chart will be included if supported) downloadExcelFile(wb, 'Multiple-Charts.xlsx'); } diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 17b6938..fe47a4d 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -288,6 +288,17 @@ export interface ExcelStyleInstruction { style?: number; } export type ChartType = "column" | "bar" | "line" | "pie" | "scatter"; +/** Axis configuration options */ +export interface AxisOptions { + /** Axis title label */ + title?: string; + /** Explicit minimum value (value axis only; ignored for category axis unless future numeric category support) */ + minimum?: number; + /** Explicit maximum value (value axis only) */ + maximum?: number; + /** Show major gridlines */ + showGridLines?: boolean; +} export interface ChartSeriesRef { /** Series display name */ name: string; @@ -303,16 +314,21 @@ export interface ChartOptions { type?: ChartType; /** Chart title shown above plot area */ title?: string; - /** Category axis title (ignored for pie) */ - xAxisTitle?: string; - /** Value axis title (ignored for pie) */ - yAxisTitle?: string; + /** Axis configuration (ignored for pie except title for completeness) */ + axis?: { + /** Category/X axis options */ + x?: AxisOptions; + /** Value/Y axis options */ + y?: AxisOptions; + }; /** Width in EMUs */ width?: number; /** Height in EMUs */ height?: number; /** Categories range (for non-scatter) e.g. Sheet1!$A$2:$A$5 */ categoriesRange?: string; + /** Stacking mode for supported chart types (column, bar, line). 'stacked' for cumulative, 'percent' for 100% scaling. Undefined => no stacking */ + stacking?: "stacked" | "percent"; /** Multi-series cell references */ series?: ChartSeriesRef[]; } @@ -410,6 +426,10 @@ export declare class Chart extends Drawing { private _nextAxisIdBase; /** Chart part XML: /xl/charts/chartN.xml */ toChartSpaceXML(): XMLDOM; + /** Create the primary chart node based on type and stacking */ + private _createPrimaryChartNode; + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping; /** Create a c:title node with minimal rich text required for Excel to render */ private _createTitleNode; /** Create a category axis (catAx) */ diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index d349656..24eabec 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -109,44 +109,11 @@ export class Chart extends Drawing { const axIdCat = axisBase + 1; const axIdVal = axisBase + 2; - // Default to vertical column chart if type omitted (Excel naming consistency). + // Default chart type const type = this.options.type || 'column'; // Categories range (shared across all non-scatter series when provided) const categoriesRange = this.options.categoriesRange || ''; - let primaryChartNode: XMLNode; - switch (type) { - case 'line': - primaryChartNode = Util.createElement(doc, 'c:lineChart'); - primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'standard']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); - break; - case 'pie': - primaryChartNode = Util.createElement(doc, 'c:pieChart'); - primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); - // Pie charts typically set varyColors=1 so each slice gets a distinct fill. - primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); - break; - case 'scatter': - primaryChartNode = Util.createElement(doc, 'c:scatterChart'); - primaryChartNode.appendChild(Util.createElement(doc, 'c:scatterStyle', [['val', 'marker']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); - break; - case 'bar': - // Horizontal bar chart - primaryChartNode = Util.createElement(doc, 'c:barChart'); - primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'bar']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); - break; - case 'column': - default: - // Vertical column chart (previous 'bar' behavior) - primaryChartNode = Util.createElement(doc, 'c:barChart'); - primaryChartNode.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'col']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); - primaryChartNode.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); - break; - } + const primaryChartNode = this._createPrimaryChartNode(doc, type, this.options.stacking); // Lean chart XML (no fallback shorthand or data cache snapshots) @@ -219,12 +186,14 @@ export class Chart extends Drawing { plotArea.appendChild(primaryChartNode); if (type !== 'pie') { + const xAxisTitle = this.options.axis?.x?.title; + const yAxisTitle = this.options.axis?.y?.title; if (type === 'scatter') { - plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', this.options.xAxisTitle)); - plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', this.options.yAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', xAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle)); } else { - plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, this.options.xAxisTitle)); - plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', this.options.yAxisTitle)); + plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, xAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle)); } } @@ -242,6 +211,71 @@ export class Chart extends Drawing { chartSpace.appendChild(Util.createElement(doc, 'c:printSettings')); return doc; } + /** Create the primary chart node based on type and stacking */ + private _createPrimaryChartNode(doc: XMLDOM, type: string, stacking?: 'stacked' | 'percent'): XMLNode { + let node: XMLNode; + const groupingValue = this._resolveGrouping(type, stacking); + switch (type) { + case 'line': { + node = Util.createElement(doc, 'c:lineChart'); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'pie': { + node = Util.createElement(doc, 'c:pieChart'); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); + break; + } + case 'scatter': { + node = Util.createElement(doc, 'c:scatterChart'); + node.appendChild(Util.createElement(doc, 'c:scatterStyle', [['val', 'marker']])); + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'bar': { + node = Util.createElement(doc, 'c:barChart'); + node.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'bar']])); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + if (stacking) { + // Ensure stacked bars/columns align in same category slot + node.appendChild(Util.createElement(doc, 'c:overlap', [['val', '100']])); + } + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + case 'column': + default: { + node = Util.createElement(doc, 'c:barChart'); + node.appendChild(Util.createElement(doc, 'c:barDir', [['val', 'col']])); + node.appendChild(Util.createElement(doc, 'c:grouping', [['val', groupingValue]])); + if (stacking) { + node.appendChild(Util.createElement(doc, 'c:overlap', [['val', '100']])); + } + node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '0']])); + break; + } + } + return node; + } + + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping(type: string, stacking?: 'stacked' | 'percent'): string { + if (type === 'pie') return 'clustered'; // required but cosmetic for pie + if (type === 'line') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'standard'; + } + if (type === 'bar' || type === 'column') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'clustered'; + } + // scatter doesn't use grouping; still return default for structural consistency + return 'standard'; + } /** Create a c:title node with minimal rich text required for Excel to render */ private _createTitleNode(doc: XMLDOM, text: string): XMLNode { diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index db0695b..1e0e1e5 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -88,8 +88,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'line', title: 'Line', - xAxisTitle: 'Months', - yAxisTitle: 'Values', + axis: { x: { title: 'Months' }, y: { title: 'Values' } }, series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], categoriesRange: 'Sheet!$A$2:$A$4', }); @@ -104,8 +103,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'pie', title: 'Pie', - xAxisTitle: 'ShouldNotShow', - yAxisTitle: 'ShouldNotShow', + axis: { x: { title: 'ShouldNotShow' }, y: { title: 'ShouldNotShow' } }, series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4' }], categoriesRange: 'Sheet!$A$2:$A$4', }); @@ -203,8 +201,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'scatter', title: 'Scatter With Axis Titles', - xAxisTitle: 'X Axis', - yAxisTitle: 'Y Axis', + axis: { x: { title: 'X Axis' }, y: { title: 'Y Axis' } }, series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', xValuesRange: 'Sheet!$A$2:$A$4' }], }); // Expect 3 title nodes: chart + x axis + y axis @@ -307,7 +304,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'column', title: 'Bar Single X', - xAxisTitle: 'Only X', + axis: { x: { title: 'Only X' } }, series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', }); @@ -320,7 +317,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'line', title: 'Line Single Y', - yAxisTitle: 'Only Y', + axis: { y: { title: 'Only Y' } }, series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], categoriesRange: 'S!$A$2:$A$4', }); @@ -474,4 +471,97 @@ describe('Chart', () => { // Data cache tests // ----------------- // Removed data cache tests due to API minimization (no includeDataCache, no fallback arrays) + + // ----------------- + // Stacking tests + // ----------------- + it('column stacked chart uses grouping stacked and overlap 100', () => { + const { xml } = buildChart({ + type: 'column', + stacking: 'stacked', + title: 'Column Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + stacking: 'percent', + title: 'Column % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + stacking: 'stacked', + title: 'Bar Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'bar', + stacking: 'percent', + title: 'Bar % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + stacking: 'stacked', + title: 'Line Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + stacking: 'percent', + title: 'Line % Stacked', + series: [ + { name: 'Q1', valuesRange: 'Sheet!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sheet!$C$2:$C$4' }, + ], + categoriesRange: 'Sheet!$A$2:$A$4', + }); + expect(xml).toContain(' no stacking */ + stacking?: 'stacked' | 'percent'; /** Multi-series cell references */ series?: ChartSeriesRef[]; } From c83f1be3becc44cbdfaa7dfd2d76d7ab66dc83b4 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 18:25:43 -0400 Subject: [PATCH 09/17] chore: refactor axis w/new props & use them (title, min, max, gridLine) --- docs/inserting-charts.md | 43 +++++++++++++---- packages/demo/src/examples/example18.html | 23 ++++++++- packages/demo/src/examples/example18.ts | 22 ++++++--- .../src/Excel/Drawing/Chart.ts | 37 ++++++++++---- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 48 +++++++++++++++++++ 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 1529d0b..2d2cf14 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -18,8 +18,12 @@ Add charts to a workbook: create data, create a chart, add it, position it. That |--------|---------|-------| | type | `column` | `bar` | `line` | `pie` | `scatter` | Defaults to `column` | | title | Chart title | Omit for none | -| xAxisTitle | X axis label | Ignored for pie | -| yAxisTitle | Y axis label | Ignored for pie | +| axis.x.title | X axis label | Ignored for pie | +| axis.y.title | Y axis label | Ignored for pie | +| axis.x.showGridLines | Show vertical gridlines | Category axis (non-pie) | +| axis.y.showGridLines | Show horizontal gridlines | Value axis (non-pie) | +| axis.y.minimum / axis.y.maximum | Force value axis bounds | Optional (numeric) | +| stacking | 'stacked' | 'percent' | Stacks series (column/bar/line) | | width / height | Size override | Defaults used if omitted | | categoriesRange | Category labels range | Skip for scatter when using `xValuesRange` | | series | Array of `{ name, valuesRange }` | 2+ series => legend | @@ -40,8 +44,10 @@ ws.addRow(['Mar', 30, 35]); const chart = new Chart({ type: 'column', title: 'Quarterly Sales', - xAxisTitle: 'Month', - yAxisTitle: 'Revenue', + axis: { + x: { title: 'Month' }, + y: { title: 'Revenue', minimum: 0, showGridLines: true }, + }, series: [ { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, @@ -75,8 +81,7 @@ wb.addChart(barChart); const lineChart = new Chart({ type: 'line', title: 'Revenue Trend', - xAxisTitle: 'Month', - yAxisTitle: 'Total', + axis: { x: { title: 'Month' }, y: { title: 'Total', showGridLines: true } }, series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }], categoriesRange: 'Sales!$A$2:$A$13', }); @@ -100,8 +105,7 @@ Provide both X and Y value ranges (numeric): (less common, placed last) const scatter = new Chart({ type: 'scatter', title: 'Distance vs Speed', - xAxisTitle: 'Distance', - yAxisTitle: 'Speed', + axis: { x: { title: 'Distance' }, y: { title: 'Speed' } }, series: [{ name: 'Run A', xValuesRange: 'Runs!$A$2:$A$11', @@ -144,6 +148,7 @@ Example (legend will show 2 entries): new Chart({ type: 'bar', title: 'Year Comparison', + axis: { x: { title: 'Month' }, y: { title: 'Revenue' } }, series: [ { name: '2024', valuesRange: 'Sales!$B$2:$B$5' }, { name: '2025', valuesRange: 'Sales!$C$2:$C$5' }, @@ -164,6 +169,7 @@ new Chart({ ```ts const simple = new Chart({ type: 'bar', + axis: { y: { minimum: 0 } }, series: [{ name: 'Sales', valuesRange: 'Sales!$B$2:$B$4' }], categoriesRange: 'Sales!$A$2:$A$4', }); @@ -171,3 +177,24 @@ wb.addChart(simple); ``` That's it — build your workbook and open in Excel. + +### Stacked & Percent Stacked + +Enable stacking on multi-series column, bar, or line charts: +```ts +new Chart({ + type: 'column', + stacking: 'stacked', // or 'percent' + axis: { x: { title: 'Month' }, y: { title: 'Revenue', minimum: 0, showGridLines: true } }, + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, + ], + categoriesRange: 'Sales!$A$2:$A$4', +}); +``` + +Notes: +- Stacking ignored for pie & scatter. +- Percent stacking displays proportional contribution (0–100%). +- Overlap is automatically set for stacked column/bar to align segments. diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html index 41ea71c..58c600b 100644 --- a/packages/demo/src/examples/example18.html +++ b/packages/demo/src/examples/example18.html @@ -18,7 +18,9 @@

-
Create a simple bar chart and export as Excel file.
+
+ Create multiple chart types (column, bar, line, pie, scatter + stacked & percent stacked variants) and export them to an Excel file. +
@@ -30,7 +32,7 @@

-
+
@@ -58,6 +60,23 @@

+
+
Charts Created:
+
    +
  • Column
  • +
  • Bar
  • +
  • Line
  • +
  • Pie
  • +
  • Scatter
  • +
  • Column Stacked
  • +
  • Bar Stacked
  • +
  • Line Stacked
  • +
  • Column % Stacked
  • +
  • Bar % Stacked
  • +
  • Line % Stacked
  • +
+

Each item becomes a worksheet with its own data table and chart.

+
diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index 80d2d55..98e5b7d 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -70,19 +70,29 @@ export default class Example18 { stacking, title: `${sheetName} (${type}${stacking ? ' ' + stacking : ''}) Chart`, axis: { - x: { title: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month' }, - y: { title: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : 'Values' }, + x: { + title: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', + // Show gridlines only on line & percent stacked line charts for demo + showGridLines: sheetName.includes('Line') && !sheetName.includes('Bar'), + }, + y: { + title: type === 'pie' ? undefined : type === 'scatter' ? 'Y Values' : sheetName.includes('% Stacked') ? 'Percent' : 'Values', + // Use 0 baseline for stacked & percent stacked; cap percent stacks at 1 + minimum: sheetName.includes('Stacked') ? 0 : undefined, + maximum: sheetName.includes('% Stacked') ? 1 : undefined, + showGridLines: sheetName.includes('Column') || sheetName.includes('Line % Stacked'), + }, }, - // Reduced to ~80% of previous size (640x400 -> 512x320) - width: 512 * 9525, - height: 320 * 9525, + // Further reduced by an additional 10% (was 512x320 -> now ~460x288) + width: 460 * 9525, + height: 288 * 9525, categoriesRange, series: seriesDefs, }); const anchor = chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, // start Chart at E2 cell - to: { x: 15, y: 30 }, // end column chosen to preserve approximate chart width + to: { x: 14, y: 28 }, // adjusted end cell to reflect 10% smaller chart footprint }); chart.anchor = anchor; drawings.addDrawing(chart); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 24eabec..1e175d8 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -186,14 +186,16 @@ export class Chart extends Drawing { plotArea.appendChild(primaryChartNode); if (type !== 'pie') { - const xAxisTitle = this.options.axis?.x?.title; - const yAxisTitle = this.options.axis?.y?.title; + const xAxisOpts = this.options.axis?.x; + const yAxisOpts = this.options.axis?.y; + const xAxisTitle = xAxisOpts?.title; + const yAxisTitle = yAxisOpts?.title; if (type === 'scatter') { - plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', xAxisTitle)); - plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle)); + plotArea.appendChild(this._createValueAxis(doc, axIdCat, axIdVal, 'b', xAxisTitle, xAxisOpts)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); } else { - plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, xAxisTitle)); - plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle)); + plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, xAxisTitle, xAxisOpts)); + plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); } } @@ -302,7 +304,7 @@ export class Chart extends Drawing { } /** Create a category axis (catAx) */ - private _createCategoryAxis(doc: XMLDOM, axId: number, crossAx: number, title?: string): XMLNode { + private _createCategoryAxis(doc: XMLDOM, axId: number, crossAx: number, title?: string, opts?: { showGridLines?: boolean }): XMLNode { const catAx = Util.createElement(doc, 'c:catAx'); catAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); const scaling = Util.createElement(doc, 'c:scaling'); @@ -313,22 +315,41 @@ export class Chart extends Drawing { catAx.appendChild(Util.createElement(doc, 'c:tickLblPos', [['val', 'nextTo']])); catAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); catAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); + if (opts?.showGridLines) { + catAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); + } if (title) catAx.appendChild(this._createTitleNode(doc, title)); return catAx; } /** Create a value axis (valAx) */ - private _createValueAxis(doc: XMLDOM, axId: number, crossAx: number, pos: 'l' | 'b', title?: string): XMLNode { + private _createValueAxis( + doc: XMLDOM, + axId: number, + crossAx: number, + pos: 'l' | 'b', + title?: string, + opts?: { minimum?: number; maximum?: number; showGridLines?: boolean }, + ): XMLNode { const valAx = Util.createElement(doc, 'c:valAx'); valAx.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axId)]])); const scaling = Util.createElement(doc, 'c:scaling'); scaling.appendChild(Util.createElement(doc, 'c:orientation', [['val', 'minMax']])); + if (typeof opts?.minimum === 'number') { + scaling.appendChild(Util.createElement(doc, 'c:min', [['val', String(opts.minimum)]])); + } + if (typeof opts?.maximum === 'number') { + scaling.appendChild(Util.createElement(doc, 'c:max', [['val', String(opts.maximum)]])); + } valAx.appendChild(scaling); valAx.appendChild(Util.createElement(doc, 'c:delete', [['val', '0']])); valAx.appendChild(Util.createElement(doc, 'c:axPos', [['val', pos]])); valAx.appendChild(Util.createElement(doc, 'c:crossAx', [['val', String(crossAx)]])); valAx.appendChild(Util.createElement(doc, 'c:crosses', [['val', 'autoZero']])); valAx.appendChild(Util.createElement(doc, 'c:crossBetween', [['val', 'between']])); + if (opts?.showGridLines) { + valAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); + } if (title) valAx.appendChild(this._createTitleNode(doc, title)); return valAx; } diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 1e0e1e5..cda2136 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -452,6 +452,54 @@ describe('Chart', () => { expect(titleSegment?.[0]).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Axis MinMax', + axis: { y: { minimum: 0, maximum: 500 } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect c:min and c:max under scaling + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Axis Max Only', + axis: { y: { maximum: 300 } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Cat Gridlines', + axis: { x: { showGridLines: true }, y: { showGridLines: true } }, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect two majorGridlines nodes (one for category axis, one for value axis) + const gridCount = xml.split(' { + const { xml } = buildChart({ + type: 'line', + title: 'No Gridlines', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(' { const chart = new Chart({ type: 'column', From 5c8a20a4c084bce04384eaf9042e9c99f23f2dfa Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 18:30:14 -0400 Subject: [PATCH 10/17] chore: add Doughtnut Charts --- docs/inserting-charts.md | 243 ++++++++++++++---- packages/demo/src/examples/example18.html | 11 +- packages/demo/src/examples/example18.ts | 57 ++-- packages/demo/src/images/charts.png | Bin 0 -> 40526 bytes .../dist/index.d.ts | 2 +- .../src/Excel/Drawing/Chart.ts | 14 +- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 15 ++ .../excel-builder-vanilla/src/interfaces.ts | 2 +- 8 files changed, 260 insertions(+), 84 deletions(-) create mode 100644 packages/demo/src/images/charts.png diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 2d2cf14..a8892ba 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -3,7 +3,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That's all—just practical usage. ### Supported types -`column` (vertical clustered), `bar` (horizontal), `line`, `pie`, `scatter` +`column` (vertical clustered), `bar` (horizontal), `line`, `pie`, `doughnut`, `scatter` ### Core steps 1. Create a workbook & worksheet @@ -16,7 +16,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That ### Option summary (ChartOptions) | Option | Purpose | Notes | |--------|---------|-------| -| type | `column` | `bar` | `line` | `pie` | `scatter` | Defaults to `column` | +| type | `column` | `bar` | `line` | `pie` | `doughnut` | `scatter` | Defaults to `column` | | title | Chart title | Omit for none | | axis.x.title | X axis label | Ignored for pie | | axis.y.title | Y axis label | Ignored for pie | @@ -62,58 +62,7 @@ ws.addDrawings(drawings.addDrawing(chart)); // or add drawings first then the ch await wb.generateFiles(); ``` -### Horizontal bar chart -```ts -const barChart = new Chart({ - type: 'bar', - title: 'Revenue (Horizontal Bar)', - series: [ - { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, - { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, - ], - categoriesRange: 'Sales!$A$2:$A$4', -}); -wb.addChart(barChart); -``` - -### Line chart (with axis titles) -```ts -const lineChart = new Chart({ - type: 'line', - title: 'Revenue Trend', - axis: { x: { title: 'Month' }, y: { title: 'Total', showGridLines: true } }, - series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }], - categoriesRange: 'Sales!$A$2:$A$13', -}); -wb.addChart(lineChart); -``` - -### Pie chart -```ts -const pie = new Chart({ - type: 'pie', - title: 'Share by Region', - series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], - categoriesRange: 'Regions!$A$2:$A$6', -}); -wb.addChart(pie); -``` - -### Scatter chart -Provide both X and Y value ranges (numeric): (less common, placed last) -```ts -const scatter = new Chart({ - type: 'scatter', - title: 'Distance vs Speed', - axis: { x: { title: 'Distance' }, y: { title: 'Speed' } }, - series: [{ - name: 'Run A', - xValuesRange: 'Runs!$A$2:$A$11', - valuesRange: 'Runs!$B$2:$B$11', - }], -}); -wb.addChart(scatter); -``` + ## Resizing (width & height) @@ -141,7 +90,7 @@ The legend only appears when the chart has two or more series. - 2+ series: legend lists each `series.name`. Notes: -- Pie: if you add multiple series you get multiple pies; the legend shows the series names. +- Pie / Doughnut: if you add multiple series you get multiple rings (doughnut) or pies; the legend shows the series names. Example (legend will show 2 entries): ```ts @@ -195,6 +144,188 @@ new Chart({ ``` Notes: -- Stacking ignored for pie & scatter. +- Stacking ignored for: doughnut, pie & scatter - Percent stacking displays proportional contribution (0–100%). - Overlap is automatically set for stacked column/bar to align segments. + +--- + +## Chart Type Examples + +Below are minimal, focused examples for each supported chart type. They assume you have already created a workbook `wb`, added a worksheet `ws` with suitable data, and added that worksheet to the workbook. Only the chart-specific parts are shown. + +#### Column +```ts +const col = new Chart({ + type: 'column', + title: 'Monthly Revenue', + axis: { x: { title: 'Month' }, y: { title: 'Amount', minimum: 0, showGridLines: true } }, + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(col); +``` + +#### Bar (horizontal) +```ts +const bar = new Chart({ + type: 'bar', + title: 'Monthly Revenue (Horizontal)', + series: [ + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$7' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$7' }, + ], + categoriesRange: 'Sales!$A$2:$A$7', +}); +wb.addChart(bar); +``` + +#### Line +```ts +const line = new Chart({ + type: 'line', + title: 'Trend', + axis: { x: { title: 'Month' }, y: { title: 'Value', showGridLines: true } }, + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(line); +``` + +#### Pie (single series for one pie) +```ts +const pie = new Chart({ + type: 'pie', + title: 'Share by Region', + series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], + categoriesRange: 'Regions!$A$2:$A$6', +}); +wb.addChart(pie); +``` + +#### Doughnut (single series for one ring) +```ts +const doughnut = new Chart({ + type: 'doughnut', + title: 'Share by Category', + series: [{ name: '2025', valuesRange: 'Categories!$B$2:$B$6' }], + categoriesRange: 'Categories!$A$2:$A$6', +}); +wb.addChart(doughnut); +``` + +#### Scatter (X/Y numeric ranges) +```ts +const scatter = new Chart({ + type: 'scatter', + title: 'Distance vs Speed', + axis: { x: { title: 'Distance' }, y: { title: 'Speed' } }, + series: [{ + name: 'Run A', + xValuesRange: 'Runs!$A$2:$A$21', + valuesRange: 'Runs!$B$2:$B$21', + }], +}); +wb.addChart(scatter); +``` + +#### Column Stacked +```ts +const colStacked = new Chart({ + type: 'column', + stacking: 'stacked', + title: 'Stacked Revenue', + axis: { x: { title: 'Month' }, y: { title: 'Total', minimum: 0, showGridLines: true } }, + series: [ + { name: 'Product A', valuesRange: 'Sales!$B$2:$B$13' }, + { name: 'Product B', valuesRange: 'Sales!$C$2:$C$13' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(colStacked); +``` + +#### Column Percent Stacked +```ts +const colPct = new Chart({ + type: 'column', + stacking: 'percent', + title: 'Product Mix %', + axis: { x: { title: 'Month' }, y: { title: 'Percent', minimum: 0, maximum: 1, showGridLines: true } }, + series: [ + { name: 'Product A', valuesRange: 'Sales!$B$2:$B$13' }, + { name: 'Product B', valuesRange: 'Sales!$C$2:$C$13' }, + ], + categoriesRange: 'Sales!$A$2:$A$13', +}); +wb.addChart(colPct); +``` + +#### Line Stacked +```ts +const lineStacked = new Chart({ + type: 'line', + stacking: 'stacked', + title: 'Cumulative Trend', + axis: { x: { title: 'Month' }, y: { title: 'Total', minimum: 0 } }, + series: [ + { name: 'North', valuesRange: 'Regions!$B$2:$B$13' }, + { name: 'South', valuesRange: 'Regions!$C$2:$C$13' }, + ], + categoriesRange: 'Regions!$A$2:$A$13', +}); +wb.addChart(lineStacked); +``` + +#### Line Percent Stacked +```ts +const linePct = new Chart({ + type: 'line', + stacking: 'percent', + title: 'Regional Contribution %', + axis: { x: { title: 'Month' }, y: { title: 'Percent', minimum: 0, maximum: 1 } }, + series: [ + { name: 'North', valuesRange: 'Regions!$B$2:$B$13' }, + { name: 'South', valuesRange: 'Regions!$C$2:$C$13' }, + ], + categoriesRange: 'Regions!$A$2:$A$13', +}); +wb.addChart(linePct); +``` + +#### Bar Stacked +```ts +const barStacked = new Chart({ + type: 'bar', + stacking: 'stacked', + title: 'Stacked Horizontal', + series: [ + { name: 'Segment A', valuesRange: 'Segments!$B$2:$B$10' }, + { name: 'Segment B', valuesRange: 'Segments!$C$2:$C$10' }, + ], + categoriesRange: 'Segments!$A$2:$A$10', +}); +wb.addChart(barStacked); +``` + +#### Bar Percent Stacked +```ts +const barPct = new Chart({ + type: 'bar', + stacking: 'percent', + title: 'Segment Share %', + axis: { y: { minimum: 0, maximum: 1 } }, + series: [ + { name: 'Segment A', valuesRange: 'Segments!$B$2:$B$10' }, + { name: 'Segment B', valuesRange: 'Segments!$C$2:$C$10' }, + ], + categoriesRange: 'Segments!$A$2:$A$10', +}); +wb.addChart(barPct); +``` + +--- +End of chart type examples. diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html index 58c600b..ab66bc3 100644 --- a/packages/demo/src/examples/example18.html +++ b/packages/demo/src/examples/example18.html @@ -19,7 +19,8 @@

- Create multiple chart types (column, bar, line, pie, scatter + stacked & percent stacked variants) and export them to an Excel file. + Create multiple chart types (column, bar, line, pie, doughnut, scatter + stacked & percent stacked variants) and export them to an + Excel file.
@@ -59,6 +60,13 @@

+
+
Excel Preview (single sheet example)
+

+ This screenshot shows one chart sheet only. The exported workbook includes every chart listed on the right. +

+ +
Charts Created:
@@ -67,6 +75,7 @@
Charts Created:
  • Bar
  • Line
  • Pie
  • +
  • Doughnut
  • Scatter
  • Column Stacked
  • Bar Stacked
  • diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index 98e5b7d..b7c3c4b 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -1,4 +1,5 @@ -import { Chart, Drawings, downloadExcelFile, Workbook } from 'excel-builder-vanilla'; +import { Chart, type ChartType, Drawings, downloadExcelFile, Workbook } from 'excel-builder-vanilla'; +import chartUrl from '../images/charts.png?url'; export default class Example18 { exportBtnElm!: HTMLButtonElement; @@ -6,6 +7,13 @@ export default class Example18 { mount() { this.exportBtnElm = document.querySelector('#export-chart') as HTMLButtonElement; this.exportBtnElm.addEventListener('click', this.startProcess.bind(this)); + // If an image placeholder exists, set its src (Vite will resolve the imported URL) + const imgElm = document.querySelector('#chart-screenshot'); + if (imgElm) { + imgElm.src = chartUrl; + imgElm.alt = 'Exported Excel charts screenshot'; + imgElm.loading = 'lazy'; + } } unmount() { @@ -21,11 +29,7 @@ export default class Example18 { const wb = new Workbook(); // Helper: create a sheet that includes its own data table & a chart of given type - const createChartSheetWithLocalData = ( - type: 'column' | 'bar' | 'line' | 'pie' | 'scatter', - sheetName: string, - stacking?: 'stacked' | 'percent', - ) => { + const createChartSheetWithLocalData = (type: ChartType, sheetName: string, stacking?: 'stacked' | 'percent') => { // Excel range sheet names with spaces or special chars must be quoted (e.g. 'Column Stacked'!$A$1) const qSheet = /[\s%]/.test(sheetName) ? `'${sheetName}'` : sheetName; const ws = wb.createWorksheet({ name: sheetName }); @@ -42,25 +46,33 @@ export default class Example18 { const yRange = `${qSheet}!$B$2:$B$${yVals.length + 1}`; seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, xValuesRange: xRange }]; } else { - // Use month/Q1/Q2 table for non-scatter charts - ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); - wb.addWorksheet(ws); - categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; - const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; - const q2Range = `${qSheet}!$C$2:$C$${months.length + 1}`; - switch (type) { - case 'pie': - seriesDefs = [ - { name: 'Q1', valuesRange: q1Range }, - { name: 'Q2', valuesRange: q2Range }, - ]; - break; - default: + // Use month/Q1/Q2 table for most non-scatter charts. + // Doughnut: intentionally single-series to avoid visual confusion (multi-series would render concentric rings) + if (type === 'doughnut') { + ws.setData([['Month', 'Q1'], ...months.map((m, i) => [m, q1[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + seriesDefs = [{ name: 'Q1', valuesRange: q1Range }]; + } else { + if (type === 'pie') { + // Single-series pie (Q1 only) + ws.setData([['Month', 'Q1'], ...months.map((m, i) => [m, q1[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + seriesDefs = [{ name: 'Q1', valuesRange: q1Range }]; + } else { + ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); + wb.addWorksheet(ws); + categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; + const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; + const q2Range = `${qSheet}!$C$2:$C$${months.length + 1}`; seriesDefs = [ { name: 'Q1', valuesRange: q1Range }, { name: 'Q2', valuesRange: q2Range }, ]; - break; + } } } @@ -68,7 +80,7 @@ export default class Example18 { const chart = new Chart({ type, stacking, - title: `${sheetName} (${type}${stacking ? ' ' + stacking : ''}) Chart`, + title: `${sheetName} (${type}${stacking ? ` ${stacking}` : ''}) Chart`, axis: { x: { title: type === 'pie' ? undefined : type === 'scatter' ? 'X Values' : 'Month', @@ -106,6 +118,7 @@ export default class Example18 { createChartSheetWithLocalData('bar', 'Bar'); // horizontal bar chart createChartSheetWithLocalData('line', 'Line'); createChartSheetWithLocalData('pie', 'Pie'); + createChartSheetWithLocalData('doughnut', 'Doughnut'); createChartSheetWithLocalData('scatter', 'Scatter'); // Stacked variants (multi-series required for meaningful stack) diff --git a/packages/demo/src/images/charts.png b/packages/demo/src/images/charts.png new file mode 100644 index 0000000000000000000000000000000000000000..298255ec11cd4df68fc209000e680ddeff1a6038 GIT binary patch literal 40526 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 { expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'doughnut', + title: 'Doughnut Attr', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { const { xml } = buildChart({ type: 'scatter', diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index a9e33ab..bece97f 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -145,7 +145,7 @@ export interface ExcelStyleInstruction { // --------------------------- // Chart related interfaces // --------------------------- -export type ChartType = 'column' | 'bar' | 'line' | 'pie' | 'scatter'; +export type ChartType = 'column' | 'bar' | 'line' | 'pie' | 'doughnut' | 'scatter'; /** Axis configuration options */ export interface AxisOptions { From f243659e4b62f9dc8a3ab0d7db895e7cc0959a5f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 18:43:35 -0400 Subject: [PATCH 11/17] chore: rename xValuesRange to scatterXRange since it's only for scatter --- docs/inserting-charts.md | 6 +++--- packages/demo/src/examples/example18.ts | 4 ++-- packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts | 4 ++-- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 8 ++++---- packages/excel-builder-vanilla/src/interfaces.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index a8892ba..f005596 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -25,9 +25,9 @@ Add charts to a workbook: create data, create a chart, add it, position it. That | axis.y.minimum / axis.y.maximum | Force value axis bounds | Optional (numeric) | | stacking | 'stacked' | 'percent' | Stacks series (column/bar/line) | | width / height | Size override | Defaults used if omitted | -| categoriesRange | Category labels range | Skip for scatter when using `xValuesRange` | +| categoriesRange | Category labels range | Skip for scatter when using `scatterXRange` | | series | Array of `{ name, valuesRange }` | 2+ series => legend | -| series[].xValuesRange | Scatter X values range | Only for scatter | +| series[].scatterXRange | Scatter X values range | Only for scatter | ### Quick start (multi‑series column chart) @@ -225,7 +225,7 @@ const scatter = new Chart({ axis: { x: { title: 'Distance' }, y: { title: 'Speed' } }, series: [{ name: 'Run A', - xValuesRange: 'Runs!$A$2:$A$21', + scatterXRange: 'Runs!$A$2:$A$21', valuesRange: 'Runs!$B$2:$B$21', }], }); diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index b7c3c4b..a9eef4b 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -34,7 +34,7 @@ export default class Example18 { const qSheet = /[\s%]/.test(sheetName) ? `'${sheetName}'` : sheetName; const ws = wb.createWorksheet({ name: sheetName }); let categoriesRange: string | undefined; - let seriesDefs: { name: string; valuesRange: string; xValuesRange?: string }[] = []; + let seriesDefs: { name: string; valuesRange: string; scatterXRange?: string }[] = []; if (type === 'scatter') { // Provide a richer numeric dataset for scatter (X,Y pairs) with 8 points @@ -44,7 +44,7 @@ export default class Example18 { wb.addWorksheet(ws); const xRange = `${qSheet}!$A$2:$A$${xVals.length + 1}`; const yRange = `${qSheet}!$B$2:$B$${yVals.length + 1}`; - seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, xValuesRange: xRange }]; + seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, scatterXRange: xRange }]; } else { // Use month/Q1/Q2 table for most non-scatter charts. // Doughnut: intentionally single-series to avoid visual confusion (multi-series would render concentric rings) diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 9c0689e..ca52993 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -132,10 +132,10 @@ export class Chart extends Drawing { if (type === 'scatter') { // Scatter uses xVal & yVal const xVal = Util.createElement(doc, 'c:xVal'); - if (s.xValuesRange) { + if (s.scatterXRange) { const numRefX = Util.createElement(doc, 'c:numRef'); const fNodeX = Util.createElement(doc, 'c:f'); - fNodeX.appendChild(doc.createTextNode(s.xValuesRange)); + fNodeX.appendChild(doc.createTextNode(s.scatterXRange)); numRefX.appendChild(fNodeX); xVal.appendChild(numRefX); } else { diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index b0d26f2..5612816 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -54,7 +54,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'scatter', title: 'Scatter Chart', - series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', xValuesRange: 'Sheet!$A$2:$A$4' }], + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', scatterXRange: 'Sheet!$A$2:$A$4' }], }); expect(xml).toContain(' { expect(xml).not.toContain(''); }); - it('scatter emits empty numLit xVal when xValuesRange missing', () => { + it('scatter emits empty numLit xVal when scatterXRange missing', () => { const { xml } = buildChart({ type: 'scatter', title: 'Scatter No X Range', @@ -202,7 +202,7 @@ describe('Chart', () => { type: 'scatter', title: 'Scatter With Axis Titles', axis: { x: { title: 'X Axis' }, y: { title: 'Y Axis' } }, - series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', xValuesRange: 'Sheet!$A$2:$A$4' }], + series: [{ name: 'S1', valuesRange: 'Sheet!$B$2:$B$4', scatterXRange: 'Sheet!$A$2:$A$4' }], }); // Expect 3 title nodes: chart + x axis + y axis const titleNodeCount = xml.split('').length - 1; @@ -285,7 +285,7 @@ describe('Chart', () => { const { xml } = buildChart({ type: 'scatter', title: 'Scatter Attr', - series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', xValuesRange: 'S!$A$2:$A$4' }], + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', scatterXRange: 'S!$A$2:$A$4' }], }); expect(xml).toContain(' Date: Wed, 22 Oct 2025 19:03:39 -0400 Subject: [PATCH 12/17] chore: add optional series color --- docs/inserting-charts.md | 25 ++++++-- packages/demo/src/examples/example18.ts | 31 ++++++++-- .../dist/index.d.ts | 8 ++- .../src/Excel/Drawing/Chart.ts | 34 +++++++++++ .../src/Excel/Drawing/__tests__/Chart.spec.ts | 59 +++++++++++++++++++ .../excel-builder-vanilla/src/interfaces.ts | 2 +- 6 files changed, 145 insertions(+), 14 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index f005596..3a5cf45 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -26,7 +26,7 @@ Add charts to a workbook: create data, create a chart, add it, position it. That | stacking | 'stacked' | 'percent' | Stacks series (column/bar/line) | | width / height | Size override | Defaults used if omitted | | categoriesRange | Category labels range | Skip for scatter when using `scatterXRange` | -| series | Array of `{ name, valuesRange }` | 2+ series => legend | +| series | Array of `{ name, valuesRange, color? }` | 2+ series => legend; `color` optional (opaque ARGB e.g. FF3366CC) | | series[].scatterXRange | Scatter X values range | Only for scatter | @@ -161,8 +161,8 @@ const col = new Chart({ title: 'Monthly Revenue', axis: { x: { title: 'Month' }, y: { title: 'Amount', minimum: 0, showGridLines: true } }, series: [ - { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }, - { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13' }, + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF3366CC' }, // ARGB + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13', color: 'FFFF9933' }, // ARGB ], categoriesRange: 'Sales!$A$2:$A$13', }); @@ -189,7 +189,7 @@ const line = new Chart({ type: 'line', title: 'Trend', axis: { x: { title: 'Month' }, y: { title: 'Value', showGridLines: true } }, - series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13' }], + series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF99CC00' }], categoriesRange: 'Sales!$A$2:$A$13', }); wb.addChart(line); @@ -227,6 +227,7 @@ const scatter = new Chart({ name: 'Run A', scatterXRange: 'Runs!$A$2:$A$21', valuesRange: 'Runs!$B$2:$B$21', + color: 'FFFF0000', // ARGB stroke color (opaque red) }], }); wb.addChart(scatter); @@ -329,3 +330,19 @@ wb.addChart(barPct); --- End of chart type examples. + +### Series Color Notes +See also the general color section in `fonts-and-colors.md`. + +Format: +- Opaque ARGB only: `FFRRGGBB` (e.g. `FF3366CC`). Use `FF` for fully opaque colors. + +Behavior: +- Column / Bar: sets a solid fill color. +- Line / Scatter: sets the stroke line color (markers, if/when added later, would share that color). +- Pie / Doughnut: series color is ignored; Excel auto-assigns slice colors (one series per pie/ring). + +Notes: +- Alpha channel (when other than `FF`) is currently ignored; colors render fully opaque. +- Invalid hex strings are silently ignored (no styling emitted). +- Theme-based colors (e.g. `{ theme: 2 }`) are not yet supported for charts—pass an ARGB/RGB string. diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index a9eef4b..e6130ec 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -34,7 +34,7 @@ export default class Example18 { const qSheet = /[\s%]/.test(sheetName) ? `'${sheetName}'` : sheetName; const ws = wb.createWorksheet({ name: sheetName }); let categoriesRange: string | undefined; - let seriesDefs: { name: string; valuesRange: string; scatterXRange?: string }[] = []; + let seriesDefs: { name: string; valuesRange: string; scatterXRange?: string; color?: string }[] = []; if (type === 'scatter') { // Provide a richer numeric dataset for scatter (X,Y pairs) with 8 points @@ -44,7 +44,14 @@ export default class Example18 { wb.addWorksheet(ws); const xRange = `${qSheet}!$A$2:$A$${xVals.length + 1}`; const yRange = `${qSheet}!$B$2:$B$${yVals.length + 1}`; - seriesDefs = [{ name: 'Y vs X', valuesRange: yRange, scatterXRange: xRange }]; + seriesDefs = [ + { + name: 'Y vs X', + valuesRange: yRange, + scatterXRange: xRange, + color: 'FFFF3333', // optional ARGB (FF opaque) stroke color (line/marker) for scatter + }, + ]; } else { // Use month/Q1/Q2 table for most non-scatter charts. // Doughnut: intentionally single-series to avoid visual confusion (multi-series would render concentric rings) @@ -53,7 +60,13 @@ export default class Example18 { wb.addWorksheet(ws); categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; - seriesDefs = [{ name: 'Q1', valuesRange: q1Range }]; + seriesDefs = [ + { + name: 'Q1', + valuesRange: q1Range, + // color intentionally omitted for doughnut: series color is ignored (Excel auto-colors slices) + }, + ]; } else { if (type === 'pie') { // Single-series pie (Q1 only) @@ -61,7 +74,13 @@ export default class Example18 { wb.addWorksheet(ws); categoriesRange = `${qSheet}!$A$2:$A$${months.length + 1}`; const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; - seriesDefs = [{ name: 'Q1', valuesRange: q1Range }]; + seriesDefs = [ + { + name: 'Q1', + valuesRange: q1Range, + // color intentionally omitted for pie to let Excel vary slice colors + }, + ]; } else { ws.setData([['Month', 'Q1', 'Q2'], ...months.map((m, i) => [m, q1[i], q2[i]])]); wb.addWorksheet(ws); @@ -69,8 +88,8 @@ export default class Example18 { const q1Range = `${qSheet}!$B$2:$B$${months.length + 1}`; const q2Range = `${qSheet}!$C$2:$C$${months.length + 1}`; seriesDefs = [ - { name: 'Q1', valuesRange: q1Range }, - { name: 'Q2', valuesRange: q2Range }, + { name: 'Q1', valuesRange: q1Range /* color: 'FF3366CC'*/ }, // ARGB (FF opaque) custom solid fill + { name: 'Q2', valuesRange: q2Range /* color: 'FFFF9933'*/ }, // ARGB (FF opaque) ]; } } diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 7c88aff..b8f79c0 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -304,10 +304,10 @@ export interface ChartSeriesRef { name: string; /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ valuesRange: string; - /** Hex ARGB or RGB color (e.g. FF0000 or FF0000FF) - currently cosmetic placeholder */ + /** Optional solid color for the series. Recommended: opaque ARGB `FFRRGGBB` (e.g. FF3366CC). RGB `RRGGBB` also accepted and treated as opaque. Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ color?: string; - /** For scatter charts: X axis values range */ - xValuesRange?: string; + /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ + scatterXRange?: string; } export interface ChartOptions { /** Chart type (defaults to 'column' if omitted) */ @@ -436,6 +436,8 @@ export declare class Chart extends Drawing { private _createCategoryAxis; /** Create a value axis (valAx) */ private _createValueAxis; + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor; } export type Relation = { [id: string]: { diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index ca52993..1fc9371 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -175,6 +175,9 @@ export class Chart extends Drawing { } } + // Optional per-series color (basic solid fill / line stroke) + this._applySeriesColor(doc, ser, type, s.color); + primaryChartNode.appendChild(ser); }); @@ -361,4 +364,35 @@ export class Chart extends Drawing { if (title) valAx.appendChild(this._createTitleNode(doc, title)); return valAx; } + + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor(doc: XMLDOM, serNode: XMLNode, type: string, color?: string) { + if (!color || typeof color !== 'string') return; + let hex = color.trim().replace(/^#/, '').toUpperCase(); + // Accept 6 (RGB) or 8 (ARGB) hex chars; strip leading alpha if present + if (/^[0-9A-F]{8}$/.test(hex)) { + hex = hex.slice(2); + } else if (!/^[0-9A-F]{6}$/.test(hex)) { + return; // invalid format; silently ignore + } + // Create spPr container + const spPr = Util.createElement(doc, 'c:spPr'); + if (type === 'line' || type === 'scatter') { + // For line/scatter charts define stroke color (ln) + const ln = Util.createElement(doc, 'a:ln'); + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + ln.appendChild(solidFill); + spPr.appendChild(ln); + } else if (type !== 'pie' && type !== 'doughnut') { + // For column/bar (and future types) define a solid fill + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + spPr.appendChild(solidFill); + } else { + // For pie/doughnut omit series-level color (Excel varies slice colors automatically) + return; + } + serNode.appendChild(spPr); + } } diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 5612816..1a0f391 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -492,6 +492,65 @@ describe('Chart', () => { expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Colored Column', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'FFFF0000' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + // Expect a:solidFill with srgbClr val="FF0000" (alpha stripped from ARGB FFFF0000) + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Colored Line', + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4', color: '80ABCDEF' }, // ARGB; expect ABCDEF + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'scatter', + title: 'Colored Scatter', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', scatterXRange: 'S!$A$2:$A$4', color: 'FF00FF00' }], + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Invalid Color', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'GARBAGE' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('does not emit series color styling for pie', () => { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie No Series Color', + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4', color: 'FF112233' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // Pie chart series should not contain c:spPr produced by our color logic + expect(xml).not.toContain(''); + }); + it('category axis renders majorGridlines when showGridLines true', () => { const { xml } = buildChart({ type: 'line', diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index bfe7d55..315644b 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -164,7 +164,7 @@ export interface ChartSeriesRef { name: string; /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ valuesRange: string; - /** Hex ARGB or RGB color (e.g. FF0000 or FF0000FF) - currently cosmetic placeholder */ + /** Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ color?: string; /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ scatterXRange?: string; From 81d98b2aa79f5b25f813836653959ed2f449fb1f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 19:17:18 -0400 Subject: [PATCH 13/17] chore: add `legend` prop --- docs/inserting-charts.md | 22 +++++-- packages/demo/src/examples/example18.ts | 10 +++- .../dist/index.d.ts | 13 ++++- .../src/Excel/Drawing/Chart.ts | 19 ++++++- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 57 +++++++++++++++++++ .../excel-builder-vanilla/src/interfaces.ts | 12 ++++ 6 files changed, 123 insertions(+), 10 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 3a5cf45..ea83cf3 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -84,15 +84,26 @@ chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } Values are column/row indices (0-based). ### Legend -The legend only appears when the chart has two or more series. +Auto behavior (no `legend` option provided): show legend only when there are 2 or more series. -- 1 series: legend is omitted automatically. -- 2+ series: legend lists each `series.name`. +You can override with the `legend` option: +```ts +legend: { + show: true, // force show (even for single series) | false to hide + position: 'topRight', // 'right' (default) | 'left' | 'top' | 'bottom' | 'topRight' + overlay: false, // true => overlay plot area (no layout space) +} +``` +Rules: +- `show: true` forces a legend even for 1 series. +- `show: false` suppresses legend even for multiple series. +- If `show` is undefined, auto mode (2+ series) applies. +- `overlay` emits `` when true; otherwise `0`. Notes: -- Pie / Doughnut: if you add multiple series you get multiple rings (doughnut) or pies; the legend shows the series names. +- Pie / Doughnut: adding multiple series produces multiple pies/rings; legend lists series names. -Example (legend will show 2 entries): +Example (legend will show 2 entries and be placed top-right): ```ts new Chart({ type: 'bar', @@ -103,6 +114,7 @@ new Chart({ { name: '2025', valuesRange: 'Sales!$C$2:$C$5' }, ], categoriesRange: 'Sales!$A$2:$A$5', + legend: { position: 'topRight' }, }); ``` diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index e6130ec..e54c535 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -96,6 +96,14 @@ export default class Example18 { } const drawings = new Drawings(); + const legendConfig = (() => { + // Demonstrate legend options selectively + if (type === 'pie') return { show: true, position: 'topRight' as const }; // force legend for single-series pie + if (sheetName === 'Column') return { position: 'topRight' as const }; // custom position (auto show since >1 series) + if (sheetName === 'Bar Stacked') return { overlay: true }; // overlay example + return undefined; + })(); + const chart = new Chart({ type, stacking, @@ -114,11 +122,11 @@ export default class Example18 { showGridLines: sheetName.includes('Column') || sheetName.includes('Line % Stacked'), }, }, - // Further reduced by an additional 10% (was 512x320 -> now ~460x288) width: 460 * 9525, height: 288 * 9525, categoriesRange, series: seriesDefs, + legend: legendConfig, }); const anchor = chart.createAnchor('twoCellAnchor', { diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index b8f79c0..81e31e4 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -304,11 +304,20 @@ export interface ChartSeriesRef { name: string; /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ valuesRange: string; - /** Optional solid color for the series. Recommended: opaque ARGB `FFRRGGBB` (e.g. FF3366CC). RGB `RRGGBB` also accepted and treated as opaque. Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ + /** Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ color?: string; /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ scatterXRange?: string; } +/** Legend configuration (minimal) */ +export interface LegendOptions { + /** Force show (true) or hide (false). If undefined, auto: show only when multiple series */ + show?: boolean; + /** Legend position (defaults to 'right' if omitted) */ + position?: "right" | "left" | "top" | "bottom" | "topRight"; + /** Overlay the legend on the plot area (no space reservation) */ + overlay?: boolean; +} export interface ChartOptions { /** Chart type (defaults to 'column' if omitted) */ type?: ChartType; @@ -331,6 +340,8 @@ export interface ChartOptions { stacking?: "stacked" | "percent"; /** Multi-series cell references */ series?: ChartSeriesRef[]; + /** Legend configuration */ + legend?: LegendOptions; } /** * @module Excel/Util diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 1fc9371..b6d1de5 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -202,11 +202,24 @@ export class Chart extends Drawing { } } - // Legend if multiple series - if ((this.options.series || []).length > 1) { + // Legend logic (configurable) + const legendOpts = this.options.legend; + const seriesCount = (this.options.series || []).length; + const autoShouldShow = seriesCount > 1; // previous behavior + const effectiveShow = typeof legendOpts?.show === 'boolean' ? legendOpts.show : autoShouldShow; + if (effectiveShow) { const legend = Util.createElement(doc, 'c:legend'); - legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', 'r']])); + // Map high-level position to OOXML codes + const posMap: Record = { right: 'r', left: 'l', top: 't', bottom: 'b', topRight: 'tr' }; + const pos = posMap[legendOpts?.position || 'right'] || 'r'; + legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', pos]])); legend.appendChild(Util.createElement(doc, 'c:layout')); + // Overlay (default 0) + if (legendOpts?.overlay) { + legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', '1']])); + } else { + legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', '0']])); + } chart.appendChild(legend); } diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 1a0f391..be169a3 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -686,4 +686,61 @@ describe('Chart', () => { expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'Force Legend', + legend: { show: true }, + series: [{ name: 'Only', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toContain(''); + }); + + it('legend.show false hides legend even for multiple series', () => { + const { xml } = buildChart({ + type: 'column', + title: 'Hide Legend', + legend: { show: false }, + series: [ + { name: 'A', valuesRange: 'S!$B$2:$B$4' }, + { name: 'B', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).not.toContain(''); + }); + + it('legend position maps to topRight', () => { + const { xml } = buildChart({ + type: 'line', + title: 'Legend Position', + legend: { show: true, position: 'topRight' }, + series: [ + { name: 'S1', valuesRange: 'S!$B$2:$B$4' }, + { name: 'S2', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + expect(xml).toMatch(/[\s\S]*? { + const { xml } = buildChart({ + type: 'bar', + title: 'Legend Overlay', + legend: { show: true, overlay: true }, + series: [ + { name: 'A', valuesRange: 'S!$B$2:$B$4' }, + { name: 'B', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + const legendSegment = xml.match(/[\s\S]*?<\/c:legend>/)?.[0]; + expect(legendSegment).toContain(' Date: Wed, 22 Oct 2025 19:56:43 -0400 Subject: [PATCH 14/17] chore: add `dataLabels` interface --- docs/inserting-charts.md | 151 ++++++++++++------ packages/demo/src/examples/example18.ts | 6 + packages/demo/src/images/charts.png | Bin 40526 -> 39119 bytes .../dist/index.d.ts | 11 ++ .../src/Excel/Drawing/Chart.ts | 14 ++ .../src/Excel/Drawing/__tests__/Chart.spec.ts | 54 +++++++ .../excel-builder-vanilla/src/interfaces.ts | 11 ++ 7 files changed, 200 insertions(+), 47 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index ea83cf3..8250777 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -1,6 +1,6 @@ ## Inserting charts -Add charts to a workbook: create data, create a chart, add it, position it. That's all—just practical usage. +Add charts to a workbook: add data, build a chart with cell ranges, position it. ### Supported types `column` (vertical clustered), `bar` (horizontal), `line`, `pie`, `doughnut`, `scatter` @@ -16,18 +16,17 @@ Add charts to a workbook: create data, create a chart, add it, position it. That ### Option summary (ChartOptions) | Option | Purpose | Notes | |--------|---------|-------| -| type | `column` | `bar` | `line` | `pie` | `doughnut` | `scatter` | Defaults to `column` | -| title | Chart title | Omit for none | -| axis.x.title | X axis label | Ignored for pie | -| axis.y.title | Y axis label | Ignored for pie | -| axis.x.showGridLines | Show vertical gridlines | Category axis (non-pie) | -| axis.y.showGridLines | Show horizontal gridlines | Value axis (non-pie) | -| axis.y.minimum / axis.y.maximum | Force value axis bounds | Optional (numeric) | -| stacking | 'stacked' | 'percent' | Stacks series (column/bar/line) | -| width / height | Size override | Defaults used if omitted | -| categoriesRange | Category labels range | Skip for scatter when using `scatterXRange` | -| series | Array of `{ name, valuesRange, color? }` | 2+ series => legend; `color` optional (opaque ARGB e.g. FF3366CC) | -| series[].scatterXRange | Scatter X values range | Only for scatter | +| type | Chart type | One of: column, bar, line, pie, doughnut, scatter (default: column) | +| title | Chart title | Omit for no title | +| axis.x.title / axis.y.title | Axis labels | Ignored for pie/doughnut | +| axis.x.showGridLines / axis.y.showGridLines | Gridlines toggles | x = vertical lines, y = horizontal lines | +| axis.y.minimum / axis.y.maximum | Value axis bounds | Numbers (e.g. 0, 1) | +| stacking | Stack series | 'stacked' or 'percent' (column / bar / line only) | +| width / height | Size (EMUs) | Usually omit (auto size) | +| categoriesRange | Category labels range | Not used by scatter (use scatterXRange instead) | +| series | Data series | Array of { name, valuesRange, color? } | +| series[].scatterXRange | X values (scatter) | Only for scatter charts | +| dataLabels | Point label toggles | { showValue?, showCategory?, showPercent?, showSeriesName? } | ### Quick start (multi‑series column chart) @@ -36,18 +35,17 @@ const wb = createWorkbook(); const ws = wb.createWorksheet({ name: 'Sales' }); wb.addWorksheet(ws); -ws.addRow(['Month', 'Q1', 'Q2']); -ws.addRow(['Jan', 10, 15]); -ws.addRow(['Feb', 20, 25]); -ws.addRow(['Mar', 30, 35]); +ws.setData([ + ['Month', 'Q1', 'Q2'], + ['Jan', 10, 15], + ['Feb', 20, 25], + ['Mar', 30, 35], +]); const chart = new Chart({ type: 'column', title: 'Quarterly Sales', - axis: { - x: { title: 'Month' }, - y: { title: 'Revenue', minimum: 0, showGridLines: true }, - }, + axis: { x: { title: 'Month' }, y: { title: 'Revenue', minimum: 0, showGridLines: true } }, series: [ { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, @@ -55,10 +53,9 @@ const chart = new Chart({ categoriesRange: 'Sales!$A$2:$A$4', }); wb.addChart(chart); - chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } }); -ws.addDrawings(drawings.addDrawing(chart)); // or add drawings first then the chart +// (Workbook export depends on your surrounding setup) await wb.generateFiles(); ``` @@ -77,11 +74,14 @@ new Chart({ ``` ## Positioning -Use a two-cell anchor: +Position a chart with a two‑cell anchor (start & end grid cells): ```ts -chart.createAnchor('twoCellAnchor', { from: { x: 4, y: 1 }, to: { x: 10, y: 16 } }); +chart.createAnchor('twoCellAnchor', { + from: { x: 4, y: 1 }, + to: { x: 10, y: 16 } +}); ``` -Values are column/row indices (0-based). +Indices are zero‑based (0 = first column / row). ### Legend Auto behavior (no `legend` option provided): show legend only when there are 2 or more series. @@ -100,8 +100,54 @@ Rules: - If `show` is undefined, auto mode (2+ series) applies. - `overlay` emits `` when true; otherwise `0`. -Notes: -- Pie / Doughnut: adding multiple series produces multiple pies/rings; legend lists series names. +Note: Pie / Doughnut with multiple series produces multiple pies/rings; legend lists series names. + +### Data Labels +Provide high-level toggles for what text appears on each point. + +API flags: +```ts +dataLabels: { + showValue?: boolean; // numeric value (Y value or slice value) + showCategory?: boolean; // category text (Month, Region, etc.) + showPercent?: boolean; // percentage (pie/doughnut, or percent-stacked series) + showSeriesName?: boolean; // series name (useful with multiple series where value alone is ambiguous) +} +``` + +Behavior: +- Pick the parts you want (value, percent, category, series name). Omitted = hidden. +- Omit `dataLabels` completely for none. +- Hover tooltips are unchanged (Excel shows full details on hover). + +Examples: +1. Value-only on a column chart: +```ts +dataLabels: { showValue: true } +``` +2. Percent-only on a pie (concise slice labels): +```ts +dataLabels: { showPercent: true } +``` +3. Value + percent on a doughnut: +```ts +dataLabels: { showValue: true, showPercent: true } +``` +4. Series name only (multi-line chart where legend is hidden): +```ts +dataLabels: { showSeriesName: true } +``` + +Full example (pie with percent only): +```ts +new Chart({ + type: 'pie', + title: 'Share', + dataLabels: { showPercent: true }, + series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], + categoriesRange: 'Regions!$A$2:$A$6', +}); +``` Example (legend will show 2 entries and be placed top-right): ```ts @@ -156,15 +202,14 @@ new Chart({ ``` Notes: -- Stacking ignored for: doughnut, pie & scatter -- Percent stacking displays proportional contribution (0–100%). -- Overlap is automatically set for stacked column/bar to align segments. +- Stacking applies only to column, bar, line. +- Percent stacking rescales each category to 100%. --- ## Chart Type Examples -Below are minimal, focused examples for each supported chart type. They assume you have already created a workbook `wb`, added a worksheet `ws` with suitable data, and added that worksheet to the workbook. Only the chart-specific parts are shown. +Below are small, focused snippets for each type. They assume you already created a workbook (`wb`) and worksheet (`ws`) with matching ranges. #### Column ```ts @@ -173,8 +218,8 @@ const col = new Chart({ title: 'Monthly Revenue', axis: { x: { title: 'Month' }, y: { title: 'Amount', minimum: 0, showGridLines: true } }, series: [ - { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF3366CC' }, // ARGB - { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13', color: 'FFFF9933' }, // ARGB + { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF3366CC' }, + { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13', color: 'FFFF9933' }, ], categoriesRange: 'Sales!$A$2:$A$13', }); @@ -203,6 +248,7 @@ const line = new Chart({ axis: { x: { title: 'Month' }, y: { title: 'Value', showGridLines: true } }, series: [{ name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF99CC00' }], categoriesRange: 'Sales!$A$2:$A$13', + dataLabels: { showValue: true }, }); wb.addChart(line); ``` @@ -212,6 +258,7 @@ wb.addChart(line); const pie = new Chart({ type: 'pie', title: 'Share by Region', + dataLabels: { showValue: true, showPercent: true }, series: [{ name: '2025', valuesRange: 'Regions!$B$2:$B$6' }], categoriesRange: 'Regions!$A$2:$A$6', }); @@ -239,8 +286,9 @@ const scatter = new Chart({ name: 'Run A', scatterXRange: 'Runs!$A$2:$A$21', valuesRange: 'Runs!$B$2:$B$21', - color: 'FFFF0000', // ARGB stroke color (opaque red) + color: 'FFFF0000', // ARGB stroke color (opaque red) }], + dataLabels: { showValue: true }, // shows Y values at each point }); wb.addChart(scatter); ``` @@ -343,18 +391,27 @@ wb.addChart(barPct); --- End of chart type examples. -### Series Color Notes -See also the general color section in `fonts-and-colors.md`. - -Format: -- Opaque ARGB only: `FFRRGGBB` (e.g. `FF3366CC`). Use `FF` for fully opaque colors. +### Series Colors +Format: opaque ARGB `FFRRGGBB` (examples: `FFFF9933` = orange, `FF3366CC` = blue). -Behavior: -- Column / Bar: sets a solid fill color. -- Line / Scatter: sets the stroke line color (markers, if/when added later, would share that color). -- Pie / Doughnut: series color is ignored; Excel auto-assigns slice colors (one series per pie/ring). +Effects: +- Column / Bar: fill color +- Line / Scatter: stroke color +- Pie / Doughnut: ignored (Excel auto colors slices) Notes: -- Alpha channel (when other than `FF`) is currently ignored; colors render fully opaque. -- Invalid hex strings are silently ignored (no styling emitted). -- Theme-based colors (e.g. `{ theme: 2 }`) are not yet supported for charts—pass an ARGB/RGB string. +- Alpha (anything other than `FF`) is ignored; colors are always rendered fully opaque. +- Invalid strings are ignored silently. +- Theme colors are not supported; supply an ARGB hex. + +### Cell Range Cheat Sheet +| Want | Pattern | Example | +|------|---------|---------| +| 3 category labels | Sheet!$A$2:$A$4 | `Sales!$A$2:$A$4` | +| Series values | Sheet!$B$2:$B$4 | `Sales!$B$2:$B$4` | +| Scatter X values | Sheet!$A$2:$A$21 | `Runs!$A$2:$A$21` | +| Scatter Y values | Sheet!$B$2:$B$21 | `Runs!$B$2:$B$21` | + +Tips: +- Always use absolute refs (`$A$1`) so range stays stable. +- Category and each series range must have the same number of rows. diff --git a/packages/demo/src/examples/example18.ts b/packages/demo/src/examples/example18.ts index e54c535..f926591 100644 --- a/packages/demo/src/examples/example18.ts +++ b/packages/demo/src/examples/example18.ts @@ -127,6 +127,12 @@ export default class Example18 { categoriesRange, series: seriesDefs, legend: legendConfig, + dataLabels: + type === 'pie' || type === 'doughnut' + ? { showPercent: true } + : sheetName === 'Column' || sheetName === 'Bar' || sheetName === 'Line' + ? { showValue: true } + : undefined, }); const anchor = chart.createAnchor('twoCellAnchor', { diff --git a/packages/demo/src/images/charts.png b/packages/demo/src/images/charts.png index 298255ec11cd4df68fc209000e680ddeff1a6038..272f90c232ec44353eca2b15b091180c53390bd4 100644 GIT binary patch literal 39119 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

    IgxoMwj%8bN@0cA~c9S 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 RVdTU3*vTK$k6irie*oERVIlwk literal 40526 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 node is emitted. */ + dataLabels?: { + /** Show numerical value */ + showValue?: boolean; + /** Show category text (for non-scatter) */ + showCategory?: boolean; + /** Show percentage (mainly useful for pie/doughnut or percent stacked) */ + showPercent?: boolean; + /** Show series name (useful when multiple series and category/value alone is ambiguous) */ + showSeriesName?: boolean; + }; } /** * @module Excel/Util diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index b6d1de5..4fadfc8 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -181,6 +181,20 @@ export class Chart extends Drawing { primaryChartNode.appendChild(ser); }); + // Data labels (chart-level). Specification places inside the chart-type node + const dLblsCfg = this.options.dataLabels; + if (dLblsCfg) { + // Always emit ; write each known child with explicit 0/1 to prevent Excel defaults. + const dLbls = Util.createElement(doc, 'c:dLbls'); + const valNode = (tag: string, enabled: boolean | undefined) => + dLbls.appendChild(Util.createElement(doc, tag, [['val', enabled === true ? '1' : '0']])); + valNode('c:showVal', dLblsCfg.showValue); + valNode('c:showCatName', dLblsCfg.showCategory); + valNode('c:showPercent', dLblsCfg.showPercent); + valNode('c:showSerName', dLblsCfg.showSeriesName); + primaryChartNode.appendChild(dLbls); + } + // Axis IDs (except pie which has no axes) if (type !== 'pie' && type !== 'doughnut') { primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdCat)]])); diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index be169a3..34036f4 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -743,4 +743,58 @@ describe('Chart', () => { const legendSegment = xml.match(/[\s\S]*?<\/c:legend>/)?.[0]; expect(legendSegment).toContain(' { + const { xml } = buildChart({ + type: 'pie', + title: 'Pie Labels', + dataLabels: { showValue: true, showPercent: true }, + series: [{ name: 'S', valuesRange: 'S!$B$2:$B$5' }], + categoriesRange: 'S!$A$2:$A$5', + }); + expect(xml).toContain(' { + const { xml } = buildChart({ + type: 'column', + title: 'No Labels', + dataLabels: {}, + series: [{ name: 'S1', valuesRange: 'S!$B$2:$B$4' }], + categoriesRange: 'S!$A$2:$A$4', + }); + // We now always emit for provided object, but with no child nodes since no keys specified. + expect(xml).toContain('[\s\S]*?<\/c:dLbls>/) ? xml : xml; // self-closing OK + expect(seg).toContain(' { + const { xml } = buildChart({ + type: 'line', + title: 'Series Name Labels', + dataLabels: { showSeriesName: true, showValue: false }, + series: [ + { name: 'Alpha', valuesRange: 'S!$B$2:$B$4' }, + { name: 'Beta', valuesRange: 'S!$C$2:$C$4' }, + ], + categoriesRange: 'S!$A$2:$A$4', + }); + const dLblsSegment = xml.match(/[\s\S]*?<\/c:dLbls>/)?.[0] || xml; // fallback to whole xml + expect(dLblsSegment).toContain(' node is emitted. */ + dataLabels?: { + /** Show numerical value */ + showValue?: boolean; + /** Show category text (for non-scatter) */ + showCategory?: boolean; + /** Show percentage (mainly useful for pie/doughnut or percent stacked) */ + showPercent?: boolean; + /** Show series name (useful when multiple series and category/value alone is ambiguous) */ + showSeriesName?: boolean; + }; } From 3074295eea7d099e7ba543690a0067c6ee5205d3 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Wed, 22 Oct 2025 20:11:43 -0400 Subject: [PATCH 15/17] chore: optimize Chart.ts file & tweak example18 --- packages/demo/src/examples/example18.html | 30 +- .../dist/index.d.ts | 22 +- .../src/Excel/Drawing/Chart.ts | 361 +++++++++--------- 3 files changed, 200 insertions(+), 213 deletions(-) diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html index ab66bc3..18bfd3d 100644 --- a/packages/demo/src/examples/example18.html +++ b/packages/demo/src/examples/example18.html @@ -2,7 +2,7 @@

    - Example 18: Create Chart + Example 18: Create Charts Code @@ -29,37 +29,11 @@

    - - - - - - - - - - - - - - - - - - - - - - - - - -
    MonthQ1Q2
    Jan120180
    Feb150160
    Mar170200
    Excel Preview (single sheet example)

    diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index d95ec8c..2b658ae 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -437,29 +437,33 @@ export declare class Chart extends Drawing { target: string | null; options: ChartOptions; constructor(options: ChartOptions); - /** RelationshipManager calls this via Drawings */ - setRelationshipId(rId: string): void; /** Return relationship type for this drawing */ getMediaType(): keyof typeof Util.schemas; - /** Creates the graphicFrame container that goes inside an anchor in drawing part */ - private createGraphicFrame; + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string): void; /** Drawing part representation (inside an anchor) */ toXML(xmlDoc: XMLDOM): XMLNode; - private _nextAxisIdBase; /** Chart part XML: /xl/charts/chartN.xml */ toChartSpaceXML(): XMLDOM; + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame; /** Create the primary chart node based on type and stacking */ private _createPrimaryChartNode; - /** Resolve grouping value based on chart type and stacking */ - private _resolveGrouping; + /** Build a node */ + private _createSeriesNode; + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor; + /** Create legend node honoring position + overlay */ + private _createLegendNode; /** Create a c:title node with minimal rich text required for Excel to render */ private _createTitleNode; /** Create a category axis (catAx) */ private _createCategoryAxis; /** Create a value axis (valAx) */ private _createValueAxis; - /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ - private _applySeriesColor; + private _nextAxisIdBase; + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping; } export type Relation = { [id: string]: { diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 4fadfc8..07fe209 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -22,59 +22,14 @@ export class Chart extends Drawing { this.options = options; } - /** RelationshipManager calls this via Drawings */ - setRelationshipId(rId: string) { - this.relId = rId; - } - /** Return relationship type for this drawing */ getMediaType(): keyof typeof Util.schemas { return 'chart'; } - /** Creates the graphicFrame container that goes inside an anchor in drawing part */ - private createGraphicFrame(xmlDoc: XMLDOM) { - const graphicFrame = Util.createElement(xmlDoc, 'xdr:graphicFrame'); - - const nvGraphicFramePr = Util.createElement(xmlDoc, 'xdr:nvGraphicFramePr'); - nvGraphicFramePr.appendChild( - Util.createElement(xmlDoc, 'xdr:cNvPr', [ - ['id', String(this.index || 1)], - ['name', this.options.title || 'Chart'], - ]), - ); - nvGraphicFramePr.appendChild(Util.createElement(xmlDoc, 'xdr:cNvGraphicFramePr')); - graphicFrame.appendChild(nvGraphicFramePr); - - // basic transform (off + ext) – values are arbitrary but required structure - const xfrm = Util.createElement(xmlDoc, 'xdr:xfrm'); - xfrm.appendChild( - Util.createElement(xmlDoc, 'a:off', [ - ['x', '0'], - ['y', '0'], - ]), - ); - xfrm.appendChild( - Util.createElement(xmlDoc, 'a:ext', [ - ['cx', String(this.options.width || 4000000)], - ['cy', String(this.options.height || 3000000)], - ]), - ); - graphicFrame.appendChild(xfrm); - - const graphic = Util.createElement(xmlDoc, 'a:graphic'); - const graphicData = Util.createElement(xmlDoc, 'a:graphicData', [['uri', 'http://schemas.openxmlformats.org/drawingml/2006/chart']]); - graphicData.appendChild( - Util.createElement(xmlDoc, 'c:chart', [ - ['xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'], - ['xmlns:r', Util.schemas.relationships], - ['r:id', this.relId || ''], - ]), - ); - graphic.appendChild(graphicData); - graphicFrame.appendChild(graphic); - - return graphicFrame; + /** RelationshipManager calls this via Drawings */ + setRelationshipId(rId: string) { + this.relId = rId; } /** Drawing part representation (inside an anchor) */ @@ -82,11 +37,6 @@ export class Chart extends Drawing { return this.anchor.toXML(xmlDoc, this.createGraphicFrame(xmlDoc)); } - private _nextAxisIdBase(): number { - // Simple axis id base using index plus a constant offset - return (this.index || 1) * 1000; - } - /** Chart part XML: /xl/charts/chartN.xml */ toChartSpaceXML(): XMLDOM { const doc = Util.createXmlDoc('http://schemas.openxmlformats.org/drawingml/2006/chart', 'c:chartSpace'); @@ -109,82 +59,20 @@ export class Chart extends Drawing { const axIdCat = axisBase + 1; const axIdVal = axisBase + 2; - // Default chart type + // Default chart type (column) if caller omitted const type = this.options.type || 'column'; - // Categories range (shared across all non-scatter series when provided) + // Categories range (applies to every non-scatter series) const categoriesRange = this.options.categoriesRange || ''; const primaryChartNode = this._createPrimaryChartNode(doc, type, this.options.stacking); - - // Lean chart XML (no fallback shorthand or data cache snapshots) - + // Series (this.options.series || []).forEach((s, idx) => { - const ser = Util.createElement(doc, 'c:ser'); - ser.appendChild(Util.createElement(doc, 'c:idx', [['val', String(idx)]])); - ser.appendChild(Util.createElement(doc, 'c:order', [['val', String(idx)]])); - - // Series title literal - const tx = Util.createElement(doc, 'c:tx'); - const txV = Util.createElement(doc, 'c:v'); - txV.appendChild(doc.createTextNode(s.name)); - tx.appendChild(txV); - ser.appendChild(tx); - - if (type === 'scatter') { - // Scatter uses xVal & yVal - const xVal = Util.createElement(doc, 'c:xVal'); - if (s.scatterXRange) { - const numRefX = Util.createElement(doc, 'c:numRef'); - const fNodeX = Util.createElement(doc, 'c:f'); - fNodeX.appendChild(doc.createTextNode(s.scatterXRange)); - numRefX.appendChild(fNodeX); - xVal.appendChild(numRefX); - } else { - // Minimal empty numLit fallback - const numLitX = Util.createElement(doc, 'c:numLit'); - numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', '0']])); - xVal.appendChild(numLitX); - } - ser.appendChild(xVal); - const yVal = Util.createElement(doc, 'c:yVal'); - const numRefY = Util.createElement(doc, 'c:numRef'); - const fNodeY = Util.createElement(doc, 'c:f'); - fNodeY.appendChild(doc.createTextNode(s.valuesRange)); - numRefY.appendChild(fNodeY); - yVal.appendChild(numRefY); - ser.appendChild(yVal); - } else { - // Categories (shared across all series) - if (categoriesRange) { - const cat = Util.createElement(doc, 'c:cat'); - const strRef = Util.createElement(doc, 'c:strRef'); - const fNodeCat = Util.createElement(doc, 'c:f'); - fNodeCat.appendChild(doc.createTextNode(categoriesRange)); - strRef.appendChild(fNodeCat); - cat.appendChild(strRef); - ser.appendChild(cat); - } - // Values - if (s.valuesRange) { - const val = Util.createElement(doc, 'c:val'); - const numRef = Util.createElement(doc, 'c:numRef'); - const fNodeVal = Util.createElement(doc, 'c:f'); - fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); - numRef.appendChild(fNodeVal); - val.appendChild(numRef); - ser.appendChild(val); - } - } - - // Optional per-series color (basic solid fill / line stroke) - this._applySeriesColor(doc, ser, type, s.color); - - primaryChartNode.appendChild(ser); + primaryChartNode.appendChild(this._createSeriesNode(doc, s, idx, type, categoriesRange)); }); - // Data labels (chart-level). Specification places inside the chart-type node + // Data labels (chart-level). Placed inside the primary chart-type node. const dLblsCfg = this.options.dataLabels; if (dLblsCfg) { - // Always emit ; write each known child with explicit 0/1 to prevent Excel defaults. + // Always emit all four known toggles with explicit 0/1 to suppress Excel auto behavior. const dLbls = Util.createElement(doc, 'c:dLbls'); const valNode = (tag: string, enabled: boolean | undefined) => dLbls.appendChild(Util.createElement(doc, tag, [['val', enabled === true ? '1' : '0']])); @@ -216,25 +104,13 @@ export class Chart extends Drawing { } } - // Legend logic (configurable) + // Legend (auto show for >1 series unless overridden) const legendOpts = this.options.legend; const seriesCount = (this.options.series || []).length; - const autoShouldShow = seriesCount > 1; // previous behavior + const autoShouldShow = seriesCount > 1; const effectiveShow = typeof legendOpts?.show === 'boolean' ? legendOpts.show : autoShouldShow; if (effectiveShow) { - const legend = Util.createElement(doc, 'c:legend'); - // Map high-level position to OOXML codes - const posMap: Record = { right: 'r', left: 'l', top: 't', bottom: 'b', topRight: 'tr' }; - const pos = posMap[legendOpts?.position || 'right'] || 'r'; - legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', pos]])); - legend.appendChild(Util.createElement(doc, 'c:layout')); - // Overlay (default 0) - if (legendOpts?.overlay) { - legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', '1']])); - } else { - legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', '0']])); - } - chart.appendChild(legend); + chart.appendChild(this._createLegendNode(doc, legendOpts)); } chart.appendChild(plotArea); @@ -243,6 +119,54 @@ export class Chart extends Drawing { chartSpace.appendChild(Util.createElement(doc, 'c:printSettings')); return doc; } + + // -- private functions + + /** Creates the graphicFrame container that goes inside an anchor in drawing part */ + private createGraphicFrame(xmlDoc: XMLDOM) { + const graphicFrame = Util.createElement(xmlDoc, 'xdr:graphicFrame'); + + const nvGraphicFramePr = Util.createElement(xmlDoc, 'xdr:nvGraphicFramePr'); + nvGraphicFramePr.appendChild( + Util.createElement(xmlDoc, 'xdr:cNvPr', [ + ['id', String(this.index || 1)], + ['name', this.options.title || 'Chart'], + ]), + ); + nvGraphicFramePr.appendChild(Util.createElement(xmlDoc, 'xdr:cNvGraphicFramePr')); + graphicFrame.appendChild(nvGraphicFramePr); + + // basic transform (off + ext) – values are arbitrary but required structure + const xfrm = Util.createElement(xmlDoc, 'xdr:xfrm'); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:off', [ + ['x', '0'], + ['y', '0'], + ]), + ); + xfrm.appendChild( + Util.createElement(xmlDoc, 'a:ext', [ + ['cx', String(this.options.width || 4000000)], + ['cy', String(this.options.height || 3000000)], + ]), + ); + graphicFrame.appendChild(xfrm); + + const graphic = Util.createElement(xmlDoc, 'a:graphic'); + const graphicData = Util.createElement(xmlDoc, 'a:graphicData', [['uri', 'http://schemas.openxmlformats.org/drawingml/2006/chart']]); + graphicData.appendChild( + Util.createElement(xmlDoc, 'c:chart', [ + ['xmlns:c', 'http://schemas.openxmlformats.org/drawingml/2006/chart'], + ['xmlns:r', Util.schemas.relationships], + ['r:id', this.relId || ''], + ]), + ); + graphic.appendChild(graphicData); + graphicFrame.appendChild(graphic); + + return graphicFrame; + } + /** Create the primary chart node based on type and stacking */ private _createPrimaryChartNode(doc: XMLDOM, type: string, stacking?: 'stacked' | 'percent'): XMLNode { let node: XMLNode; @@ -300,21 +224,115 @@ export class Chart extends Drawing { return node; } - /** Resolve grouping value based on chart type and stacking */ - private _resolveGrouping(type: string, stacking?: 'stacked' | 'percent'): string { - if (type === 'pie' || type === 'doughnut') return 'clustered'; // required but cosmetic - if (type === 'line') { - if (stacking === 'stacked') return 'stacked'; - if (stacking === 'percent') return 'percentStacked'; - return 'standard'; + /** Build a node */ + private _createSeriesNode( + doc: XMLDOM, + s: { name: string; valuesRange: string; scatterXRange?: string; color?: string }, + idx: number, + type: string, + categoriesRange: string, + ): XMLNode { + const ser = Util.createElement(doc, 'c:ser'); + const idxStr = String(idx); + ser.appendChild(Util.createElement(doc, 'c:idx', [['val', idxStr]])); + ser.appendChild(Util.createElement(doc, 'c:order', [['val', idxStr]])); + + // Series title literal + const tx = Util.createElement(doc, 'c:tx'); + const txV = Util.createElement(doc, 'c:v'); + txV.appendChild(doc.createTextNode(s.name)); + tx.appendChild(txV); + ser.appendChild(tx); + + if (type === 'scatter') { + // xVal + const xVal = Util.createElement(doc, 'c:xVal'); + if (s.scatterXRange) { + const numRefX = Util.createElement(doc, 'c:numRef'); + const fNodeX = Util.createElement(doc, 'c:f'); + fNodeX.appendChild(doc.createTextNode(s.scatterXRange)); + numRefX.appendChild(fNodeX); + xVal.appendChild(numRefX); + } else { + const numLitX = Util.createElement(doc, 'c:numLit'); + numLitX.appendChild(Util.createElement(doc, 'c:ptCount', [['val', '0']])); + xVal.appendChild(numLitX); + } + ser.appendChild(xVal); + // yVal + const yVal = Util.createElement(doc, 'c:yVal'); + const numRefY = Util.createElement(doc, 'c:numRef'); + const fNodeY = Util.createElement(doc, 'c:f'); + fNodeY.appendChild(doc.createTextNode(s.valuesRange)); + numRefY.appendChild(fNodeY); + yVal.appendChild(numRefY); + ser.appendChild(yVal); + } else { + if (categoriesRange) { + const cat = Util.createElement(doc, 'c:cat'); + const strRef = Util.createElement(doc, 'c:strRef'); + const fNodeCat = Util.createElement(doc, 'c:f'); + fNodeCat.appendChild(doc.createTextNode(categoriesRange)); + strRef.appendChild(fNodeCat); + cat.appendChild(strRef); + ser.appendChild(cat); + } + if (s.valuesRange) { + const val = Util.createElement(doc, 'c:val'); + const numRef = Util.createElement(doc, 'c:numRef'); + const fNodeVal = Util.createElement(doc, 'c:f'); + fNodeVal.appendChild(doc.createTextNode(s.valuesRange)); + numRef.appendChild(fNodeVal); + val.appendChild(numRef); + ser.appendChild(val); + } } - if (type === 'bar' || type === 'column') { - if (stacking === 'stacked') return 'stacked'; - if (stacking === 'percent') return 'percentStacked'; - return 'clustered'; + + // Optional per-series color + this._applySeriesColor(doc, ser, type, s.color); + return ser; + } + + /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ + private _applySeriesColor(doc: XMLDOM, serNode: XMLNode, type: string, color?: string) { + if (!color || typeof color !== 'string') return; + let hex = color.trim().replace(/^#/, '').toUpperCase(); + // Accept 6 (RGB) or 8 (ARGB) hex chars; strip leading alpha if present + if (/^[0-9A-F]{8}$/.test(hex)) { + hex = hex.slice(2); + } else if (!/^[0-9A-F]{6}$/.test(hex)) { + return; // invalid format; silently ignore } - // scatter doesn't use grouping; still return default for structural consistency - return 'standard'; + // Create spPr container + const spPr = Util.createElement(doc, 'c:spPr'); + if (type === 'line' || type === 'scatter') { + // For line/scatter charts define stroke color (ln) + const ln = Util.createElement(doc, 'a:ln'); + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + ln.appendChild(solidFill); + spPr.appendChild(ln); + } else if (type !== 'pie' && type !== 'doughnut') { + // For column/bar (and future types) define a solid fill + const solidFill = Util.createElement(doc, 'a:solidFill'); + solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); + spPr.appendChild(solidFill); + } else { + // For pie/doughnut omit series-level color (Excel varies slice colors automatically) + return; + } + serNode.appendChild(spPr); + } + + /** Create legend node honoring position + overlay */ + private _createLegendNode(doc: XMLDOM, legendOpts?: { position?: string; overlay?: boolean }): XMLNode { + const legend = Util.createElement(doc, 'c:legend'); + const posMap: Record = { right: 'r', left: 'l', top: 't', bottom: 'b', topRight: 'tr' }; + const pos = posMap[legendOpts?.position || 'right'] || 'r'; + legend.appendChild(Util.createElement(doc, 'c:legendPos', [['val', pos]])); + legend.appendChild(Util.createElement(doc, 'c:layout')); + legend.appendChild(Util.createElement(doc, 'c:overlay', [['val', legendOpts?.overlay ? '1' : '0']])); + return legend; } /** Create a c:title node with minimal rich text required for Excel to render */ @@ -392,34 +410,25 @@ export class Chart extends Drawing { return valAx; } - /** Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ - private _applySeriesColor(doc: XMLDOM, serNode: XMLNode, type: string, color?: string) { - if (!color || typeof color !== 'string') return; - let hex = color.trim().replace(/^#/, '').toUpperCase(); - // Accept 6 (RGB) or 8 (ARGB) hex chars; strip leading alpha if present - if (/^[0-9A-F]{8}$/.test(hex)) { - hex = hex.slice(2); - } else if (!/^[0-9A-F]{6}$/.test(hex)) { - return; // invalid format; silently ignore + private _nextAxisIdBase(): number { + // Simple axis id base using index plus a constant offset + return (this.index || 1) * 1000; + } + + /** Resolve grouping value based on chart type and stacking */ + private _resolveGrouping(type: string, stacking?: 'stacked' | 'percent'): string { + if (type === 'pie' || type === 'doughnut') return 'clustered'; // required but cosmetic + if (type === 'line') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'standard'; } - // Create spPr container - const spPr = Util.createElement(doc, 'c:spPr'); - if (type === 'line' || type === 'scatter') { - // For line/scatter charts define stroke color (ln) - const ln = Util.createElement(doc, 'a:ln'); - const solidFill = Util.createElement(doc, 'a:solidFill'); - solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); - ln.appendChild(solidFill); - spPr.appendChild(ln); - } else if (type !== 'pie' && type !== 'doughnut') { - // For column/bar (and future types) define a solid fill - const solidFill = Util.createElement(doc, 'a:solidFill'); - solidFill.appendChild(Util.createElement(doc, 'a:srgbClr', [['val', hex]])); - spPr.appendChild(solidFill); - } else { - // For pie/doughnut omit series-level color (Excel varies slice colors automatically) - return; + if (type === 'bar' || type === 'column') { + if (stacking === 'stacked') return 'stacked'; + if (stacking === 'percent') return 'percentStacked'; + return 'clustered'; } - serNode.appendChild(spPr); + // scatter doesn't use grouping; still return default for structural consistency + return 'standard'; } } From 8f1cb5edb0959398a227acba7ccdfdf33d93b9c9 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 24 Oct 2025 00:32:51 -0400 Subject: [PATCH 16/17] chore: final code cleanup --- docs/inserting-charts.md | 32 +++++++++++++++---- packages/demo/src/app-routing.ts | 2 +- packages/demo/src/examples/example18.html | 6 ++-- .../dist/index.d.ts | 15 +++++---- .../src/Excel/Drawing/Chart.ts | 31 ++++++++++-------- .../src/Excel/Drawing/__tests__/Chart.spec.ts | 5 --- .../excel-builder-vanilla/src/interfaces.ts | 7 ++-- 7 files changed, 61 insertions(+), 37 deletions(-) diff --git a/docs/inserting-charts.md b/docs/inserting-charts.md index 8250777..4ea7d44 100644 --- a/docs/inserting-charts.md +++ b/docs/inserting-charts.md @@ -13,6 +13,10 @@ Add charts to a workbook: add data, build a chart with cell ranges, position it. 5. Anchor it (e.g. `twoCellAnchor`) 6. Generate files +{% hint style="info" %} +**Tips** Categories typically populate the X-axis, while series values go on the Y-axis. +{% endhint %} + ### Option summary (ChartOptions) | Option | Purpose | Notes | |--------|---------|-------| @@ -24,9 +28,9 @@ Add charts to a workbook: add data, build a chart with cell ranges, position it. | stacking | Stack series | 'stacked' or 'percent' (column / bar / line only) | | width / height | Size (EMUs) | Usually omit (auto size) | | categoriesRange | Category labels range | Not used by scatter (use scatterXRange instead) | -| series | Data series | Array of { name, valuesRange, color? } | +| series | Data series | Array of { name, valuesRange, color } | | series[].scatterXRange | X values (scatter) | Only for scatter charts | -| dataLabels | Point label toggles | { showValue?, showCategory?, showPercent?, showSeriesName? } | +| dataLabels | Point label toggles | { showValue, showCategory, showPercent, showSeriesName } | ### Quick start (multi‑series column chart) @@ -45,7 +49,10 @@ ws.setData([ const chart = new Chart({ type: 'column', title: 'Quarterly Sales', - axis: { x: { title: 'Month' }, y: { title: 'Revenue', minimum: 0, showGridLines: true } }, + axis: { + x: { title: 'Month' }, // X-Axis: Horizontal categories (months) + y: { title: 'Revenue', minimum: 0, showGridLines: true } // Y-Axis: Vertical values (sales amounts) + }, series: [ { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, @@ -192,7 +199,10 @@ Enable stacking on multi-series column, bar, or line charts: new Chart({ type: 'column', stacking: 'stacked', // or 'percent' - axis: { x: { title: 'Month' }, y: { title: 'Revenue', minimum: 0, showGridLines: true } }, + axis: { + x: { title: 'Month' }, + y: { title: 'Revenue', minimum: 0, showGridLines: true } + }, series: [ { name: 'Q1', valuesRange: 'Sales!$B$2:$B$4' }, { name: 'Q2', valuesRange: 'Sales!$C$2:$C$4' }, @@ -216,7 +226,10 @@ Below are small, focused snippets for each type. They assume you already created const col = new Chart({ type: 'column', title: 'Monthly Revenue', - axis: { x: { title: 'Month' }, y: { title: 'Amount', minimum: 0, showGridLines: true } }, + axis: { + x: { title: 'Month' }, // X-Axis: Horizontal categories (months) + y: { title: 'Amount', minimum: 0, showGridLines: true } // Y-Axis: Vertical values (revenue) + }, series: [ { name: 'Q1', valuesRange: 'Sales!$B$2:$B$13', color: 'FF3366CC' }, { name: 'Q2', valuesRange: 'Sales!$C$2:$C$13', color: 'FFFF9933' }, @@ -315,7 +328,9 @@ const colPct = new Chart({ type: 'column', stacking: 'percent', title: 'Product Mix %', - axis: { x: { title: 'Month' }, y: { title: 'Percent', minimum: 0, maximum: 1, showGridLines: true } }, + axis: { + x: { title: 'Month' }, + y: { title: 'Percent', minimum: 0, maximum: 1, showGridLines: true } }, series: [ { name: 'Product A', valuesRange: 'Sales!$B$2:$B$13' }, { name: 'Product B', valuesRange: 'Sales!$C$2:$C$13' }, @@ -347,7 +362,10 @@ const linePct = new Chart({ type: 'line', stacking: 'percent', title: 'Regional Contribution %', - axis: { x: { title: 'Month' }, y: { title: 'Percent', minimum: 0, maximum: 1 } }, + axis: { + x: { title: 'Month' }, + y: { title: 'Percent', minimum: 0, maximum: 1 } + }, series: [ { name: 'North', valuesRange: 'Regions!$B$2:$B$13' }, { name: 'South', valuesRange: 'Regions!$C$2:$C$13' }, diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index 4e41eab..b1292a7 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -49,7 +49,7 @@ export const exampleRouting = [ { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' }, { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: '16- Streaming Features Demo' }, { name: 'example17', view: '/src/examples/example17.html', viewModel: Example17, title: '17- Streaming Export with Images' }, - { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Chart Demo' }, + { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Charts Demo' }, ], }, ]; diff --git a/packages/demo/src/examples/example18.html b/packages/demo/src/examples/example18.html index 18bfd3d..f2736a4 100644 --- a/packages/demo/src/examples/example18.html +++ b/packages/demo/src/examples/example18.html @@ -34,15 +34,15 @@

    -
    -
    Excel Preview (single sheet example)
    +
    +
    Excel Preview (single sheet example)

    This screenshot shows one chart sheet only. The exported workbook includes every chart listed on the right.

    -
    +
    Charts Created:
    • Column
    • diff --git a/packages/excel-builder-vanilla-types/dist/index.d.ts b/packages/excel-builder-vanilla-types/dist/index.d.ts index 2b658ae..5243778 100644 --- a/packages/excel-builder-vanilla-types/dist/index.d.ts +++ b/packages/excel-builder-vanilla-types/dist/index.d.ts @@ -302,9 +302,12 @@ export interface AxisOptions { export interface ChartSeriesRef { /** Series display name */ name: string; - /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ + /** Cell range for series values (e.g. `Sheet1!$B$2:$B$5`) */ valuesRange: string; - /** Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ + /** + * Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). + * Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. + */ color?: string; /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ scatterXRange?: string; @@ -426,10 +429,10 @@ export declare class Util { /** * Minimal Chart implementation (clustered column) required for Excel to render without repair. * This produces 2 parts: - * 1) Drawing graphicFrame (returned by toXML for inclusion in /xl/drawings/drawingN.xml) - * 2) Chart part XML (returned by toChartSpaceXML for inclusion in /xl/charts/chartN.xml) + * 1) Drawing graphicFrame (returned by toXML for inclusion in `/xl/drawings/drawingN.xml`) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in `/xl/charts/chartN.xml`) * Relationships: - * drawingN.xml.rels -> ../charts/chartN.xml (Type chart) + * `drawingN.xml.rels` -> `../charts/chartN.xml` (Type chart) */ export declare class Chart extends Drawing { relId: string | null; @@ -443,7 +446,7 @@ export declare class Chart extends Drawing { setRelationshipId(rId: string): void; /** Drawing part representation (inside an anchor) */ toXML(xmlDoc: XMLDOM): XMLNode; - /** Chart part XML: /xl/charts/chartN.xml */ + /** Chart part XML: `/xl/charts/chartN.xml` */ toChartSpaceXML(): XMLDOM; /** Creates the graphicFrame container that goes inside an anchor in drawing part */ private createGraphicFrame; diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts index 07fe209..aec1578 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/Chart.ts @@ -6,15 +6,15 @@ import { Drawing } from './Drawing.js'; /** * Minimal Chart implementation (clustered column) required for Excel to render without repair. * This produces 2 parts: - * 1) Drawing graphicFrame (returned by toXML for inclusion in /xl/drawings/drawingN.xml) - * 2) Chart part XML (returned by toChartSpaceXML for inclusion in /xl/charts/chartN.xml) + * 1) Drawing graphicFrame (returned by toXML for inclusion in `/xl/drawings/drawingN.xml`) + * 2) Chart part XML (returned by toChartSpaceXML for inclusion in `/xl/charts/chartN.xml`) * Relationships: - * drawingN.xml.rels -> ../charts/chartN.xml (Type chart) + * `drawingN.xml.rels` -> `../charts/chartN.xml` (Type chart) */ export class Chart extends Drawing { relId: string | null = null; // relationship id from drawing rels index: number | null = null; // 1-based index assigned by workbook - target: string | null = null; // relative target path (../charts/chartN.xml) + target: string | null = null; // relative target path (`../charts/chartN.xml`) options: ChartOptions; constructor(options: ChartOptions) { @@ -37,7 +37,7 @@ export class Chart extends Drawing { return this.anchor.toXML(xmlDoc, this.createGraphicFrame(xmlDoc)); } - /** Chart part XML: /xl/charts/chartN.xml */ + /** Chart part XML: `/xl/charts/chartN.xml` */ toChartSpaceXML(): XMLDOM { const doc = Util.createXmlDoc('http://schemas.openxmlformats.org/drawingml/2006/chart', 'c:chartSpace'); const chartSpace = doc.documentElement; @@ -46,7 +46,7 @@ export class Chart extends Drawing { chartSpace.setAttribute('xmlns:r', Util.schemas.relationships); const chart = Util.createElement(doc, 'c:chart'); - // Title (only if provided). autoTitleDeleted must be 0 or omitted when we set a title. + // Title (only if provided). `autoTitleDeleted` must be 0 or omitted when we set a title. if (this.options.title) { chart.appendChild(this._createTitleNode(doc, this.options.title)); chart.appendChild(Util.createElement(doc, 'c:autoTitleDeleted', [['val', '0']])); @@ -65,7 +65,8 @@ export class Chart extends Drawing { const categoriesRange = this.options.categoriesRange || ''; const primaryChartNode = this._createPrimaryChartNode(doc, type, this.options.stacking); // Series - (this.options.series || []).forEach((s, idx) => { + const series = this.options.series || []; + series.forEach((s, idx) => { primaryChartNode.appendChild(this._createSeriesNode(doc, s, idx, type, categoriesRange)); }); @@ -106,8 +107,7 @@ export class Chart extends Drawing { // Legend (auto show for >1 series unless overridden) const legendOpts = this.options.legend; - const seriesCount = (this.options.series || []).length; - const autoShouldShow = seriesCount > 1; + const autoShouldShow = series.length > 1; const effectiveShow = typeof legendOpts?.show === 'boolean' ? legendOpts.show : autoShouldShow; if (effectiveShow) { chart.appendChild(this._createLegendNode(doc, legendOpts)); @@ -125,7 +125,6 @@ export class Chart extends Drawing { /** Creates the graphicFrame container that goes inside an anchor in drawing part */ private createGraphicFrame(xmlDoc: XMLDOM) { const graphicFrame = Util.createElement(xmlDoc, 'xdr:graphicFrame'); - const nvGraphicFramePr = Util.createElement(xmlDoc, 'xdr:nvGraphicFramePr'); nvGraphicFramePr.appendChild( Util.createElement(xmlDoc, 'xdr:cNvPr', [ @@ -374,7 +373,9 @@ export class Chart extends Drawing { if (opts?.showGridLines) { catAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); } - if (title) catAx.appendChild(this._createTitleNode(doc, title)); + if (title) { + catAx.appendChild(this._createTitleNode(doc, title)); + } return catAx; } @@ -406,7 +407,9 @@ export class Chart extends Drawing { if (opts?.showGridLines) { valAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); } - if (title) valAx.appendChild(this._createTitleNode(doc, title)); + if (title) { + valAx.appendChild(this._createTitleNode(doc, title)); + } return valAx; } @@ -417,7 +420,9 @@ export class Chart extends Drawing { /** Resolve grouping value based on chart type and stacking */ private _resolveGrouping(type: string, stacking?: 'stacked' | 'percent'): string { - if (type === 'pie' || type === 'doughnut') return 'clustered'; // required but cosmetic + if (type === 'pie' || type === 'doughnut') { + return 'clustered'; // required but cosmetic + } if (type === 'line') { if (stacking === 'stacked') return 'stacked'; if (stacking === 'percent') return 'percentStacked'; diff --git a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts index 34036f4..8148dd8 100644 --- a/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts +++ b/packages/excel-builder-vanilla/src/Excel/Drawing/__tests__/Chart.spec.ts @@ -589,11 +589,6 @@ describe('Chart', () => { expect(xml).toMatch(/]*r:id=""/); }); - // ----------------- - // Data cache tests - // ----------------- - // Removed data cache tests due to API minimization (no includeDataCache, no fallback arrays) - // ----------------- // Stacking tests // ----------------- diff --git a/packages/excel-builder-vanilla/src/interfaces.ts b/packages/excel-builder-vanilla/src/interfaces.ts index 6f11cbf..4fd39af 100644 --- a/packages/excel-builder-vanilla/src/interfaces.ts +++ b/packages/excel-builder-vanilla/src/interfaces.ts @@ -162,9 +162,12 @@ export interface AxisOptions { export interface ChartSeriesRef { /** Series display name */ name: string; - /** Cell range for series values (e.g. Sheet1!$B$2:$B$5) */ + /** Cell range for series values (e.g. `Sheet1!$B$2:$B$5`) */ valuesRange: string; - /** Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. */ + /** + * Optional solid color for the series. Use opaque ARGB `FFRRGGBB` (e.g. FF3366CC). + * Alpha (other than FF) currently ignored. Theme colors not yet supported for charts. + */ color?: string; /** Scatter only: per-series X axis numeric range (ignored for non-scatter charts) */ scatterXRange?: string; From 577326bc706f5c339e1848c4c33cad046b37a4ae Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Fri, 24 Oct 2025 00:40:58 -0400 Subject: [PATCH 17/17] chore: final code cleanup --- packages/demo/src/app-routing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/demo/src/app-routing.ts b/packages/demo/src/app-routing.ts index b1292a7..b58fde9 100644 --- a/packages/demo/src/app-routing.ts +++ b/packages/demo/src/app-routing.ts @@ -49,7 +49,7 @@ export const exampleRouting = [ { name: 'example15', view: '/src/examples/example15.html', viewModel: Example15, title: '15- Streaming Excel Export' }, { name: 'example16', view: '/src/examples/example16.html', viewModel: Example16, title: '16- Streaming Features Demo' }, { name: 'example17', view: '/src/examples/example17.html', viewModel: Example17, title: '17- Streaming Export with Images' }, - { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Charts Demo' }, + { name: 'example18', view: '/src/examples/example18.html', viewModel: Example18, title: '18- Charts' }, ], }, ];