Skip to content

Commit 320bcb8

Browse files
committed
feat: charts
1 parent 7e19045 commit 320bcb8

23 files changed

Lines changed: 3776 additions & 4 deletions

File tree

devtools/visual-testing/pnpm-lock.yaml

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/layout-engine/contracts/src/index.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,7 @@ export type ImageBlock = {
575575
flipV?: boolean; // Vertical flip
576576
};
577577

578-
export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup';
578+
export type DrawingKind = 'image' | 'vectorShape' | 'shapeGroup' | 'chart';
579579

580580
export type DrawingContentSnapshot = {
581581
name: string;
@@ -816,7 +816,63 @@ export type ImageDrawing = DrawingBlockBase &
816816
drawingKind: 'image';
817817
};
818818

819-
export type DrawingBlock = VectorShapeDrawing | ShapeGroupDrawing | ImageDrawing;
819+
// ============================================================================
820+
// Chart Drawing Types
821+
// ============================================================================
822+
823+
/** A single data series in a chart (e.g., one set of bars in a bar chart). */
824+
export type ChartSeriesData = {
825+
/** Display name for the series (from c:tx). */
826+
name: string;
827+
/** Category labels (from c:cat / c:strCache). */
828+
categories: string[];
829+
/** Numeric values (from c:val / c:numCache). */
830+
values: number[];
831+
/** Optional X-axis values for XY charts (scatter/bubble). */
832+
xValues?: number[];
833+
/** Optional bubble radius/size values for bubble charts. */
834+
bubbleSizes?: number[];
835+
};
836+
837+
/** Axis configuration extracted from c:catAx / c:valAx. */
838+
export type ChartAxisConfig = {
839+
title?: string;
840+
orientation?: 'minMax' | 'maxMin';
841+
};
842+
843+
/** Normalized chart data model parsed from OOXML chart XML. */
844+
export type ChartModel = {
845+
/** OOXML chart element name (e.g., 'barChart', 'lineChart', 'pieChart'). */
846+
chartType: string;
847+
/** Sub-type qualifier (e.g., 'clustered', 'stacked', 'percentStacked'). */
848+
subType?: string;
849+
/** Bar direction — 'col' for vertical columns, 'bar' for horizontal bars. */
850+
barDirection?: 'col' | 'bar';
851+
/** Data series in the chart. */
852+
series: ChartSeriesData[];
853+
/** Category axis config. */
854+
categoryAxis?: ChartAxisConfig;
855+
/** Value axis config. */
856+
valueAxis?: ChartAxisConfig;
857+
/** Legend position (e.g., 'r', 'b', 't', 'l'). */
858+
legendPosition?: string;
859+
/** OOXML chart style ID. */
860+
styleId?: number;
861+
};
862+
863+
/** Chart drawing block. */
864+
export type ChartDrawing = DrawingBlockBase & {
865+
drawingKind: 'chart';
866+
geometry: DrawingGeometry;
867+
/** Parsed chart data for rendering. */
868+
chartData: ChartModel;
869+
/** Relationship ID for the chart part in the docx package. */
870+
chartRelId?: string;
871+
/** Path to the chart XML part (e.g., 'word/charts/chart1.xml'). */
872+
chartPartPath?: string;
873+
};
874+
875+
export type DrawingBlock = VectorShapeDrawing | ShapeGroupDrawing | ImageDrawing | ChartDrawing;
820876

