pptx-browser 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +209 -0
- package/package.json +53 -0
- package/src/animation.js +817 -0
- package/src/charts.js +989 -0
- package/src/clipboard.js +416 -0
- package/src/colors.js +297 -0
- package/src/effects3d.js +312 -0
- package/src/extract.js +535 -0
- package/src/fntdata.js +265 -0
- package/src/fonts.js +676 -0
- package/src/index.js +751 -0
- package/src/pdf.js +298 -0
- package/src/render.js +1964 -0
- package/src/shapes.js +666 -0
- package/src/slideshow.js +492 -0
- package/src/smartart.js +696 -0
- package/src/svg.js +732 -0
- package/src/theme.js +88 -0
- package/src/utils.js +50 -0
- package/src/writer.js +1015 -0
- package/src/zip-writer.js +214 -0
- package/src/zip.js +194 -0
package/src/charts.js
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* charts.js — Full OOXML chart renderer.
|
|
3
|
+
*
|
|
4
|
+
* Supports: bar (horizontal), column (vertical), line, pie, doughnut,
|
|
5
|
+
* area, scatter — each in clustered / stacked / percentStacked.
|
|
6
|
+
*
|
|
7
|
+
* Chart data lives in ppt/charts/chart1.xml and is referenced from
|
|
8
|
+
* a slide via a relationship ID on the <c:chart> element inside
|
|
9
|
+
* <a:graphicData uri="…/chart">.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { g1, gtn, attr, attrInt } from './utils.js';
|
|
13
|
+
import { resolveColorElement, findFirstColorChild, colorToCss } from './colors.js';
|
|
14
|
+
|
|
15
|
+
// ── Default palette (Office theme accent colours + extras) ──────────────────
|
|
16
|
+
const DEFAULT_PALETTE = [
|
|
17
|
+
'#4472C4', '#ED7D31', '#A9D18E', '#FF0000',
|
|
18
|
+
'#FFC000', '#5B9BD5', '#70AD47', '#C00000',
|
|
19
|
+
'#7030A0', '#00B0F0', '#FF7F00', '#9E480E',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// ── XML helpers specific to chart namespace ─────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function cv(el) {
|
|
25
|
+
// <c:v> text content → number if parseable
|
|
26
|
+
const t = el?.textContent?.trim();
|
|
27
|
+
if (t === undefined || t === null || t === '') return null;
|
|
28
|
+
const n = parseFloat(t);
|
|
29
|
+
return isNaN(n) ? t : n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Collect ordered <c:pt> values from a cache (strCache or numCache). */
|
|
33
|
+
function readCache(cacheEl) {
|
|
34
|
+
if (!cacheEl) return [];
|
|
35
|
+
const count = attrInt(g1(cacheEl, 'ptCount'), 'val', 0);
|
|
36
|
+
const result = new Array(count).fill(null);
|
|
37
|
+
for (const pt of gtn(cacheEl, 'pt')) {
|
|
38
|
+
const idx = attrInt(pt, 'idx', 0);
|
|
39
|
+
result[idx] = cv(g1(pt, 'v'));
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Read series name from <c:tx>. */
|
|
45
|
+
function seriesName(ser) {
|
|
46
|
+
const tx = g1(ser, 'tx');
|
|
47
|
+
if (!tx) return null;
|
|
48
|
+
// Inline string
|
|
49
|
+
const v = g1(tx, 'v');
|
|
50
|
+
if (v) return v.textContent.trim();
|
|
51
|
+
// strRef cache
|
|
52
|
+
const strCache = g1(tx, 'strCache');
|
|
53
|
+
if (strCache) {
|
|
54
|
+
const pt = g1(strCache, 'pt');
|
|
55
|
+
const vEl = pt ? g1(pt, 'v') : null;
|
|
56
|
+
return vEl ? vEl.textContent.trim() : null;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read category labels from <c:cat> or <c:xVal>. */
|
|
62
|
+
function readCategories(ser) {
|
|
63
|
+
const catEl = g1(ser, 'cat') || g1(ser, 'xVal');
|
|
64
|
+
if (!catEl) return [];
|
|
65
|
+
return readCache(g1(catEl, 'strCache') || g1(catEl, 'numCache'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read numeric values from <c:val> or <c:yVal>. */
|
|
69
|
+
function readValues(ser) {
|
|
70
|
+
const valEl = g1(ser, 'val') || g1(ser, 'yVal');
|
|
71
|
+
if (!valEl) return [];
|
|
72
|
+
return readCache(g1(valEl, 'numCache'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read per-series colour from spPr > solidFill, or fall back to index. */
|
|
76
|
+
function seriesColor(ser, idx, themeColors) {
|
|
77
|
+
const spPr = g1(ser, 'spPr');
|
|
78
|
+
if (spPr) {
|
|
79
|
+
const solidFill = g1(spPr, 'solidFill');
|
|
80
|
+
if (solidFill) {
|
|
81
|
+
const colorChild = findFirstColorChild(solidFill);
|
|
82
|
+
const c = resolveColorElement(colorChild, themeColors);
|
|
83
|
+
if (c) return colorToCss(c);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Use theme accent colours in order
|
|
87
|
+
const accentKey = `accent${(idx % 6) + 1}`;
|
|
88
|
+
if (themeColors[accentKey]) {
|
|
89
|
+
const rgb = themeColors[accentKey];
|
|
90
|
+
return `#${rgb}`;
|
|
91
|
+
}
|
|
92
|
+
return DEFAULT_PALETTE[idx % DEFAULT_PALETTE.length];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Per data-point override colour. Returns map: index → css colour string. */
|
|
96
|
+
function dataPointColors(ser, themeColors) {
|
|
97
|
+
const map = {};
|
|
98
|
+
for (const dPt of gtn(ser, 'dPt')) {
|
|
99
|
+
const idx = attrInt(g1(dPt, 'idx'), 'val', -1);
|
|
100
|
+
if (idx < 0) continue;
|
|
101
|
+
const spPr = g1(dPt, 'spPr');
|
|
102
|
+
if (!spPr) continue;
|
|
103
|
+
const solidFill = g1(spPr, 'solidFill');
|
|
104
|
+
if (!solidFill) continue;
|
|
105
|
+
const colorChild = findFirstColorChild(solidFill);
|
|
106
|
+
const c = resolveColorElement(colorChild, themeColors);
|
|
107
|
+
if (c) map[idx] = colorToCss(c);
|
|
108
|
+
}
|
|
109
|
+
return map;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Chart bounds ─────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function chartBounds(cx, cy, cw, ch, opts = {}) {
|
|
115
|
+
const {
|
|
116
|
+
padL = 0.12, padR = 0.06, padT = 0.08, padB = 0.10,
|
|
117
|
+
legendH = 0.10, hasLegend = true, hasTitle = false,
|
|
118
|
+
} = opts;
|
|
119
|
+
const tOffset = hasTitle ? ch * 0.08 : 0;
|
|
120
|
+
const lOffset = hasLegend ? ch * legendH : 0;
|
|
121
|
+
return {
|
|
122
|
+
x: cx + cw * padL,
|
|
123
|
+
y: cy + ch * padT + tOffset,
|
|
124
|
+
w: cw * (1 - padL - padR),
|
|
125
|
+
h: ch * (1 - padT - padB) - lOffset - tOffset,
|
|
126
|
+
legendY: cy + ch * (1 - legendH * 0.7),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Drawing primitives ───────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function roundRect(ctx, x, y, w, h, r = 3) {
|
|
133
|
+
if (w < 0) { x += w; w = -w; }
|
|
134
|
+
if (h < 0) { y += h; h = -h; }
|
|
135
|
+
r = Math.min(r, Math.abs(w) / 2, Math.abs(h) / 2);
|
|
136
|
+
ctx.beginPath();
|
|
137
|
+
ctx.moveTo(x + r, y);
|
|
138
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
139
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
140
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
141
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
142
|
+
ctx.closePath();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function drawAxisLine(ctx, x1, y1, x2, y2, color = '#999', width = 0.7) {
|
|
146
|
+
ctx.save();
|
|
147
|
+
ctx.strokeStyle = color;
|
|
148
|
+
ctx.lineWidth = width;
|
|
149
|
+
ctx.setLineDash([]);
|
|
150
|
+
ctx.beginPath();
|
|
151
|
+
ctx.moveTo(x1, y1);
|
|
152
|
+
ctx.lineTo(x2, y2);
|
|
153
|
+
ctx.stroke();
|
|
154
|
+
ctx.restore();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function drawGridLine(ctx, x1, y1, x2, y2) {
|
|
158
|
+
ctx.save();
|
|
159
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.10)';
|
|
160
|
+
ctx.lineWidth = 0.5;
|
|
161
|
+
ctx.setLineDash([3, 3]);
|
|
162
|
+
ctx.beginPath();
|
|
163
|
+
ctx.moveTo(x1, y1);
|
|
164
|
+
ctx.lineTo(x2, y2);
|
|
165
|
+
ctx.stroke();
|
|
166
|
+
ctx.setLineDash([]);
|
|
167
|
+
ctx.restore();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function niceStep(range, targetTicks = 5) {
|
|
171
|
+
const rough = range / targetTicks;
|
|
172
|
+
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
173
|
+
const norm = rough / mag;
|
|
174
|
+
let step;
|
|
175
|
+
if (norm < 1.5) step = 1;
|
|
176
|
+
else if (norm < 3.5) step = 2;
|
|
177
|
+
else if (norm < 7.5) step = 5;
|
|
178
|
+
else step = 10;
|
|
179
|
+
return step * mag;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function calcAxisRange(values, forceZero = true) {
|
|
183
|
+
const flat = values.filter(v => typeof v === 'number' && isFinite(v));
|
|
184
|
+
if (!flat.length) return { min: 0, max: 100, step: 20 };
|
|
185
|
+
let min = forceZero ? Math.min(0, ...flat) : Math.min(...flat);
|
|
186
|
+
let max = Math.max(...flat);
|
|
187
|
+
if (min === max) { min -= 1; max += 1; }
|
|
188
|
+
const step = niceStep(max - min);
|
|
189
|
+
max = Math.ceil(max / step) * step;
|
|
190
|
+
min = Math.floor(min / step) * step;
|
|
191
|
+
return { min, max, step };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fmtLabel(n) {
|
|
195
|
+
if (typeof n !== 'number') return String(n ?? '');
|
|
196
|
+
if (Math.abs(n) >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
|
197
|
+
if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
198
|
+
if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
199
|
+
if (Number.isInteger(n)) return String(n);
|
|
200
|
+
return n.toFixed(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Legend ───────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
function drawLegend(ctx, cx, cy, cw, legendY, series, scale) {
|
|
206
|
+
if (!series.length) return;
|
|
207
|
+
const sz = Math.max(8, Math.min(14, cw * 0.025)) * scale;
|
|
208
|
+
const itemW = cw / Math.max(series.length, 1);
|
|
209
|
+
ctx.save();
|
|
210
|
+
ctx.font = `${sz}px sans-serif`;
|
|
211
|
+
ctx.textBaseline = 'middle';
|
|
212
|
+
for (let i = 0; i < series.length; i++) {
|
|
213
|
+
if (!series[i].name) continue;
|
|
214
|
+
const lx = cx + itemW * i + sz * 0.5;
|
|
215
|
+
// Colour swatch
|
|
216
|
+
ctx.fillStyle = series[i].color;
|
|
217
|
+
roundRect(ctx, lx, legendY - sz / 2, sz * 1.2, sz);
|
|
218
|
+
ctx.fill();
|
|
219
|
+
// Label
|
|
220
|
+
ctx.fillStyle = '#444';
|
|
221
|
+
ctx.fillText(series[i].name, lx + sz * 1.5, legendY);
|
|
222
|
+
}
|
|
223
|
+
ctx.restore();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// BAR / COLUMN CHART
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function renderBarChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
231
|
+
const barChart = g1(chartEl, 'barChart') || g1(chartEl, 'bar3DChart');
|
|
232
|
+
const isColumn = !barChart || attr(barChart, 'barDir', 'col') !== 'bar';
|
|
233
|
+
const grouping = attr(barChart, 'grouping', 'clustered');
|
|
234
|
+
const isStacked = grouping === 'stacked' || grouping === 'percentStacked';
|
|
235
|
+
const isPct = grouping === 'percentStacked';
|
|
236
|
+
|
|
237
|
+
const serEls = gtn(barChart, 'ser');
|
|
238
|
+
const seriesData = serEls.map((s, i) => ({
|
|
239
|
+
name: seriesName(s),
|
|
240
|
+
values: readValues(s),
|
|
241
|
+
color: seriesColor(s, i, themeColors),
|
|
242
|
+
dptColors: dataPointColors(s, themeColors),
|
|
243
|
+
}));
|
|
244
|
+
if (!seriesData.length) return;
|
|
245
|
+
|
|
246
|
+
const cats = readCategories(serEls[0]) || [];
|
|
247
|
+
const numCats = Math.max(cats.length, seriesData[0]?.values.length || 0, 1);
|
|
248
|
+
|
|
249
|
+
const b = chartBounds(cx, cy, cw, ch, { hasLegend: seriesData.length > 1 });
|
|
250
|
+
|
|
251
|
+
// Compute axis range
|
|
252
|
+
let axisVals;
|
|
253
|
+
if (isStacked) {
|
|
254
|
+
// sum per category
|
|
255
|
+
axisVals = Array.from({ length: numCats }, (_, ci) =>
|
|
256
|
+
seriesData.reduce((s, ser) => s + (ser.values[ci] || 0), 0));
|
|
257
|
+
if (isPct) axisVals = axisVals.map(() => 100);
|
|
258
|
+
} else {
|
|
259
|
+
axisVals = seriesData.flatMap(s => s.values);
|
|
260
|
+
}
|
|
261
|
+
const range = calcAxisRange(axisVals);
|
|
262
|
+
|
|
263
|
+
const fontSize = Math.max(7, Math.min(11, b.w * 0.018)) * scale;
|
|
264
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
265
|
+
ctx.textBaseline = 'middle';
|
|
266
|
+
|
|
267
|
+
// ── Y axis (column) or X axis (bar) ─────────────────────────────────────
|
|
268
|
+
const valueSteps = Math.round((range.max - range.min) / range.step);
|
|
269
|
+
const axisLabelPad = isColumn ? b.x * 0.35 : b.h * 0.15;
|
|
270
|
+
|
|
271
|
+
if (isColumn) {
|
|
272
|
+
// Y axis: value axis on left
|
|
273
|
+
ctx.save();
|
|
274
|
+
ctx.textAlign = 'right';
|
|
275
|
+
ctx.fillStyle = '#666';
|
|
276
|
+
for (let t = 0; t <= valueSteps; t++) {
|
|
277
|
+
const val = range.min + t * range.step;
|
|
278
|
+
const pct = (val - range.min) / (range.max - range.min);
|
|
279
|
+
const gy = b.y + b.h - pct * b.h;
|
|
280
|
+
drawGridLine(ctx, b.x, gy, b.x + b.w, gy);
|
|
281
|
+
ctx.fillText(isPct ? val + '%' : fmtLabel(val), b.x - 4 * scale, gy);
|
|
282
|
+
}
|
|
283
|
+
ctx.restore();
|
|
284
|
+
|
|
285
|
+
// X axis: categories
|
|
286
|
+
ctx.save();
|
|
287
|
+
ctx.textAlign = 'center';
|
|
288
|
+
ctx.fillStyle = '#666';
|
|
289
|
+
const barGroupW = b.w / numCats;
|
|
290
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
291
|
+
const lx = b.x + barGroupW * ci + barGroupW / 2;
|
|
292
|
+
const label = String(cats[ci] ?? ci + 1);
|
|
293
|
+
ctx.fillText(label, lx, b.y + b.h + fontSize * 1.5);
|
|
294
|
+
}
|
|
295
|
+
ctx.restore();
|
|
296
|
+
|
|
297
|
+
drawAxisLine(ctx, b.x, b.y, b.x, b.y + b.h);
|
|
298
|
+
drawAxisLine(ctx, b.x, b.y + b.h, b.x + b.w, b.y + b.h);
|
|
299
|
+
|
|
300
|
+
// Draw bars
|
|
301
|
+
const barGroupW2 = b.w / numCats;
|
|
302
|
+
const gap = barGroupW2 * 0.15;
|
|
303
|
+
const groupInner = barGroupW2 - gap * 2;
|
|
304
|
+
const barW = isStacked ? groupInner : groupInner / seriesData.length;
|
|
305
|
+
|
|
306
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
307
|
+
const gx = b.x + barGroupW2 * ci + gap;
|
|
308
|
+
let stackBase = 0;
|
|
309
|
+
|
|
310
|
+
for (let si = 0; si < seriesData.length; si++) {
|
|
311
|
+
const ser = seriesData[si];
|
|
312
|
+
let val = ser.values[ci] ?? 0;
|
|
313
|
+
if (isPct) {
|
|
314
|
+
const total = seriesData.reduce((s, ss) => s + (ss.values[ci] || 0), 0);
|
|
315
|
+
val = total ? (val / total) * 100 : 0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const color = ser.dptColors[ci] || ser.color;
|
|
319
|
+
const barH = (Math.abs(val) / (range.max - range.min)) * b.h;
|
|
320
|
+
const bx = isStacked ? gx : gx + si * barW;
|
|
321
|
+
const basePct = isStacked
|
|
322
|
+
? (stackBase - range.min) / (range.max - range.min)
|
|
323
|
+
: (Math.max(0, -range.min)) / (range.max - range.min);
|
|
324
|
+
const baseY = b.y + b.h - basePct * b.h;
|
|
325
|
+
const barY = val >= 0 ? baseY - barH : baseY;
|
|
326
|
+
|
|
327
|
+
ctx.save();
|
|
328
|
+
ctx.fillStyle = color;
|
|
329
|
+
roundRect(ctx, bx, barY, isStacked ? groupInner : barW - 1 * scale, barH, 2 * scale);
|
|
330
|
+
ctx.fill();
|
|
331
|
+
// subtle top shine
|
|
332
|
+
const shine = ctx.createLinearGradient(bx, barY, bx, barY + barH * 0.3);
|
|
333
|
+
shine.addColorStop(0, 'rgba(255,255,255,0.18)');
|
|
334
|
+
shine.addColorStop(1, 'rgba(255,255,255,0)');
|
|
335
|
+
ctx.fillStyle = shine;
|
|
336
|
+
ctx.fill();
|
|
337
|
+
ctx.restore();
|
|
338
|
+
|
|
339
|
+
if (isStacked) stackBase += Math.abs(val);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
} else {
|
|
344
|
+
// HORIZONTAL BAR CHART
|
|
345
|
+
ctx.save();
|
|
346
|
+
ctx.textAlign = 'right';
|
|
347
|
+
ctx.fillStyle = '#666';
|
|
348
|
+
const barGroupH = b.h / numCats;
|
|
349
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
350
|
+
const ly = b.y + barGroupH * ci + barGroupH / 2;
|
|
351
|
+
const label = String(cats[ci] ?? ci + 1);
|
|
352
|
+
ctx.fillText(label, b.x - 4 * scale, ly);
|
|
353
|
+
}
|
|
354
|
+
ctx.restore();
|
|
355
|
+
|
|
356
|
+
ctx.save();
|
|
357
|
+
ctx.textAlign = 'center';
|
|
358
|
+
ctx.fillStyle = '#666';
|
|
359
|
+
for (let t = 0; t <= valueSteps; t++) {
|
|
360
|
+
const val = range.min + t * range.step;
|
|
361
|
+
const pct = (val - range.min) / (range.max - range.min);
|
|
362
|
+
const gx = b.x + pct * b.w;
|
|
363
|
+
drawGridLine(ctx, gx, b.y, gx, b.y + b.h);
|
|
364
|
+
ctx.fillText(isPct ? val + '%' : fmtLabel(val), gx, b.y + b.h + fontSize * 1.5);
|
|
365
|
+
}
|
|
366
|
+
ctx.restore();
|
|
367
|
+
|
|
368
|
+
drawAxisLine(ctx, b.x, b.y + b.h, b.x + b.w, b.y + b.h);
|
|
369
|
+
drawAxisLine(ctx, b.x, b.y, b.x, b.y + b.h);
|
|
370
|
+
|
|
371
|
+
const gap = barGroupH * 0.15;
|
|
372
|
+
const groupInner = barGroupH - gap * 2;
|
|
373
|
+
const barH = isStacked ? groupInner : groupInner / seriesData.length;
|
|
374
|
+
const zeroX = b.x + (-range.min / (range.max - range.min)) * b.w;
|
|
375
|
+
|
|
376
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
377
|
+
const gy = b.y + barGroupH * ci + gap;
|
|
378
|
+
let stackBase = 0;
|
|
379
|
+
|
|
380
|
+
for (let si = 0; si < seriesData.length; si++) {
|
|
381
|
+
const ser = seriesData[si];
|
|
382
|
+
let val = ser.values[ci] ?? 0;
|
|
383
|
+
if (isPct) {
|
|
384
|
+
const total = seriesData.reduce((s, ss) => s + (ss.values[ci] || 0), 0);
|
|
385
|
+
val = total ? (val / total) * 100 : 0;
|
|
386
|
+
}
|
|
387
|
+
const color = ser.dptColors[ci] || ser.color;
|
|
388
|
+
const barW = (Math.abs(val) / (range.max - range.min)) * b.w;
|
|
389
|
+
const by = isStacked ? gy : gy + si * barH;
|
|
390
|
+
const baseX = isStacked ? zeroX + (stackBase / (range.max - range.min)) * b.w : zeroX;
|
|
391
|
+
const bx = val >= 0 ? baseX : baseX - barW;
|
|
392
|
+
|
|
393
|
+
ctx.save();
|
|
394
|
+
ctx.fillStyle = color;
|
|
395
|
+
roundRect(ctx, bx, by, barW, isStacked ? groupInner : barH - 1 * scale, 2 * scale);
|
|
396
|
+
ctx.fill();
|
|
397
|
+
ctx.restore();
|
|
398
|
+
|
|
399
|
+
if (isStacked) stackBase += Math.abs(val);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (seriesData.length > 1) {
|
|
405
|
+
drawLegend(ctx, cx, cy, cw, b.legendY, seriesData, scale);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
410
|
+
// LINE CHART
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function renderLineChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
414
|
+
const lineChart = g1(chartEl, 'lineChart') || g1(chartEl, 'line3DChart');
|
|
415
|
+
const serEls = gtn(lineChart, 'ser');
|
|
416
|
+
const seriesData = serEls.map((s, i) => ({
|
|
417
|
+
name: seriesName(s),
|
|
418
|
+
values: readValues(s),
|
|
419
|
+
color: seriesColor(s, i, themeColors),
|
|
420
|
+
marker: attr(g1(s, 'marker'), 'symbol', 'none') !== 'none',
|
|
421
|
+
smooth: attr(g1(s, 'smooth'), 'val', '0') === '1',
|
|
422
|
+
}));
|
|
423
|
+
if (!seriesData.length) return;
|
|
424
|
+
|
|
425
|
+
const cats = readCategories(serEls[0]) || [];
|
|
426
|
+
const numCats = Math.max(cats.length, seriesData[0]?.values.length || 0, 1);
|
|
427
|
+
const b = chartBounds(cx, cy, cw, ch, { hasLegend: true });
|
|
428
|
+
const range = calcAxisRange(seriesData.flatMap(s => s.values));
|
|
429
|
+
const fontSize = Math.max(7, Math.min(11, b.w * 0.018)) * scale;
|
|
430
|
+
|
|
431
|
+
// Axes
|
|
432
|
+
const valueSteps = Math.round((range.max - range.min) / range.step);
|
|
433
|
+
ctx.save();
|
|
434
|
+
ctx.textAlign = 'right';
|
|
435
|
+
ctx.fillStyle = '#666';
|
|
436
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
437
|
+
for (let t = 0; t <= valueSteps; t++) {
|
|
438
|
+
const val = range.min + t * range.step;
|
|
439
|
+
const pct = (val - range.min) / (range.max - range.min);
|
|
440
|
+
const gy = b.y + b.h - pct * b.h;
|
|
441
|
+
drawGridLine(ctx, b.x, gy, b.x + b.w, gy);
|
|
442
|
+
ctx.fillText(fmtLabel(val), b.x - 4 * scale, gy);
|
|
443
|
+
}
|
|
444
|
+
ctx.restore();
|
|
445
|
+
|
|
446
|
+
ctx.save();
|
|
447
|
+
ctx.textAlign = 'center';
|
|
448
|
+
ctx.fillStyle = '#666';
|
|
449
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
450
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
451
|
+
const gx = b.x + (ci / (numCats - 1 || 1)) * b.w;
|
|
452
|
+
drawGridLine(ctx, gx, b.y, gx, b.y + b.h);
|
|
453
|
+
const label = String(cats[ci] ?? ci + 1);
|
|
454
|
+
ctx.fillText(label, gx, b.y + b.h + fontSize * 1.5);
|
|
455
|
+
}
|
|
456
|
+
ctx.restore();
|
|
457
|
+
|
|
458
|
+
drawAxisLine(ctx, b.x, b.y, b.x, b.y + b.h);
|
|
459
|
+
drawAxisLine(ctx, b.x, b.y + b.h, b.x + b.w, b.y + b.h);
|
|
460
|
+
|
|
461
|
+
// Draw each series
|
|
462
|
+
for (const ser of seriesData) {
|
|
463
|
+
const pts = ser.values.map((v, i) => {
|
|
464
|
+
const pct = (numCats > 1) ? i / (numCats - 1) : 0.5;
|
|
465
|
+
const vpct = (v - range.min) / (range.max - range.min);
|
|
466
|
+
return { x: b.x + pct * b.w, y: b.y + b.h - vpct * b.h, v };
|
|
467
|
+
}).filter(p => typeof p.v === 'number');
|
|
468
|
+
|
|
469
|
+
if (pts.length < 2) continue;
|
|
470
|
+
|
|
471
|
+
// Area fill under line
|
|
472
|
+
ctx.save();
|
|
473
|
+
ctx.beginPath();
|
|
474
|
+
ctx.moveTo(pts[0].x, b.y + b.h);
|
|
475
|
+
for (const p of pts) ctx.lineTo(p.x, p.y);
|
|
476
|
+
ctx.lineTo(pts[pts.length - 1].x, b.y + b.h);
|
|
477
|
+
ctx.closePath();
|
|
478
|
+
ctx.fillStyle = ser.color + '22';
|
|
479
|
+
ctx.fill();
|
|
480
|
+
ctx.restore();
|
|
481
|
+
|
|
482
|
+
// Line
|
|
483
|
+
ctx.save();
|
|
484
|
+
ctx.strokeStyle = ser.color;
|
|
485
|
+
ctx.lineWidth = 2 * scale;
|
|
486
|
+
ctx.lineJoin = 'round';
|
|
487
|
+
ctx.lineCap = 'round';
|
|
488
|
+
ctx.beginPath();
|
|
489
|
+
if (ser.smooth && pts.length > 2) {
|
|
490
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
491
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
492
|
+
const cpx = (pts[i].x + pts[i + 1].x) / 2;
|
|
493
|
+
const cpy = (pts[i].y + pts[i + 1].y) / 2;
|
|
494
|
+
ctx.quadraticCurveTo(pts[i].x, pts[i].y, cpx, cpy);
|
|
495
|
+
}
|
|
496
|
+
ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
|
|
497
|
+
} else {
|
|
498
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
499
|
+
for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
|
|
500
|
+
}
|
|
501
|
+
ctx.stroke();
|
|
502
|
+
ctx.restore();
|
|
503
|
+
|
|
504
|
+
// Markers
|
|
505
|
+
const markerR = 3.5 * scale;
|
|
506
|
+
for (const p of pts) {
|
|
507
|
+
ctx.save();
|
|
508
|
+
ctx.fillStyle = ser.color;
|
|
509
|
+
ctx.strokeStyle = '#fff';
|
|
510
|
+
ctx.lineWidth = 1.5 * scale;
|
|
511
|
+
ctx.beginPath();
|
|
512
|
+
ctx.arc(p.x, p.y, markerR, 0, Math.PI * 2);
|
|
513
|
+
ctx.fill();
|
|
514
|
+
ctx.stroke();
|
|
515
|
+
ctx.restore();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
drawLegend(ctx, cx, cy, cw, b.legendY, seriesData, scale);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
523
|
+
// AREA CHART
|
|
524
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
function renderAreaChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
527
|
+
const areaChart = g1(chartEl, 'areaChart') || g1(chartEl, 'area3DChart');
|
|
528
|
+
const grouping = attr(areaChart, 'grouping', 'standard');
|
|
529
|
+
const isPct = grouping === 'percentStacked';
|
|
530
|
+
const isStacked = grouping === 'stacked' || isPct;
|
|
531
|
+
|
|
532
|
+
const serEls = gtn(areaChart, 'ser');
|
|
533
|
+
// Reverse so first series is on top
|
|
534
|
+
const seriesData = serEls.map((s, i) => ({
|
|
535
|
+
name: seriesName(s),
|
|
536
|
+
values: readValues(s),
|
|
537
|
+
color: seriesColor(s, i, themeColors),
|
|
538
|
+
})).reverse();
|
|
539
|
+
if (!seriesData.length) return;
|
|
540
|
+
|
|
541
|
+
const numCats = Math.max(...seriesData.map(s => s.values.length), 1);
|
|
542
|
+
const b = chartBounds(cx, cy, cw, ch, { hasLegend: true });
|
|
543
|
+
|
|
544
|
+
let maxVal = 0;
|
|
545
|
+
if (isPct) {
|
|
546
|
+
maxVal = 100;
|
|
547
|
+
} else {
|
|
548
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
549
|
+
const sum = seriesData.reduce((s, ser) => s + (ser.values[ci] || 0), 0);
|
|
550
|
+
maxVal = Math.max(maxVal, sum);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const range = { min: 0, max: maxVal || 100, step: niceStep(maxVal || 100) };
|
|
554
|
+
|
|
555
|
+
// Axes
|
|
556
|
+
const valueSteps = Math.round((range.max - range.min) / range.step);
|
|
557
|
+
const fontSize = Math.max(7, Math.min(11, b.w * 0.018)) * scale;
|
|
558
|
+
ctx.save();
|
|
559
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
560
|
+
ctx.textAlign = 'right';
|
|
561
|
+
ctx.fillStyle = '#666';
|
|
562
|
+
for (let t = 0; t <= valueSteps; t++) {
|
|
563
|
+
const val = range.min + t * range.step;
|
|
564
|
+
const pct = (val - range.min) / (range.max - range.min);
|
|
565
|
+
const gy = b.y + b.h - pct * b.h;
|
|
566
|
+
drawGridLine(ctx, b.x, gy, b.x + b.w, gy);
|
|
567
|
+
ctx.fillText(isPct ? val + '%' : fmtLabel(val), b.x - 4 * scale, gy);
|
|
568
|
+
}
|
|
569
|
+
ctx.restore();
|
|
570
|
+
|
|
571
|
+
drawAxisLine(ctx, b.x, b.y, b.x, b.y + b.h);
|
|
572
|
+
drawAxisLine(ctx, b.x, b.y + b.h, b.x + b.w, b.y + b.h);
|
|
573
|
+
|
|
574
|
+
const stacks = new Array(numCats).fill(0);
|
|
575
|
+
|
|
576
|
+
for (const ser of seriesData) {
|
|
577
|
+
ctx.save();
|
|
578
|
+
ctx.beginPath();
|
|
579
|
+
const topPts = [];
|
|
580
|
+
|
|
581
|
+
for (let ci = 0; ci < numCats; ci++) {
|
|
582
|
+
let val = ser.values[ci] ?? 0;
|
|
583
|
+
if (isPct) {
|
|
584
|
+
const total = seriesData.reduce((s, ss) => s + (ss.values[ci] || 0), 0);
|
|
585
|
+
val = total ? (val / total) * 100 : 0;
|
|
586
|
+
}
|
|
587
|
+
const base = isStacked ? stacks[ci] : 0;
|
|
588
|
+
const top = base + Math.abs(val);
|
|
589
|
+
const bpct = (base - range.min) / (range.max - range.min);
|
|
590
|
+
const tpct = (top - range.min) / (range.max - range.min);
|
|
591
|
+
const xpos = b.x + (ci / (numCats - 1 || 1)) * b.w;
|
|
592
|
+
topPts.push({ x: xpos, y: b.y + b.h - tpct * b.h, baseY: b.y + b.h - bpct * b.h });
|
|
593
|
+
if (isStacked) stacks[ci] += Math.abs(val);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Bottom edge (previous stack or zero line)
|
|
597
|
+
ctx.moveTo(topPts[0].x, topPts[0].baseY);
|
|
598
|
+
for (const p of topPts) ctx.lineTo(p.x, p.baseY);
|
|
599
|
+
// Top edge reversed
|
|
600
|
+
for (let i = topPts.length - 1; i >= 0; i--) ctx.lineTo(topPts[i].x, topPts[i].y);
|
|
601
|
+
ctx.closePath();
|
|
602
|
+
ctx.fillStyle = ser.color + 'cc';
|
|
603
|
+
ctx.fill();
|
|
604
|
+
|
|
605
|
+
// Top outline
|
|
606
|
+
ctx.beginPath();
|
|
607
|
+
ctx.moveTo(topPts[0].x, topPts[0].y);
|
|
608
|
+
for (const p of topPts.slice(1)) ctx.lineTo(p.x, p.y);
|
|
609
|
+
ctx.strokeStyle = ser.color;
|
|
610
|
+
ctx.lineWidth = 1.5 * scale;
|
|
611
|
+
ctx.stroke();
|
|
612
|
+
ctx.restore();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
drawLegend(ctx, cx, cy, cw, b.legendY, [...seriesData].reverse(), scale);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
|
+
// PIE / DOUGHNUT CHART
|
|
620
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
function renderPieChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
623
|
+
const pieChart = g1(chartEl, 'pieChart') || g1(chartEl, 'pie3DChart');
|
|
624
|
+
const doughnut = g1(chartEl, 'doughnutChart');
|
|
625
|
+
const chartNode = pieChart || doughnut;
|
|
626
|
+
const isDoughnut = !!doughnut;
|
|
627
|
+
|
|
628
|
+
const serEls = gtn(chartNode, 'ser');
|
|
629
|
+
if (!serEls.length) return;
|
|
630
|
+
|
|
631
|
+
const ser = serEls[0]; // pie/doughnut always 1 series
|
|
632
|
+
const values = readValues(ser).map(v => (typeof v === 'number' && v > 0 ? v : 0));
|
|
633
|
+
const cats = readCategories(ser);
|
|
634
|
+
const dptClrs = dataPointColors(ser, themeColors);
|
|
635
|
+
|
|
636
|
+
const total = values.reduce((a, b) => a + b, 0);
|
|
637
|
+
if (!total) return;
|
|
638
|
+
|
|
639
|
+
// Center and radius
|
|
640
|
+
const cr = Math.min(cw, ch) * 0.38;
|
|
641
|
+
const pcx = cx + cw * 0.44;
|
|
642
|
+
const pcy = cy + ch * 0.50;
|
|
643
|
+
const holeR = isDoughnut ? cr * 0.55 : 0;
|
|
644
|
+
|
|
645
|
+
let startAngle = -Math.PI / 2;
|
|
646
|
+
|
|
647
|
+
for (let i = 0; i < values.length; i++) {
|
|
648
|
+
if (!values[i]) continue;
|
|
649
|
+
const sweep = (values[i] / total) * Math.PI * 2;
|
|
650
|
+
const color = dptClrs[i] || seriesColor(ser, i, themeColors);
|
|
651
|
+
const midA = startAngle + sweep / 2;
|
|
652
|
+
// Slight explode on hover-ish effect (first slice only)
|
|
653
|
+
const explode = i === 0 ? cr * 0.04 : 0;
|
|
654
|
+
const eox = explode * Math.cos(midA);
|
|
655
|
+
const eoy = explode * Math.sin(midA);
|
|
656
|
+
|
|
657
|
+
ctx.save();
|
|
658
|
+
ctx.beginPath();
|
|
659
|
+
ctx.moveTo(pcx + eox, pcy + eoy);
|
|
660
|
+
ctx.arc(pcx + eox, pcy + eoy, cr, startAngle, startAngle + sweep);
|
|
661
|
+
ctx.closePath();
|
|
662
|
+
|
|
663
|
+
ctx.fillStyle = color;
|
|
664
|
+
ctx.fill();
|
|
665
|
+
|
|
666
|
+
// Subtle shadow on each slice
|
|
667
|
+
ctx.shadowColor = 'rgba(0,0,0,0.12)';
|
|
668
|
+
ctx.shadowBlur = 4 * scale;
|
|
669
|
+
ctx.strokeStyle = '#fff';
|
|
670
|
+
ctx.lineWidth = 1.5 * scale;
|
|
671
|
+
ctx.stroke();
|
|
672
|
+
ctx.restore();
|
|
673
|
+
|
|
674
|
+
// Hole
|
|
675
|
+
if (holeR > 0) {
|
|
676
|
+
ctx.save();
|
|
677
|
+
ctx.beginPath();
|
|
678
|
+
ctx.arc(pcx, pcy, holeR, 0, Math.PI * 2);
|
|
679
|
+
ctx.fillStyle = '#fff';
|
|
680
|
+
ctx.fill();
|
|
681
|
+
ctx.restore();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// % label on large slices
|
|
685
|
+
const pct = values[i] / total;
|
|
686
|
+
if (pct > 0.05) {
|
|
687
|
+
const lx = pcx + eox + (cr * 0.65) * Math.cos(midA);
|
|
688
|
+
const ly = pcy + eoy + (cr * 0.65) * Math.sin(midA);
|
|
689
|
+
const fontSize = Math.max(8, Math.min(13, cr * 0.15)) * scale;
|
|
690
|
+
ctx.save();
|
|
691
|
+
ctx.fillStyle = '#fff';
|
|
692
|
+
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
693
|
+
ctx.textAlign = 'center';
|
|
694
|
+
ctx.textBaseline = 'middle';
|
|
695
|
+
ctx.fillText(Math.round(pct * 100) + '%', lx, ly);
|
|
696
|
+
ctx.restore();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
startAngle += sweep;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Doughnut centre label
|
|
703
|
+
if (isDoughnut) {
|
|
704
|
+
const fontSize = Math.max(10, Math.min(16, cr * 0.22)) * scale;
|
|
705
|
+
ctx.save();
|
|
706
|
+
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
707
|
+
ctx.fillStyle = '#333';
|
|
708
|
+
ctx.textAlign = 'center';
|
|
709
|
+
ctx.textBaseline = 'middle';
|
|
710
|
+
ctx.fillText(fmtLabel(total), pcx, pcy);
|
|
711
|
+
ctx.restore();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Legend on right side
|
|
715
|
+
const legX = cx + cw * 0.78;
|
|
716
|
+
const fontSize = Math.max(8, Math.min(12, cw * 0.022)) * scale;
|
|
717
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
718
|
+
ctx.textBaseline = 'middle';
|
|
719
|
+
const rowH = fontSize * 1.8;
|
|
720
|
+
const startY = pcy - (values.length * rowH) / 2;
|
|
721
|
+
|
|
722
|
+
for (let i = 0; i < values.length; i++) {
|
|
723
|
+
const lx = legX;
|
|
724
|
+
const ly = startY + i * rowH;
|
|
725
|
+
const color = dptClrs[i] || seriesColor(ser, i, themeColors);
|
|
726
|
+
ctx.fillStyle = color;
|
|
727
|
+
roundRect(ctx, lx, ly - fontSize * 0.5, fontSize * 1.2, fontSize);
|
|
728
|
+
ctx.fill();
|
|
729
|
+
ctx.fillStyle = '#444';
|
|
730
|
+
const label = String(cats[i] ?? `Item ${i + 1}`);
|
|
731
|
+
ctx.fillText(label, lx + fontSize * 1.5, ly);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
736
|
+
// SCATTER CHART
|
|
737
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
function renderScatterChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
740
|
+
const scatterChart = g1(chartEl, 'scatterChart') || g1(chartEl, 'bubbleChart');
|
|
741
|
+
const serEls = gtn(scatterChart, 'ser');
|
|
742
|
+
const seriesData = serEls.map((s, i) => {
|
|
743
|
+
const xVals = readCache(g1(g1(s, 'xVal'), 'numCache'));
|
|
744
|
+
const yVals = readCache(g1(g1(s, 'yVal'), 'numCache'));
|
|
745
|
+
const bubSz = readCache(g1(g1(s, 'bubbleSize'), 'numCache'));
|
|
746
|
+
return {
|
|
747
|
+
name: seriesName(s),
|
|
748
|
+
color: seriesColor(s, i, themeColors),
|
|
749
|
+
points: xVals.map((x, j) => ({ x, y: yVals[j], r: bubSz[j] }))
|
|
750
|
+
.filter(p => typeof p.x === 'number' && typeof p.y === 'number'),
|
|
751
|
+
};
|
|
752
|
+
});
|
|
753
|
+
if (!seriesData.length) return;
|
|
754
|
+
|
|
755
|
+
const b = chartBounds(cx, cy, cw, ch, { hasLegend: seriesData.length > 1 });
|
|
756
|
+
|
|
757
|
+
const allX = seriesData.flatMap(s => s.points.map(p => p.x));
|
|
758
|
+
const allY = seriesData.flatMap(s => s.points.map(p => p.y));
|
|
759
|
+
const rangeX = calcAxisRange(allX, false);
|
|
760
|
+
const rangeY = calcAxisRange(allY);
|
|
761
|
+
const fontSize = Math.max(7, Math.min(11, b.w * 0.018)) * scale;
|
|
762
|
+
|
|
763
|
+
// Grid and axes
|
|
764
|
+
const stepsX = Math.round((rangeX.max - rangeX.min) / rangeX.step);
|
|
765
|
+
const stepsY = Math.round((rangeY.max - rangeY.min) / rangeY.step);
|
|
766
|
+
|
|
767
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
768
|
+
ctx.save();
|
|
769
|
+
ctx.textAlign = 'center';
|
|
770
|
+
ctx.fillStyle = '#666';
|
|
771
|
+
for (let t = 0; t <= stepsX; t++) {
|
|
772
|
+
const val = rangeX.min + t * rangeX.step;
|
|
773
|
+
const pct = (val - rangeX.min) / (rangeX.max - rangeX.min);
|
|
774
|
+
const gx = b.x + pct * b.w;
|
|
775
|
+
drawGridLine(ctx, gx, b.y, gx, b.y + b.h);
|
|
776
|
+
ctx.fillText(fmtLabel(val), gx, b.y + b.h + fontSize * 1.5);
|
|
777
|
+
}
|
|
778
|
+
ctx.textAlign = 'right';
|
|
779
|
+
for (let t = 0; t <= stepsY; t++) {
|
|
780
|
+
const val = rangeY.min + t * rangeY.step;
|
|
781
|
+
const pct = (val - rangeY.min) / (rangeY.max - rangeY.min);
|
|
782
|
+
const gy = b.y + b.h - pct * b.h;
|
|
783
|
+
drawGridLine(ctx, b.x, gy, b.x + b.w, gy);
|
|
784
|
+
ctx.fillText(fmtLabel(val), b.x - 4 * scale, gy);
|
|
785
|
+
}
|
|
786
|
+
ctx.restore();
|
|
787
|
+
|
|
788
|
+
drawAxisLine(ctx, b.x, b.y, b.x, b.y + b.h);
|
|
789
|
+
drawAxisLine(ctx, b.x, b.y + b.h, b.x + b.w, b.y + b.h);
|
|
790
|
+
|
|
791
|
+
const maxR = seriesData.flatMap(s => s.points.map(p => p.r ?? 1));
|
|
792
|
+
const maxBubble = Math.max(...maxR.filter(v => typeof v === 'number'), 1);
|
|
793
|
+
const maxBubbleR = Math.min(b.w, b.h) * 0.06;
|
|
794
|
+
|
|
795
|
+
for (const ser of seriesData) {
|
|
796
|
+
for (const pt of ser.points) {
|
|
797
|
+
const px = b.x + ((pt.x - rangeX.min) / (rangeX.max - rangeX.min)) * b.w;
|
|
798
|
+
const py = b.y + b.h - ((pt.y - rangeY.min) / (rangeY.max - rangeY.min)) * b.h;
|
|
799
|
+
const r = pt.r != null ? (pt.r / maxBubble) * maxBubbleR : 4 * scale;
|
|
800
|
+
ctx.save();
|
|
801
|
+
ctx.fillStyle = ser.color + 'aa';
|
|
802
|
+
ctx.strokeStyle = ser.color;
|
|
803
|
+
ctx.lineWidth = 1 * scale;
|
|
804
|
+
ctx.beginPath();
|
|
805
|
+
ctx.arc(px, py, r, 0, Math.PI * 2);
|
|
806
|
+
ctx.fill();
|
|
807
|
+
ctx.stroke();
|
|
808
|
+
ctx.restore();
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (seriesData.length > 1) {
|
|
813
|
+
drawLegend(ctx, cx, cy, cw, b.legendY, seriesData, scale);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
818
|
+
// RADAR CHART
|
|
819
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
function renderRadarChart(ctx, chartEl, cx, cy, cw, ch, themeColors, scale) {
|
|
822
|
+
const radarChart = g1(chartEl, 'radarChart');
|
|
823
|
+
const serEls = gtn(radarChart, 'ser');
|
|
824
|
+
const seriesData = serEls.map((s, i) => ({
|
|
825
|
+
name: seriesName(s),
|
|
826
|
+
values: readValues(s),
|
|
827
|
+
color: seriesColor(s, i, themeColors),
|
|
828
|
+
}));
|
|
829
|
+
if (!seriesData.length) return;
|
|
830
|
+
|
|
831
|
+
const cats = readCategories(serEls[0]) || [];
|
|
832
|
+
const N = cats.length || seriesData[0]?.values.length || 0;
|
|
833
|
+
if (N < 3) return;
|
|
834
|
+
|
|
835
|
+
const pcx = cx + cw * 0.50;
|
|
836
|
+
const pcy = cy + ch * 0.50;
|
|
837
|
+
const r = Math.min(cw, ch) * 0.34;
|
|
838
|
+
const range = calcAxisRange(seriesData.flatMap(s => s.values));
|
|
839
|
+
const rings = 4;
|
|
840
|
+
|
|
841
|
+
// Web grid
|
|
842
|
+
for (let ring = 1; ring <= rings; ring++) {
|
|
843
|
+
const rr = r * ring / rings;
|
|
844
|
+
ctx.save();
|
|
845
|
+
ctx.beginPath();
|
|
846
|
+
for (let i = 0; i < N; i++) {
|
|
847
|
+
const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
|
|
848
|
+
const px = pcx + rr * Math.cos(angle);
|
|
849
|
+
const py = pcy + rr * Math.sin(angle);
|
|
850
|
+
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
|
851
|
+
}
|
|
852
|
+
ctx.closePath();
|
|
853
|
+
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
|
|
854
|
+
ctx.lineWidth = 0.5;
|
|
855
|
+
ctx.stroke();
|
|
856
|
+
ctx.restore();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Spokes
|
|
860
|
+
for (let i = 0; i < N; i++) {
|
|
861
|
+
const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
|
|
862
|
+
drawAxisLine(ctx, pcx, pcy, pcx + r * Math.cos(angle), pcy + r * Math.sin(angle));
|
|
863
|
+
// Category labels
|
|
864
|
+
const lx = pcx + (r + 16 * scale) * Math.cos(angle);
|
|
865
|
+
const ly = pcy + (r + 16 * scale) * Math.sin(angle);
|
|
866
|
+
ctx.save();
|
|
867
|
+
ctx.font = `${Math.max(7, 11 * scale)}px sans-serif`;
|
|
868
|
+
ctx.fillStyle = '#555';
|
|
869
|
+
ctx.textAlign = 'center';
|
|
870
|
+
ctx.textBaseline = 'middle';
|
|
871
|
+
ctx.fillText(String(cats[i] ?? i + 1), lx, ly);
|
|
872
|
+
ctx.restore();
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Data polygons
|
|
876
|
+
for (const ser of seriesData) {
|
|
877
|
+
const pts = ser.values.map((v, i) => {
|
|
878
|
+
const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
|
|
879
|
+
const pct = (v - range.min) / (range.max - range.min);
|
|
880
|
+
return {
|
|
881
|
+
x: pcx + r * pct * Math.cos(angle),
|
|
882
|
+
y: pcy + r * pct * Math.sin(angle),
|
|
883
|
+
};
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
ctx.save();
|
|
887
|
+
ctx.beginPath();
|
|
888
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
889
|
+
for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
|
|
890
|
+
ctx.closePath();
|
|
891
|
+
ctx.fillStyle = ser.color + '44';
|
|
892
|
+
ctx.fill();
|
|
893
|
+
ctx.strokeStyle = ser.color;
|
|
894
|
+
ctx.lineWidth = 1.5 * scale;
|
|
895
|
+
ctx.stroke();
|
|
896
|
+
ctx.restore();
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
drawLegend(ctx, cx, cy, cw, cy + ch - 20 * scale, seriesData, scale);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
903
|
+
// CHART TITLE
|
|
904
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
function drawChartTitle(ctx, chartEl, cx, cy, cw, scale) {
|
|
907
|
+
const titleEl = g1(g1(chartEl, 'chart'), 'title');
|
|
908
|
+
if (!titleEl) return false;
|
|
909
|
+
const txEl = g1(titleEl, 'tx');
|
|
910
|
+
if (!txEl) return false;
|
|
911
|
+
let text = '';
|
|
912
|
+
for (const t of gtn(txEl, 't')) text += t.textContent;
|
|
913
|
+
if (!text.trim()) return false;
|
|
914
|
+
|
|
915
|
+
const fontSize = Math.max(10, Math.min(16, cw * 0.030)) * scale;
|
|
916
|
+
ctx.save();
|
|
917
|
+
ctx.font = `bold ${fontSize}px sans-serif`;
|
|
918
|
+
ctx.fillStyle = '#333';
|
|
919
|
+
ctx.textAlign = 'center';
|
|
920
|
+
ctx.textBaseline = 'top';
|
|
921
|
+
ctx.fillText(text, cx + cw / 2, cy + 6 * scale);
|
|
922
|
+
ctx.restore();
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
927
|
+
// MAIN ENTRY POINT
|
|
928
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Render a chart from its parsed XML document.
|
|
932
|
+
*
|
|
933
|
+
* @param {CanvasRenderingContext2D} ctx
|
|
934
|
+
* @param {Document} chartDoc — parsed chart1.xml
|
|
935
|
+
* @param {number} cx, cy, cw, ch — bounding box on canvas (px)
|
|
936
|
+
* @param {object} themeColors — resolved theme colours
|
|
937
|
+
* @param {number} scale — px-per-EMU scale factor
|
|
938
|
+
*/
|
|
939
|
+
export function renderChart(ctx, chartDoc, cx, cy, cw, ch, themeColors, scale) {
|
|
940
|
+
if (!chartDoc) return;
|
|
941
|
+
const chartEl = chartDoc;
|
|
942
|
+
|
|
943
|
+
// Chart background
|
|
944
|
+
ctx.save();
|
|
945
|
+
ctx.fillStyle = '#fff';
|
|
946
|
+
roundRect(ctx, cx, cy, cw, ch, 4 * scale);
|
|
947
|
+
ctx.fill();
|
|
948
|
+
ctx.strokeStyle = '#ddd';
|
|
949
|
+
ctx.lineWidth = 0.5;
|
|
950
|
+
ctx.stroke();
|
|
951
|
+
ctx.restore();
|
|
952
|
+
|
|
953
|
+
// Title
|
|
954
|
+
const hasTitle = drawChartTitle(ctx, chartEl, cx, cy, cw, scale);
|
|
955
|
+
|
|
956
|
+
const ty = hasTitle ? cy + Math.min(ch * 0.08, 24 * scale) : cy;
|
|
957
|
+
const th = ch - (ty - cy);
|
|
958
|
+
|
|
959
|
+
const plotArea = g1(chartEl, 'plotArea');
|
|
960
|
+
if (!plotArea) return;
|
|
961
|
+
|
|
962
|
+
const b3d = g1(plotArea, 'bar3DChart') || g1(plotArea, 'barChart');
|
|
963
|
+
const l3d = g1(plotArea, 'line3DChart') || g1(plotArea, 'lineChart');
|
|
964
|
+
const a3d = g1(plotArea, 'area3DChart') || g1(plotArea, 'areaChart');
|
|
965
|
+
const p3d = g1(plotArea, 'pie3DChart') || g1(plotArea, 'pieChart');
|
|
966
|
+
const dnut = g1(plotArea, 'doughnutChart');
|
|
967
|
+
const sct = g1(plotArea, 'scatterChart') || g1(plotArea, 'bubbleChart');
|
|
968
|
+
const rdr = g1(plotArea, 'radarChart');
|
|
969
|
+
|
|
970
|
+
// Pass plotArea as 'chartEl' so each renderer can find its chart node
|
|
971
|
+
if (b3d) renderBarChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
972
|
+
else if (l3d) renderLineChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
973
|
+
else if (a3d) renderAreaChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
974
|
+
else if (dnut) renderPieChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
975
|
+
else if (p3d) renderPieChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
976
|
+
else if (sct) renderScatterChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
977
|
+
else if (rdr) renderRadarChart(ctx, plotArea, cx, ty, cw, th, themeColors, scale);
|
|
978
|
+
else {
|
|
979
|
+
// Unknown chart type — show type name
|
|
980
|
+
ctx.save();
|
|
981
|
+
ctx.fillStyle = '#aaa';
|
|
982
|
+
ctx.font = `${12 * scale}px sans-serif`;
|
|
983
|
+
ctx.textAlign = 'center';
|
|
984
|
+
ctx.textBaseline = 'middle';
|
|
985
|
+
const typeEl = plotArea.firstElementChild;
|
|
986
|
+
ctx.fillText(typeEl?.localName ?? 'Chart', cx + cw / 2, cy + ch / 2);
|
|
987
|
+
ctx.restore();
|
|
988
|
+
}
|
|
989
|
+
}
|