UNPKG

excel-builder-vanilla

Version:

An easy way of building Excel files with javascript

440 lines (408 loc) 18.9 kB
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; } /** Return relationship type for this drawing */ getMediaType(): keyof typeof Util.schemas { return 'chart'; } /** RelationshipManager calls this via Drawings */ setRelationshipId(rId: string) { this.relId = rId; } /** Drawing part representation (inside an anchor) */ toXML(xmlDoc: XMLDOM) { return this.anchor.toXML(xmlDoc, this._createGraphicFrame(xmlDoc)); } /** 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; // Default chart type (column) if caller omitted const type = this.options.type || 'column'; // Categories range (applies to every non-scatter series) const categoriesRange = this.options.categoriesRange || ''; const primaryChartNode = this._createPrimaryChartNode(doc, type, this.options.stacking); // Series const series = this.options.series || []; series.forEach((s, idx) => { primaryChartNode.appendChild(this._createSeriesNode(doc, s, idx, type, categoriesRange)); }); // Data labels (chart-level). Placed inside the primary chart-type node. const dLblsCfg = this.options.dataLabels; if (dLblsCfg) { // 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']])); 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)]])); primaryChartNode.appendChild(Util.createElement(doc, 'c:axId', [['val', String(axIdVal)]])); } plotArea.appendChild(primaryChartNode); if (type !== 'pie' && type !== 'doughnut') { 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, xAxisOpts)); plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); } else { plotArea.appendChild(this._createCategoryAxis(doc, axIdCat, axIdVal, xAxisTitle, xAxisOpts)); plotArea.appendChild(this._createValueAxis(doc, axIdVal, axIdCat, 'l', yAxisTitle, yAxisOpts)); } } // Legend (auto show for >1 series unless overridden) const legendOpts = this.options.legend; const autoShouldShow = series.length > 1; const effectiveShow = typeof legendOpts?.show === 'boolean' ? legendOpts.show : autoShouldShow; if (effectiveShow) { chart.appendChild(this._createLegendNode(doc, legendOpts)); } chart.appendChild(plotArea); chart.appendChild(Util.createElement(doc, 'c:plotVisOnly', [['val', '1']])); chartSpace.appendChild(chart); chartSpace.appendChild(Util.createElement(doc, 'c:printSettings')); return doc; } // -- private functions /** @private Creates the graphicFrame container that goes inside an anchor in drawing part */ _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; } /** @private Create the primary chart node based on type and stacking */ _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 'doughnut': { node = Util.createElement(doc, 'c:doughnutChart'); node.appendChild(Util.createElement(doc, 'c:grouping', [['val', 'clustered']])); node.appendChild(Util.createElement(doc, 'c:varyColors', [['val', '1']])); // Add a default holeSize (50%) to visualize doughnut; Excel defaults to 50 if absent but explicit for clarity node.appendChild(Util.createElement(doc, 'c:holeSize', [['val', '50']])); 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; } /** @private Build a <c:ser> node */ _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); } } // Optional per-series color this._applySeriesColor(doc, ser, type, s.color); return ser; } /** @private Apply a basic series color if provided. Supports RGB (RRGGBB) or ARGB (AARRGGBB); leading # optional. Alpha (if provided) is stripped. */ _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); } /** @private Create legend node honoring position + overlay */ _createLegendNode(doc: XMLDOM, legendOpts?: { position?: string; overlay?: boolean }): XMLNode { const legend = Util.createElement(doc, 'c:legend'); const posMap: Record<string, string> = { 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; } /** @private Create a c:title node with minimal rich text required for Excel to render */ _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; } /** @private Create a category axis (catAx) */ _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'); 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 (opts?.showGridLines) { catAx.appendChild(Util.createElement(doc, 'c:majorGridlines')); } if (title) { catAx.appendChild(this._createTitleNode(doc, title)); } return catAx; } /** @private Create a value axis (valAx) */ _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; } /** @private Simple axis id base using index plus a constant offset */ _nextAxisIdBase(): number { return (this.index || 1) * 1000; } /** @private Resolve grouping value based on chart type and stacking */ _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'; } 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'; } }