821877
/**
822878
* Vertical alignment of content within a section/page.

packages/layout-engine/layout-bridge/src/diff.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,14 @@ const drawingBlocksEqual = (a: DrawingBlock, b: DrawingBlock): boolean => {
478478
);
479479
}
480480

481+
if (a.drawingKind === 'chart' && b.drawingKind === 'chart') {
482+
return (
483+
drawingGeometryEqual(a.geometry, b.geometry) &&
484+
a.chartRelId === b.chartRelId &&
485+
jsonEqual(a.chartData, b.chartData)
486+
);
487+
}
488+
481489
return true;
482490
};
483491

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { JSDOM } from 'jsdom';
3+
import { createChartElement, createChartPlaceholder, formatTickValue } from './chart-renderer.js';
4+
import type { ChartModel, DrawingGeometry } from '@superdoc/contracts';
5+
6+
let doc: Document;
7+
8+
beforeEach(() => {
9+
doc = new JSDOM('<!DOCTYPE html><html><body></body></html>').window.document;
10+
});
11+
12+
const defaultGeometry: DrawingGeometry = { width: 400, height: 300, rotation: 0, flipH: false, flipV: false };
13+
14+
function makeBarChart(overrides: Partial<ChartModel> = {}): ChartModel {
15+
return {
16+
chartType: 'barChart',
17+
barDirection: 'col',
18+
series: [{ name: 'Series 1', categories: ['Q1', 'Q2', 'Q3'], values: [100, 200, 150] }],
19+
legendPosition: 'r',
20+
...overrides,
21+
};
22+
}
23+
24+
function makePieChart(overrides: Partial<ChartModel> = {}): ChartModel {
25+
return {
26+
chartType: 'pieChart',
27+
series: [{ name: 'Sales', categories: ['1st Qtr', '2nd Qtr', '3rd Qtr', '4th Qtr'], values: [8.2, 3.2, 1.4, 1.2] }],
28+
legendPosition: 'b',
29+
...overrides,
30+
};
31+
}
32+
33+
function makeLineChart(overrides: Partial<ChartModel> = {}): ChartModel {
34+
return {
35+
chartType: 'lineChart',
36+
series: [
37+
{ name: 'Series 1', categories: ['Q1', 'Q2', 'Q3', 'Q4'], values: [10, 24, 18, 31] },
38+
{ name: 'Series 2', categories: ['Q1', 'Q2', 'Q3', 'Q4'], values: [8, 12, 20, 25] },
39+
],
40+
legendPosition: 'b',
41+
...overrides,
42+
};
43+
}
44+
45+
function makeAreaChart(overrides: Partial<ChartModel> = {}): ChartModel {
46+
return {
47+
chartType: 'areaChart',
48+
series: [{ name: 'Series 1', categories: ['Q1', 'Q2', 'Q3', 'Q4'], values: [10, 24, 18, 31] }],
49+
legendPosition: 'b',
50+
...overrides,
51+
};
52+
}
53+
54+
function makeDoughnutChart(overrides: Partial<ChartModel> = {}): ChartModel {
55+
return {
56+
chartType: 'doughnutChart',
57+
series: [{ name: 'Sales', categories: ['1st Qtr', '2nd Qtr', '3rd Qtr', '4th Qtr'], values: [8.2, 3.2, 1.4, 1.2] }],
58+
legendPosition: 'b',
59+
...overrides,
60+
};
61+
}
62+
63+
function makeScatterChart(overrides: Partial<ChartModel> = {}): ChartModel {
64+
return {
65+
chartType: 'scatterChart',
66+
series: [{ name: 'Series 1', categories: [], values: [12, 30, 22, 35], xValues: [1, 2, 3, 4] }],
67+
legendPosition: 'b',
68+
...overrides,
69+
};
70+
}
71+
72+
function makeBubbleChart(overrides: Partial<ChartModel> = {}): ChartModel {
73+
return {
74+
chartType: 'bubbleChart',
75+
series: [
76+
{
77+
name: 'Series 1',
78+
categories: [],
79+
values: [10, 18, 26],
80+
xValues: [1, 3, 5],
81+
bubbleSizes: [4, 8, 16],
82+
},
83+
],
84+
legendPosition: 'r',
85+
...overrides,
86+
};
87+
}
88+
89+
function makeRadarChart(overrides: Partial<ChartModel> = {}): ChartModel {
90+
return {
91+
chartType: 'radarChart',
92+
series: [
93+
{ name: 'Series 1', categories: ['Speed', 'Design', 'Quality', 'Cost'], values: [65, 80, 72, 55] },
94+
{ name: 'Series 2', categories: ['Speed', 'Design', 'Quality', 'Cost'], values: [58, 66, 81, 70] },
95+
],
96+
legendPosition: 'b',
97+
...overrides,
98+
};
99+
}
100+
101+
describe('formatTickValue', () => {
102+
it('formats millions', () => expect(formatTickValue(2_500_000)).toBe('2.5M'));
103+
it('formats thousands', () => expect(formatTickValue(4_500)).toBe('4.5K'));
104+
it('formats integers', () => expect(formatTickValue(42)).toBe('42'));
105+
it('formats decimals', () => expect(formatTickValue(3.14)).toBe('3.1'));
106+
it('formats negative millions', () => expect(formatTickValue(-1_000_000)).toBe('-1.0M'));
107+
});
108+
109+
describe('createChartElement', () => {
110+
it('renders a bar chart as SVG', () => {
111+
const el = createChartElement(doc, makeBarChart(), defaultGeometry);
112+
expect(el.classList.contains('superdoc-chart')).toBe(true);
113+
expect(el.querySelector('svg')).not.toBeNull();
114+
});
115+
116+
it('shows placeholder for missing chart data', () => {
117+
const el = createChartElement(doc, undefined, defaultGeometry);
118+
expect(el.textContent).toContain('No chart data');
119+
});
120+
121+
it('shows placeholder for empty series', () => {
122+
const el = createChartElement(doc, makeBarChart({ series: [] }), defaultGeometry);
123+
expect(el.textContent).toContain('No chart data');
124+
});
125+
126+
it('shows placeholder for unsupported chart type', () => {
127+
const el = createChartElement(doc, makeBarChart({ chartType: 'surfaceChart' }), defaultGeometry);
128+
expect(el.textContent).toContain('Chart: surfaceChart');
129+
});
130+
131+
it('renders bars for each series value', () => {
132+
const el = createChartElement(doc, makeBarChart(), defaultGeometry);
133+
const rects = el.querySelectorAll('svg rect');
134+
// 3 data points = 3 bar rects (no legend swatch for single series with legendPosition)
135+
// Wait — with the fix, legend shows for single series too. Let's check:
136+
// Series 1 has 3 values → 3 bars
137+
// Legend: 1 series with legendPosition → 1 swatch rect
138+
expect(rects.length).toBeGreaterThanOrEqual(3);
139+
});
140+
141+
it('renders a pie chart as SVG paths', () => {
142+
const el = createChartElement(doc, makePieChart(), defaultGeometry);
143+
const svg = el.querySelector('svg');
144+
expect(svg).not.toBeNull();
145+
expect(svg!.querySelectorAll('path,circle').length).toBeGreaterThan(0);
146+
});
147+
148+
it('renders pie title from series name', () => {
149+
const el = createChartElement(doc, makePieChart(), defaultGeometry);
150+
const textEls = el.querySelectorAll('svg text');
151+
const title = Array.from(textEls).find((t) => t.textContent === 'Sales');
152+
expect(title).not.toBeUndefined();
153+
});
154+
155+
it('renders pie legend categories', () => {
156+
const el = createChartElement(doc, makePieChart(), defaultGeometry);
157+
const textEls = el.querySelectorAll('svg text');
158+
const legendLabel = Array.from(textEls).find((t) => t.textContent === '1st Qtr');
159+
expect(legendLabel).not.toBeUndefined();
160+
});
161+
162+
it('renders a line chart with polyline paths', () => {
163+
const el = createChartElement(doc, makeLineChart(), defaultGeometry);
164+
const svg = el.querySelector('svg');
165+
expect(svg).not.toBeNull();
166+
expect(svg!.querySelectorAll('polyline').length).toBeGreaterThan(0);
167+
});
168+
169+
it('renders an area chart with filled paths', () => {
170+
const el = createChartElement(doc, makeAreaChart(), defaultGeometry);
171+
const svg = el.querySelector('svg');
172+
expect(svg).not.toBeNull();
173+
expect(svg!.querySelectorAll('path').length).toBeGreaterThan(0);
174+
});
175+
176+
it('renders a doughnut chart as ring slices', () => {
177+
const el = createChartElement(doc, makeDoughnutChart(), defaultGeometry);
178+
const svg = el.querySelector('svg');
179+
expect(svg).not.toBeNull();
180+
expect(svg!.querySelectorAll('path,circle').length).toBeGreaterThan(0);
181+
});
182+
183+
it('renders a scatter chart with point markers', () => {
184+
const el = createChartElement(doc, makeScatterChart(), defaultGeometry);
185+
const svg = el.querySelector('svg');
186+
expect(svg).not.toBeNull();
187+
expect(svg!.querySelectorAll('circle').length).toBeGreaterThan(0);
188+
});
189+
190+
it('renders a bubble chart with variable-size circles', () => {
191+
const el = createChartElement(doc, makeBubbleChart(), defaultGeometry);
192+
const svg = el.querySelector('svg');
193+
expect(svg).not.toBeNull();
194+
const radii = Array.from(svg!.querySelectorAll('circle')).map((node) => Number(node.getAttribute('r')));
195+
expect(Math.max(...radii)).toBeGreaterThan(Math.min(...radii));
196+
});
197+
198+
it('renders a radar chart as polygons', () => {
199+
const el = createChartElement(doc, makeRadarChart(), defaultGeometry);
200+
const svg = el.querySelector('svg');
201+
expect(svg).not.toBeNull();
202+
expect(svg!.querySelectorAll('polygon').length).toBeGreaterThan(0);
203+
});
204+
205+
it('routes stockChart to line renderer', () => {
206+
const el = createChartElement(doc, makeLineChart({ chartType: 'stockChart' }), defaultGeometry);
207+
const svg = el.querySelector('svg');
208+
expect(svg).not.toBeNull();
209+
expect(svg!.querySelectorAll('polyline').length).toBeGreaterThan(0);
210+
});
211+
212+
it('routes ofPieChart to pie renderer', () => {
213+
const chart = makePieChart({ chartType: 'ofPieChart' });
214+
const el = createChartElement(doc, chart, defaultGeometry);
215+
const svg = el.querySelector('svg');
216+
expect(svg).not.toBeNull();
217+
expect(svg!.querySelectorAll('path,circle').length).toBeGreaterThan(0);
218+
});
219+
220+
it('renders legend when chart has legendPosition (even single series)', () => {
221+
const chart = makeBarChart({ legendPosition: 'b' });
222+
const el = createChartElement(doc, chart, defaultGeometry);
223+
const svg = el.querySelector('svg')!;
224+
const textEls = svg.querySelectorAll('text');
225+
const legendText = Array.from(textEls).find((t) => t.textContent === 'Series 1');
226+
expect(legendText).not.toBeUndefined();
227+
});
228+
229+
it('omits legend when chart has no legendPosition', () => {
230+
const chart = makeBarChart({ legendPosition: undefined });
231+
const el = createChartElement(doc, chart, defaultGeometry);
232+
const svg = el.querySelector('svg')!;
233+
const textEls = svg.querySelectorAll('text');
234+
const legendText = Array.from(textEls).find((t) => t.textContent === 'Series 1');
235+
expect(legendText).toBeUndefined();
236+
});
237+
});
238+
239+
describe('performance guardrails', () => {
240+
it('truncates series beyond MAX_RENDERED_SERIES (20)', () => {
241+
const series = Array.from({ length: 25 }, (_, i) => ({
242+
name: `S${i}`,
243+
categories: ['A'],
244+
values: [i * 10],
245+
}));
246+
const chart = makeBarChart({ series });
247+
const el = createChartElement(doc, chart, defaultGeometry);
248+
expect(el.textContent).toContain('Data truncated');
249+
});
250+
251+
it('truncates data points beyond MAX_POINTS_PER_SERIES (500)', () => {
252+
const categories = Array.from({ length: 600 }, (_, i) => `C${i}`);
253+
const values = Array.from({ length: 600 }, (_, i) => i);
254+
const chart = makeBarChart({ series: [{ name: 'Big', categories, values }] });
255+
const el = createChartElement(doc, chart, defaultGeometry);
256+
expect(el.textContent).toContain('Data truncated');
257+
});
258+
259+
it('falls back to placeholder when estimated SVG elements exceed budget (5000)', () => {
260+
// 20 series × 500 points = 10,000 bars alone → exceeds 5,000 budget
261+
const series = Array.from({ length: 20 }, (_, i) => ({
262+
name: `S${i}`,
263+
categories: Array.from({ length: 500 }, (_, j) => `C${j}`),
264+
values: Array.from({ length: 500 }, (_, j) => j),
265+
}));
266+
const chart = makeBarChart({ series });
267+
const el = createChartElement(doc, chart, defaultGeometry);
268+
expect(el.textContent).toContain('too complex');
269+
// Should NOT have an SVG — it's a placeholder
270+
expect(el.querySelector('svg')).toBeNull();
271+
});
272+
});
273+
274+
describe('createChartPlaceholder', () => {
275+
it('shows the label text', () => {
276+
const container = doc.createElement('div');
277+
const el = createChartPlaceholder(doc, container, 'Test Label');
278+
expect(el.textContent).toContain('Test Label');
279+
});
280+
281+
it('sets flex display for centering', () => {
282+
const container = doc.createElement('div');
283+
const el = createChartPlaceholder(doc, container, 'x');
284+
expect(el.style.display).toBe('flex');
285+
});
286+
});

0 commit comments

Comments
 (0)