@openpresentation/opf-render 0.11.2 → 0.11.4

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/dist/charts.js ADDED
@@ -0,0 +1,1009 @@
1
+ // Chart previews for the OPF chart type catalog (FF-22).
2
+ //
3
+ // The core catalog keeps one chart type per Office chart construct and
4
+ // deprecates the rest with a replacement id (`deprecation.replacedBy`,
5
+ // `mappings.openxml`). The tables below are carried here so the preview stays
6
+ // synchronous and self-contained; test/chart-catalog.mjs checks them against
7
+ // the installed core catalog. Kept ids render the construct PowerPoint shows
8
+ // for the exported chart; deprecated ids render exactly like their replacement;
9
+ // any other id returns null so the caller keeps its legacy single-series preview.
10
+ import { chartColorForFill, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
11
+
12
+ // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
13
+ // per-surface contrast adjustment (`chartColorForFill`).
14
+ export const CHART_SERIES_COLORS = Object.freeze([
15
+ "#2874A6", "#1B4F72", "#5499C7", "#7BDBB2", "#3AC67A", "#24A89E",
16
+ "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
17
+ ]);
18
+
19
+ const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
20
+ const line = (grouping, markers) => ({ kind: "line", grouping, markers });
21
+ const area = (grouping) => ({ kind: "area", grouping });
22
+ const radar = (style) => ({ kind: "radar", style, markers: style === "marker" });
23
+
24
+ export const CHART_TYPES = Object.freeze({
25
+ column: bar("col", "clustered"),
26
+ "stacked-column-3x": bar("col", "stacked"),
27
+ "100pct-stacked-column-3x": bar("col", "percentStacked"),
28
+ bar: bar("bar", "clustered"),
29
+ "stacked-bar-3x": bar("bar", "stacked"),
30
+ "100pct-stacked-bar-3x": bar("bar", "percentStacked"),
31
+ line: line("standard", false),
32
+ "line-with-markers": line("standard", true),
33
+ "stacked-line-3x": line("stacked", false),
34
+ "stacked-line-with-markers-3x": line("stacked", true),
35
+ area: area("standard"),
36
+ "stacked-area-3x": area("stacked"),
37
+ "100pct-stacked-area-3x": area("percentStacked"),
38
+ pie: { kind: "pie" },
39
+ doughnut: { kind: "doughnut" },
40
+ scatter: { kind: "scatter" },
41
+ radar: radar("standard"),
42
+ "radar-with-markers": radar("marker"),
43
+ "filled-radar": radar("filled"),
44
+ treemap: { kind: "treemap" },
45
+ histogram: { kind: "histogram" },
46
+ pareto: { kind: "pareto" },
47
+ "box-and-whisker": { kind: "box" },
48
+ waterfall: { kind: "waterfall" },
49
+ funnel: { kind: "funnel" },
50
+ world: { kind: "map" }
51
+ });
52
+
53
+ const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
54
+ const suffixes = (base, target) => Object.fromEntries(["", "-2x", "-3x", "-4x", "-5x", "-6x"].map((suffix) => [`${base}${suffix}`, target]));
55
+
56
+ // Deprecated core ids -> kept replacement (core spec/catalogs/chart-types `deprecation.replacedBy`).
57
+ export const DEPRECATED_CHART_TYPES = Object.freeze({
58
+ ...variants("100pct-bullet-bar", "100pct-stacked-bar-3x"),
59
+ "100pct-progress-bar": "100pct-stacked-bar-3x",
60
+ "100pct-stacked-bar-2x": "100pct-stacked-bar-3x",
61
+ ...variants("100pct-bullet-column", "100pct-stacked-column-3x"),
62
+ "100pct-stacked-column-2x": "100pct-stacked-column-3x",
63
+ "100pct-stacked-area-2x": "100pct-stacked-area-3x",
64
+ australia: "world",
65
+ canada: "world",
66
+ "united-kingdom": "world",
67
+ "united-states": "world",
68
+ "box-and-whisker-2x": "box-and-whisker",
69
+ "box-and-whisker-3x": "box-and-whisker",
70
+ ...variants("bullet-bar", "bar"),
71
+ "clustered-bar-2x": "bar",
72
+ ...variants("bullet-column", "column"),
73
+ "clustered-column": "column",
74
+ ...suffixes("dot-plot", "scatter"),
75
+ dumbbell: "scatter",
76
+ "line-2x": "line",
77
+ "line-3x": "line",
78
+ "line-with-high-low": "line",
79
+ ...suffixes("sparkline", "line"),
80
+ "line-with-high-low-and-markers": "line-with-markers",
81
+ "line-with-markers-2x": "line-with-markers",
82
+ "line-with-markers-3x": "line-with-markers",
83
+ "stacked-area-2x": "stacked-area-3x",
84
+ "stacked-bar-2x": "stacked-bar-3x",
85
+ "stacked-column-2x": "stacked-column-3x",
86
+ "stacked-line-2x": "stacked-line-3x",
87
+ "stacked-line-with-markers-2x": "stacked-line-with-markers-3x",
88
+ "treemap-2x": "treemap",
89
+ "treemap-3x": "treemap"
90
+ });
91
+
92
+ const ALIASES = Object.freeze({ donut: "doughnut" });
93
+
94
+ /** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
95
+ export function resolveChartType(type) {
96
+ const raw = String(type ?? "").trim().toLowerCase();
97
+ const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
98
+ return Object.hasOwn(CHART_TYPES, id) ? id : null;
99
+ }
100
+
101
+ const RENDERERS = {
102
+ bar: renderCategoryChart,
103
+ line: renderCategoryChart,
104
+ area: renderCategoryChart,
105
+ pie: renderCircularChart,
106
+ doughnut: renderCircularChart,
107
+ scatter: renderScatterChart,
108
+ radar: renderRadarChart,
109
+ // Chartex constructs (opf-pptx writes them as cx:chartSpace parts, FF-22b).
110
+ treemap: renderTreemapChart,
111
+ histogram: renderHistogramChart,
112
+ pareto: renderHistogramChart,
113
+ box: renderBoxWhiskerChart,
114
+ waterfall: renderWaterfallChart,
115
+ funnel: renderFunnelChart,
116
+ map: renderRegionMapChart
117
+ };
118
+
119
+ // Chartex constructs also accept a lone value column (the exporter plots it
120
+ // against row numbers, or bins it for histogram and pareto).
121
+ const CHARTEX_KINDS = new Set(["treemap", "histogram", "pareto", "box", "waterfall", "funnel", "map"]);
122
+
123
+ /**
124
+ * Render a catalog chart. Returns null when the id is outside the catalog, has
125
+ * no preview renderer, or carries no inline category-major data, so the caller
126
+ * can keep its legacy output ("No chart data" for unresolved sources).
127
+ */
128
+ export function renderCatalogChart(item, box, bound, options, svg) {
129
+ const id = resolveChartType(item.value?.type);
130
+ const spec = id && CHART_TYPES[id];
131
+ const render = spec && RENDERERS[spec.kind];
132
+ const data = item.value?.data;
133
+ const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
134
+ const columns = Array.isArray(data?.columns) ? data.columns : [];
135
+ if (!render || !rows.length || columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
136
+ const c = chartContext(item, box, bound, options, svg, rows, columns);
137
+ c.mark("rect", { x: box.x, y: box.y, width: box.width, height: box.height, fill: c.surface, stroke: bound.design.colors.border, "stroke-width": 1 }, item.path);
138
+ render(c, spec);
139
+ return svg.tag("g", { ...svg.traceAttrs(options, item.path), "data-opf-chart": options.trace ? id : undefined }, c.children.join("\n"));
140
+ }
141
+
142
+ function chartContext(item, box, bound, options, svg, rows, columns) {
143
+ const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
144
+ const composition = bound.composition ?? bound.geometry.composition ?? {};
145
+ const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
146
+ const surface = bound.design.colors.surface;
147
+ const labelColor = textColorForFill(surface, bound.design.colors.text);
148
+ const style = resolveTextStyle({ fontFamily: bound.design.fonts.body, fontWeight: 400, italic: false, path: item.path }, options.textMeasurement);
149
+ const measure = textWidthMeasurer(style, options.textMeasurement);
150
+ const children = [];
151
+ const n = svg.stableNumber;
152
+ const numericAttrs = new Set(["x", "y", "width", "height", "cx", "cy", "r", "x1", "x2", "y1", "y2", "stroke-width"]);
153
+ const c = {
154
+ item, box, bound, options, svg, rows, columns, children, u, fontPx, surface, labelColor,
155
+ path: item.path,
156
+ pt: u * 4 / 3,
157
+ pad: 10 * u,
158
+ lineHeight: fontPx * 1.5,
159
+ colors: CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color)),
160
+ gridColor: bound.design.colors.border,
161
+ axisColor: "#888888",
162
+ number: chartNumber,
163
+ label: (value) => value === null || value === undefined ? "" : String(value),
164
+ width: (value) => measure(String(value), fontPx),
165
+ num: n,
166
+ mark(name, attrs, path) {
167
+ const out = {};
168
+ for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
169
+ children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
170
+ },
171
+ text(value, rect, path, align = "center") {
172
+ if (!(rect.width > 0 && rect.height > 0)) return;
173
+ children.push(svg.renderTextBox(String(value), rect, bound, {
174
+ path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
175
+ fill: labelColor, options, align, verticalAlign: "middle"
176
+ }));
177
+ },
178
+ series(first = 1) {
179
+ return columns.slice(first).map((name, offset) => ({
180
+ name: c.label(name), column: first + offset, index: offset,
181
+ values: rows.map((row) => chartNumber(row[first + offset]))
182
+ }));
183
+ }
184
+ };
185
+ return c;
186
+ }
187
+
188
+ export function chartNumber(value) {
189
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
190
+ if (typeof value === "string" && value.trim()) {
191
+ const parsed = Number(value);
192
+ return Number.isFinite(parsed) ? parsed : null;
193
+ }
194
+ return null;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Shared axis and legend helpers
199
+
200
+ /**
201
+ * Office-like automatic value axis: include zero unless every value sits in
202
+ * the top sixth of the range, add 5% headroom, then choose the smallest
203
+ * 1/2/5 x 10^k major unit that fits `maxIntervals`.
204
+ */
205
+ export function niceScale(dataMin, dataMax, maxIntervals = 10, { percent = false } = {}) {
206
+ const limit = Number.isFinite(maxIntervals) ? Math.max(1, Math.floor(maxIntervals)) : 10;
207
+ if (percent) {
208
+ const min = dataMin < -1e-9 ? -1 : 0, max = dataMax > 1e-9 || min === 0 ? 1 : 0;
209
+ return scaleFrom(min, max, pickStep(min, max, limit, [0.1, 0.2, 0.25, 0.5, 1]));
210
+ }
211
+ if (!Number.isFinite(dataMin) || !Number.isFinite(dataMax)) { dataMin = 0; dataMax = 1; }
212
+ let lo = Math.min(dataMin, 0), hi = Math.max(dataMax, 0);
213
+ if (dataMin > 0 && dataMax - dataMin < dataMax / 6) lo = dataMin;
214
+ if (dataMax < 0 && dataMax - dataMin < -dataMin / 6) hi = dataMax;
215
+ if (hi === lo) {
216
+ if (hi === 0) hi = 1;
217
+ else if (hi > 0) lo = 0;
218
+ else hi = 0;
219
+ }
220
+ const span = hi - lo;
221
+ const top = hi > 0 ? hi + span / 20 : hi;
222
+ const bottom = lo < 0 ? lo - span / 20 : lo > 0 ? Math.max(0, lo - span / 20) : lo;
223
+ const raw = (top - bottom) / limit;
224
+ if (!(raw > 0) || !Number.isFinite(raw)) return normalizedScale(dataMin, dataMax, limit);
225
+ let exponent = Math.floor(Math.log10(raw)) - 1;
226
+ for (; exponent <= 308; exponent++) {
227
+ for (const mantissa of [1, 2, 5]) {
228
+ const step = mantissa * 10 ** exponent;
229
+ if (!(step > 0) || !Number.isFinite(step)) continue;
230
+ const min = Math.floor(bottom / step + 1e-9) * step, max = Math.ceil(top / step - 1e-9) * step;
231
+ if (Number.isFinite(min) && Number.isFinite(max) && Math.round((max - min) / step) <= limit) return scaleFrom(clean(min), clean(max), step);
232
+ }
233
+ }
234
+ return normalizedScale(dataMin, dataMax, limit);
235
+ }
236
+
237
+ // Work in representable units when headroom or a tick step exceeds Number's
238
+ // range. Only axis bounds/ticks saturate at the representable endpoints; data
239
+ // values keep their actual positions. Subnormal rounding may merge ticks.
240
+ function normalizedScale(dataMin, dataMax, limit) {
241
+ const magnitude = Math.max(Math.abs(dataMin), Math.abs(dataMax));
242
+ const unit = 10 ** Math.floor(Math.log10(magnitude)) || Number.MIN_VALUE;
243
+ const normalized = niceScale(dataMin / unit, dataMax / unit, limit);
244
+ const restore = (value) => {
245
+ const scaled = value * unit;
246
+ return Number.isFinite(scaled) ? scaled : Math.sign(value) * Number.MAX_VALUE;
247
+ };
248
+ const min = Math.min(restore(normalized.min), dataMin);
249
+ const max = Math.max(restore(normalized.max), dataMax);
250
+ const ticks = [...new Set([min, ...normalized.ticks.map(restore), max])].sort((a, b) => a - b);
251
+ return { min, max, step: restore(normalized.step) || Number.MIN_VALUE, ticks };
252
+ }
253
+
254
+ // Keep ordinary arithmetic unchanged. A range spanning both finite endpoints
255
+ // can overflow even though every value/bound is finite; divide before subtracting.
256
+ function axisFraction(value, scale, reverse = false) {
257
+ const range = scale.max - scale.min;
258
+ const distance = reverse ? scale.max - value : value - scale.min;
259
+ if (Number.isFinite(range) && Number.isFinite(distance)) return distance / (range || 1);
260
+ const unit = Math.max(Math.abs(scale.min), Math.abs(scale.max));
261
+ const min = scale.min / unit, max = scale.max / unit, position = value / unit;
262
+ return (reverse ? max - position : position - min) / (max - min);
263
+ }
264
+
265
+ function pickStep(min, max, limit, steps) {
266
+ return steps.find((step) => Math.round((max - min) / step) <= limit) ?? max - min;
267
+ }
268
+
269
+ function scaleFrom(min, max, step) {
270
+ const ticks = [];
271
+ const count = Math.round((max - min) / step);
272
+ for (let index = 0; index <= count; index++) ticks.push(clean(min + index * step));
273
+ return { min, max, step, ticks };
274
+ }
275
+
276
+ function clean(value) {
277
+ const rounded = Number(value.toPrecision(12));
278
+ return Object.is(rounded, -0) ? 0 : rounded;
279
+ }
280
+
281
+ /** Office General / 0% tick labels. */
282
+ export function formatTick(value, percent = false) {
283
+ if (percent) return `${clean(value * 100)}%`;
284
+ return String(clean(value));
285
+ }
286
+
287
+ function legendEntries(c, entries) {
288
+ // PowerPoint places the exported legend on the right (PptxGenJS legendPos r).
289
+ if (!entries.length) return 0;
290
+ const swatch = c.fontPx * 0.6, gap = c.fontPx * 0.4, keyWidth = entries.some((entry) => entry.key === "line") ? c.fontPx * 1.4 : swatch;
291
+ const textWidth = Math.max(...entries.map((entry) => c.width(entry.name)));
292
+ const width = Math.min(c.box.width * 0.45, keyWidth + gap + textWidth + c.fontPx * 0.5);
293
+ const rowHeight = Math.min(c.lineHeight, (c.box.height - 2 * c.pad) / entries.length);
294
+ const x = c.box.x + c.box.width - c.pad - width;
295
+ let y = c.box.y + Math.max(c.pad, (c.box.height - rowHeight * entries.length) / 2);
296
+ for (const entry of entries) {
297
+ const middle = y + rowHeight / 2;
298
+ // Legend keys follow the series format: a line (with marker) for line/radar
299
+ // series, a marker for scatter series, a filled square otherwise.
300
+ if (entry.key === "line") c.mark("polyline", { points: `${c.num(x)},${c.num(middle)} ${c.num(x + keyWidth)},${c.num(middle)}`, fill: "none", stroke: entry.color, "stroke-width": 2 * c.pt }, entry.path);
301
+ if (entry.key === "marker" || (entry.key === "line" && entry.marker)) c.mark("circle", { cx: x + keyWidth / 2, cy: middle, r: 3 * c.pt, fill: entry.color, stroke: entry.color, "stroke-width": 0.75 * c.pt }, entry.path);
302
+ if (!entry.key) c.mark("rect", { x, y: middle - swatch / 2, width: swatch, height: swatch, fill: entry.color }, entry.path);
303
+ c.text(entry.name, { x: x + keyWidth + gap, y, width: Math.max(1, width - keyWidth - gap), height: rowHeight }, entry.path, "left");
304
+ y += rowHeight;
305
+ }
306
+ return width + c.pad;
307
+ }
308
+
309
+ function seriesLegend(c, series, key, marker = false) {
310
+ return series.length > 1
311
+ ? legendEntries(c, series.map((s) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, key, marker })))
312
+ : 0;
313
+ }
314
+
315
+ function gridline(c, x1, y1, x2, y2) {
316
+ c.mark("line", { x1, y1, x2, y2, stroke: c.gridColor, "stroke-opacity": 0.7, "stroke-width": c.pt });
317
+ }
318
+
319
+ function axisLine(c, x1, y1, x2, y2) {
320
+ c.mark("line", { x1, y1, x2, y2, stroke: c.axisColor, "stroke-width": c.pt });
321
+ }
322
+
323
+ function maxIntervalsFor(length, labelExtent) {
324
+ return Math.max(1, Math.min(10, Math.floor(length / Math.max(1, labelExtent))));
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Category charts: column, bar, line and area (standard, stacked, 100%).
329
+
330
+ /**
331
+ * Plotted extents per series point. Bars stack positive and negative values
332
+ * separately; lines and areas accumulate the signed sum. 100% groupings divide
333
+ * by the category's sum of absolute values first.
334
+ */
335
+ export function stackCategoryValues(series, count, kind, grouping, path = "chart") {
336
+ const percent = grouping === "percentStacked";
337
+ const stacked = grouping === "stacked" || percent;
338
+ const totals = Array.from({ length: count }, (_, i) => series.reduce((sum, s) => sum + Math.abs(s.values[i] ?? 0), 0));
339
+ if (percent && totals.some((total) => !Number.isFinite(total))) throw chartAggregateError(path, "percentage total");
340
+ const positive = new Array(count).fill(0), negative = new Array(count).fill(0), running = new Array(count).fill(0);
341
+ return series.map((s) => s.values.map((raw, i) => {
342
+ if (raw === null && kind !== "area") return null;
343
+ let value = raw ?? 0;
344
+ if (percent) value = totals[i] ? value / totals[i] : 0;
345
+ if (!stacked) return { value, from: 0, to: value };
346
+ if (kind === "bar") {
347
+ const base = value >= 0 ? positive : negative;
348
+ const from = base[i];
349
+ base[i] += value;
350
+ if (!Number.isFinite(base[i])) throw chartAggregateError(path, "stacked total");
351
+ return { value, from, to: base[i] };
352
+ }
353
+ const from = running[i];
354
+ running[i] += value;
355
+ if (!Number.isFinite(running[i])) throw chartAggregateError(path, "stacked total");
356
+ return { value, from, to: running[i] };
357
+ }));
358
+ }
359
+
360
+ function chartAggregateError(path, aggregate) {
361
+ return new RangeError(`Cannot render ${path}: ${aggregate} exceeds the finite numeric range. Rescale the chart values before rendering.`);
362
+ }
363
+
364
+ function renderCategoryChart(c, spec) {
365
+ const { box, pad, fontPx } = c;
366
+ const horizontal = spec.kind === "bar" && spec.dir === "bar";
367
+ const percent = spec.grouping === "percentStacked";
368
+ const series = c.series();
369
+ const categories = c.rows.map((row) => c.label(row[0]));
370
+ const count = categories.length;
371
+ const stacks = stackCategoryValues(series, count, spec.kind, spec.grouping, c.path);
372
+ // The scale follows the plotted values. Only stacked bars and areas plot from a
373
+ // base (the running total below them); an unstacked series has a synthetic zero
374
+ // `from`, which must not force zero onto the axis, and a line plots `to` alone.
375
+ const stackedGrouping = spec.grouping === "stacked" || percent;
376
+ const extents = stacks.flat().filter(Boolean).flatMap((point) => spec.kind === "line" || !stackedGrouping ? [point.to] : [point.from, point.to]);
377
+ const dataMin = extents.length ? Math.min(...extents) : 0, dataMax = extents.length ? Math.max(...extents) : 1;
378
+ const legendWidth = seriesLegend(c, series, spec.kind === "line" ? "line" : undefined, spec.markers);
379
+ const right = box.x + box.width - pad - legendWidth;
380
+ const top = box.y + pad + c.lineHeight / 2;
381
+ let plot, scale;
382
+ if (horizontal) {
383
+ const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
384
+ const x = box.x + pad + categoryWidth + c.pad / 2;
385
+ const bottom = box.y + box.height - pad - c.lineHeight;
386
+ const width = Math.max(1, right - x - fontPx);
387
+ const tickWidth = Math.max(c.width(formatTick(dataMax, percent)), c.width(formatTick(dataMin, percent)), c.width("100%")) + fontPx;
388
+ scale = niceScale(dataMin, dataMax, maxIntervalsFor(width, tickWidth), { percent });
389
+ plot = { x, y: top, width, height: Math.max(1, bottom - top) };
390
+ categories.forEach((name, i) => {
391
+ const band = plot.height / count;
392
+ c.text(name, { x: box.x + pad, y: plot.y + plot.height - (i + 1) * band, width: categoryWidth, height: band }, `${c.path}.data.rows.${i}.0`, "right");
393
+ });
394
+ } else {
395
+ const bottom = box.y + box.height - pad - c.lineHeight;
396
+ const height = Math.max(1, bottom - top);
397
+ scale = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
398
+ const tickWidth = Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
399
+ const x = box.x + pad + tickWidth;
400
+ plot = { x, y: top, width: Math.max(1, right - x - fontPx / 2), height };
401
+ const band = plot.width / count;
402
+ categories.forEach((name, i) => c.text(name, { x: plot.x + i * band, y: plot.y + plot.height, width: band, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`));
403
+ }
404
+ const at = (value) => horizontal ? plot.x + axisFraction(value, scale) * plot.width : plot.y + axisFraction(value, scale, true) * plot.height;
405
+ // Category axis crosses at zero when zero is on the axis (autoZero).
406
+ const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
407
+ for (const tick of scale.ticks) {
408
+ const position = at(tick), label = formatTick(tick, percent);
409
+ if (horizontal) {
410
+ gridline(c, position, plot.y, position, plot.y + plot.height);
411
+ const width = c.width(label) + fontPx;
412
+ c.text(label, { x: position - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
413
+ } else {
414
+ gridline(c, plot.x, position, plot.x + plot.width, position);
415
+ c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
416
+ }
417
+ }
418
+ const band = (horizontal ? plot.height : plot.width) / count;
419
+ if (spec.kind === "bar") drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale });
420
+ else if (spec.kind === "line") drawLines(c, spec, series, stacks, { plot, band, at });
421
+ else drawAreas(c, series, stacks, { plot, band, at, crossing });
422
+ if (horizontal) {
423
+ axisLine(c, crossing, plot.y, crossing, plot.y + plot.height);
424
+ axisLine(c, plot.x, plot.y + plot.height, plot.x + plot.width, plot.y + plot.height);
425
+ } else {
426
+ axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
427
+ axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
428
+ }
429
+ }
430
+
431
+ // PptxGenJS gap widths: clustered 150%, stacked 50% (overlap 100).
432
+ export function barGeometry(band, seriesCount, grouping) {
433
+ if (grouping === "clustered") {
434
+ const group = band / 2.5;
435
+ return { group, width: group / seriesCount, offset: (band - group) / 2, clustered: true };
436
+ }
437
+ const width = band / 1.5;
438
+ return { group: width, width, offset: (band - width) / 2, clustered: false };
439
+ }
440
+
441
+ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale }) {
442
+ // Bars grow from zero, or from the axis minimum (maximum, for all-negative data) when zero is off the axis.
443
+ const base = (value) => Math.min(scale.max, Math.max(scale.min, value));
444
+ const geometry = barGeometry(band, series.length, spec.grouping);
445
+ series.forEach((s, j) => {
446
+ const color = c.colors[j % c.colors.length];
447
+ stacks[j].forEach((point, i) => {
448
+ if (!point) return;
449
+ const slot = geometry.clustered ? j * geometry.width : 0;
450
+ const a = at(base(point.from)), b = at(point.to);
451
+ const path = `${c.path}.data.rows.${i}.${s.column}`;
452
+ if (horizontal) {
453
+ // Office bar charts draw the first category and first series nearest the origin (bottom).
454
+ const y = plot.y + plot.height - i * band - geometry.offset - slot - geometry.width;
455
+ c.mark("rect", { x: Math.min(a, b), y, width: Math.abs(b - a), height: geometry.width, fill: color }, path);
456
+ } else {
457
+ const x = plot.x + i * band + geometry.offset + slot;
458
+ c.mark("rect", { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a), fill: color }, path);
459
+ }
460
+ });
461
+ });
462
+ }
463
+
464
+ function drawLines(c, spec, series, stacks, { plot, band, at }) {
465
+ const radius = 3 * c.pt;
466
+ series.forEach((s, j) => {
467
+ const color = c.colors[j % c.colors.length];
468
+ let run = [];
469
+ const flush = () => {
470
+ if (run.length > 1) c.mark("polyline", { points: run.map(([x, y]) => `${c.num(x)},${c.num(y)}`).join(" "), fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${s.column}`);
471
+ run = [];
472
+ };
473
+ stacks[j].forEach((point, i) => {
474
+ if (!point) { flush(); return; }
475
+ run.push([plot.x + (i + 0.5) * band, at(point.to)]);
476
+ });
477
+ flush();
478
+ if (spec.markers) stacks[j].forEach((point, i) => {
479
+ if (point) c.mark("circle", { cx: plot.x + (i + 0.5) * band, cy: at(point.to), r: radius, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
480
+ });
481
+ });
482
+ }
483
+
484
+ function drawAreas(c, series, stacks, { plot, band, at, crossing }) {
485
+ series.forEach((s, j) => {
486
+ const points = stacks[j].map((point, i) => ({ x: plot.x + (i + 0.5) * band, from: point.from, to: point.to }));
487
+ const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
488
+ const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
489
+ c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: c.colors[j % c.colors.length] }, `${c.path}.data.columns.${s.column}`);
490
+ });
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Pie and doughnut: first series only, clockwise from 12 o'clock, one colour per category.
495
+
496
+ function renderCircularChart(c, spec) {
497
+ const { box, pad } = c;
498
+ const categories = c.rows.map((row) => c.label(row[0]));
499
+ const values = c.rows.map((row) => Math.abs(chartNumber(row[1]) ?? 0));
500
+ const legendWidth = legendEntries(c, categories.map((name, i) => ({
501
+ name, color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0`
502
+ })));
503
+ const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - legendWidth), height: Math.max(1, box.height - 2 * pad) };
504
+ const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
505
+ const r = Math.max(1, Math.min(area.width, area.height) / 2 * 0.9);
506
+ const inner = spec.kind === "doughnut" ? r * 0.5 : 0;
507
+ const total = values.reduce((sum, value) => sum + value, 0);
508
+ if (!Number.isFinite(total)) throw chartAggregateError(c.path, "slice total");
509
+ if (!total) {
510
+ c.text("No positive chart values", area, c.path);
511
+ return;
512
+ }
513
+ const border = { stroke: "#F9F9F9", "stroke-width": 0.75 * c.pt };
514
+ let angle = -Math.PI / 2;
515
+ values.forEach((value, i) => {
516
+ const delta = value / total * Math.PI * 2, end = angle + delta;
517
+ const fill = c.colors[i % c.colors.length], path = `${c.path}.data.rows.${i}.1`;
518
+ if (delta >= Math.PI * 2 - 1e-9) {
519
+ if (inner) c.mark("path", { d: `${circlePath(c, cx, cy, r)} ${circlePath(c, cx, cy, inner)}`, fill, "fill-rule": "evenodd", ...border }, path);
520
+ else c.mark("circle", { cx, cy, r, fill, ...border }, path);
521
+ } else if (delta > 0) {
522
+ c.mark("path", { d: slicePath(c, cx, cy, r, inner, angle, end), fill, ...border }, path);
523
+ }
524
+ angle = end;
525
+ });
526
+ }
527
+
528
+ function polar(cx, cy, r, angle) {
529
+ return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
530
+ }
531
+
532
+ function circlePath(c, cx, cy, r) {
533
+ const n = c.num;
534
+ return `M ${n(cx - r)} ${n(cy)} A ${n(r)} ${n(r)} 0 1 0 ${n(cx + r)} ${n(cy)} A ${n(r)} ${n(r)} 0 1 0 ${n(cx - r)} ${n(cy)} Z`;
535
+ }
536
+
537
+ function slicePath(c, cx, cy, r, inner, start, end) {
538
+ const n = c.num, large = end - start > Math.PI ? 1 : 0;
539
+ const [x1, y1] = polar(cx, cy, r, start), [x2, y2] = polar(cx, cy, r, end);
540
+ if (!inner) return `M ${n(cx)} ${n(cy)} L ${n(x1)} ${n(y1)} A ${n(r)} ${n(r)} 0 ${large} 1 ${n(x2)} ${n(y2)} Z`;
541
+ const [x3, y3] = polar(cx, cy, inner, end), [x4, y4] = polar(cx, cy, inner, start);
542
+ return `M ${n(x1)} ${n(y1)} A ${n(r)} ${n(r)} 0 ${large} 1 ${n(x2)} ${n(y2)} L ${n(x3)} ${n(y3)} A ${n(inner)} ${n(inner)} 0 ${large} 0 ${n(x4)} ${n(y4)} Z`;
543
+ }
544
+
545
+ // ---------------------------------------------------------------------------
546
+ // Scatter: X from columns[1], Y series from columns[2..], markers only.
547
+
548
+ export function scatterSeries(rows, columns) {
549
+ const numericX = columns.length > 2;
550
+ const xs = rows.map((row, i) => numericX ? chartNumber(row[1]) : i + 1);
551
+ const first = numericX ? 2 : 1;
552
+ const series = columns.slice(first).map((name, offset) => ({
553
+ name: name === null || name === undefined ? "" : String(name), column: first + offset, index: offset,
554
+ points: rows.map((row, i) => ({ row: i, x: xs[i], y: chartNumber(row[first + offset]) })).filter((p) => p.x !== null && p.y !== null)
555
+ }));
556
+ return { series, xs };
557
+ }
558
+
559
+ function renderScatterChart(c) {
560
+ const { box, pad, fontPx } = c;
561
+ const { series } = scatterSeries(c.rows, c.columns);
562
+ const points = series.flatMap((s) => s.points);
563
+ const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
564
+ const legendWidth = seriesLegend(c, series, "marker");
565
+ const top = box.y + pad + c.lineHeight / 2, bottom = box.y + box.height - pad - c.lineHeight;
566
+ const height = Math.max(1, bottom - top);
567
+ const yScale = niceScale(ys.length ? Math.min(...ys) : 0, ys.length ? Math.max(...ys) : 1, maxIntervalsFor(height, c.lineHeight * 1.2));
568
+ const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick)))) + fontPx * 0.5;
569
+ const plot = { x: box.x + pad + tickWidth, y: top, height };
570
+ plot.width = Math.max(1, box.x + box.width - pad - legendWidth - fontPx - plot.x);
571
+ const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1)), c.width(formatTick(xs.length ? Math.min(...xs) : 0))) + fontPx;
572
+ const xScale = niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(plot.width, xLabelWidth));
573
+ const xAt = (v) => plot.x + axisFraction(v, xScale) * plot.width;
574
+ const yAt = (v) => plot.y + axisFraction(v, yScale, true) * plot.height;
575
+ for (const tick of yScale.ticks) {
576
+ const y = yAt(tick);
577
+ gridline(c, plot.x, y, plot.x + plot.width, y);
578
+ c.text(formatTick(tick), { x: box.x + pad, y: y - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
579
+ }
580
+ for (const tick of xScale.ticks) {
581
+ const label = formatTick(tick), width = c.width(label) + fontPx;
582
+ c.text(label, { x: xAt(tick) - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
583
+ }
584
+ const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
585
+ axisLine(c, xCross, plot.y, xCross, plot.y + plot.height);
586
+ axisLine(c, plot.x, yCross, plot.x + plot.width, yCross);
587
+ series.forEach((s, j) => {
588
+ const color = c.colors[j % c.colors.length];
589
+ for (const point of s.points) c.mark("circle", { cx: xAt(point.x), cy: yAt(point.y), r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${point.row}.${s.column}`);
590
+ });
591
+ }
592
+
593
+ // ---------------------------------------------------------------------------
594
+ // Radar: categories clockwise from 12 o'clock, value axis on the vertical spoke.
595
+
596
+ function renderRadarChart(c, spec) {
597
+ const { box, pad, fontPx } = c;
598
+ const series = c.series();
599
+ const categories = c.rows.map((row) => c.label(row[0]));
600
+ const count = categories.length;
601
+ const values = series.flatMap((s) => s.values).filter((v) => v !== null);
602
+ const legendWidth = seriesLegend(c, series, spec.style === "filled" ? undefined : "line", spec.markers);
603
+ const labelWidth = Math.min(box.width * 0.2, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
604
+ const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - legendWidth), height: Math.max(1, box.height - 2 * pad) };
605
+ const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
606
+ const radius = Math.max(1, Math.min(area.width / 2 - labelWidth, area.height / 2 - c.lineHeight));
607
+ const scale = niceScale(values.length ? Math.min(...values) : 0, values.length ? Math.max(...values) : 1, maxIntervalsFor(radius, c.lineHeight));
608
+ const angle = (i) => -Math.PI / 2 + i / count * Math.PI * 2;
609
+ const rAt = (v) => axisFraction(Math.min(scale.max, Math.max(scale.min, v)), scale) * radius;
610
+ const point = (i, v) => polar(cx, cy, rAt(v), angle(i));
611
+ const n = c.num;
612
+ for (const tick of scale.ticks) {
613
+ if (tick === scale.min) continue;
614
+ const ring = categories.map((_, i) => point(i, tick).map(n).join(" "));
615
+ c.mark("path", { d: `M ${ring.join(" L ")} Z`, fill: "none", stroke: c.gridColor, "stroke-opacity": 0.7, "stroke-width": c.pt });
616
+ }
617
+ categories.forEach((name, i) => {
618
+ const [x, y] = polar(cx, cy, radius, angle(i));
619
+ axisLine(c, cx, cy, x, y);
620
+ const [lx, ly] = polar(cx, cy, radius + fontPx * 0.4, angle(i));
621
+ const cos = Math.cos(angle(i)), sin = Math.sin(angle(i));
622
+ const align = Math.abs(cos) < 0.2 ? "center" : cos > 0 ? "left" : "right";
623
+ const rect = {
624
+ x: align === "center" ? lx - labelWidth / 2 : align === "left" ? lx : lx - labelWidth,
625
+ y: Math.abs(sin) < 0.2 ? ly - c.lineHeight / 2 : sin < 0 ? ly - c.lineHeight : ly,
626
+ width: labelWidth, height: c.lineHeight
627
+ };
628
+ c.text(name, rect, `${c.path}.data.rows.${i}.0`, align);
629
+ });
630
+ series.forEach((s, j) => {
631
+ const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
632
+ if (spec.style === "filled") {
633
+ const outline = s.values.map((v, i) => point(i, v ?? scale.min).map(n).join(" "));
634
+ c.mark("path", { d: `M ${outline.join(" L ")} Z`, fill: color }, path);
635
+ return;
636
+ }
637
+ const at = (i) => point(i, s.values[i]).map(n).join(" ");
638
+ let d = "";
639
+ if (s.values.every((v) => v !== null)) d = `M ${s.values.map((_, i) => at(i)).join(" L ")}${count > 2 ? " Z" : ""}`;
640
+ else for (let i = 0; i < count; i++) {
641
+ const next = (i + 1) % count;
642
+ if (s.values[i] !== null && s.values[next] !== null && next !== i) d += `${d ? " " : ""}M ${at(i)} L ${at(next)}`;
643
+ }
644
+ if (d) c.mark("path", { d, fill: "none", stroke: color, "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, path);
645
+ if (spec.markers) s.values.forEach((v, i) => {
646
+ if (v === null) return;
647
+ const [x, y] = point(i, v);
648
+ c.mark("circle", { cx: x, cy: y, r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
649
+ });
650
+ });
651
+ // Value axis labels sit on top of filled series, as in PowerPoint.
652
+ for (const tick of scale.ticks) {
653
+ const label = formatTick(tick), width = c.width(label) + fontPx * 0.5;
654
+ c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
655
+ }
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // Chartex constructs (FF-22b). opf-pptx writes these as native Office 2016
660
+ // chartex parts (cx:series layoutId treemap, clusteredColumn with binning or
661
+ // aggregation, paretoLine, boxWhisker, waterfall, funnel, regionMap); the
662
+ // previews below draw the same data with the same colours and defaults.
663
+
664
+ /**
665
+ * The plotted column of a chartex chart: [Category, Value, ...] plots the first
666
+ * value column against its categories; a lone value column is plotted against
667
+ * the row numbers (histogram and pareto bin the values instead).
668
+ */
669
+ export function chartexValues(rows, columns) {
670
+ const hasCategories = columns.length > 1;
671
+ const column = hasCategories ? 1 : 0;
672
+ const values = rows.map((row) => chartNumber(row[column]));
673
+ const categories = rows.map((row, i) => hasCategories ? (row[0] === null || row[0] === undefined ? "" : String(row[0])) : String(i + 1));
674
+ return { hasCategories, column, values, categories };
675
+ }
676
+
677
+ // Office's automatic histogram bins: Scott's normal reference rule gives the bin
678
+ // width 3.49 * sd * n^(-1/3) (sample standard deviation) over [min, max]; the
679
+ // exporter writes the same count as cx:binCount.
680
+ export function scottBinCount(values) {
681
+ const n = values.length;
682
+ if (n === 0) return 1;
683
+ let min = Infinity, max = -Infinity, mean = 0;
684
+ for (const value of values) { if (value < min) min = value; if (value > max) max = value; mean += value / n; }
685
+ if (!(max > min) || n < 2) return 1;
686
+ let variance = 0;
687
+ for (const value of values) variance += (value - mean) * (value - mean) / (n - 1);
688
+ const width = 3.49 * Math.sqrt(variance) * Math.cbrt(n) ** -1;
689
+ if (!(width > 0) || !Number.isFinite(width)) return 1;
690
+ const count = Math.ceil((max - min) / width - 1e-9);
691
+ return Number.isFinite(count) && count >= 1 ? count : 1;
692
+ }
693
+
694
+ /**
695
+ * Equal-width bins from the minimum, closed on the right like PowerPoint's
696
+ * ("[min, e1]", then "(e1, e2]"): each value falls in the last bin whose upper
697
+ * edge it does not exceed. Labels carry six significant digits, and more when
698
+ * two bins would read alike.
699
+ */
700
+ export function histogramBins(values) {
701
+ const finite = values.filter((value) => value !== null && Number.isFinite(value));
702
+ if (!finite.length) return [];
703
+ const min = finite.reduce((a, b) => Math.min(a, b)), max = finite.reduce((a, b) => Math.max(a, b));
704
+ const count = scottBinCount(finite);
705
+ const edge = (index) => index === 0 ? min : index === count ? max : min / count * (count - index) + max / count * index;
706
+ const bins = Array.from({ length: count }, (_, index) => ({ low: edge(index), high: edge(index + 1), count: 0 }));
707
+ for (const value of finite) {
708
+ let index = 0;
709
+ while (index + 1 < count && value > bins[index].high) index++;
710
+ bins[index].count++;
711
+ }
712
+ for (let precision = 6; ; precision++) {
713
+ const format = (value) => String(Number(value.toPrecision(precision)));
714
+ const labels = bins.map((bin, index) => count === 1 && min === max ? format(min) : `${index === 0 ? "[" : "("}${format(bin.low)}, ${format(bin.high)}]`);
715
+ if (new Set(labels).size === labels.length || precision === 17) return bins.map((bin, index) => ({ ...bin, label: labels[index] }));
716
+ }
717
+ }
718
+
719
+ // Vertical layout shared by the axis-bearing constructs: category labels below,
720
+ // value ticks on the left (and a percentage axis on the right for pareto).
721
+ function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentAxis = false }) {
722
+ const { box, pad, fontPx } = c;
723
+ const top = box.y + pad + c.lineHeight / 2;
724
+ const bottom = box.y + box.height - pad - c.lineHeight;
725
+ const height = Math.max(1, bottom - top);
726
+ const tickWidth = Math.max(0, ...tickLabels.map((label) => c.width(label))) + fontPx * 0.5;
727
+ const x = box.x + pad + tickWidth;
728
+ const right = box.x + box.width - pad - legendWidth - (percentAxis ? c.width("100%") + fontPx : fontPx / 2);
729
+ const plot = { x, y: top, width: Math.max(1, right - x), height };
730
+ const band = plot.width / Math.max(1, categories.length);
731
+ categories.forEach((entry, i) => c.text(entry.name, { x: plot.x + i * band, y: plot.y + plot.height, width: band, height: c.lineHeight }, entry.path));
732
+ return { plot, band };
733
+ }
734
+
735
+ function valueAxis(c, plot, scale) {
736
+ const at = (value) => plot.y + axisFraction(value, scale, true) * plot.height;
737
+ for (const tick of scale.ticks) {
738
+ const y = at(tick);
739
+ gridline(c, plot.x, y, plot.x + plot.width, y);
740
+ c.text(formatTick(tick), { x: c.box.x + c.pad, y: y - c.lineHeight / 2, width: plot.x - c.box.x - c.pad - c.fontPx * 0.35, height: c.lineHeight }, c.path, "right");
741
+ }
742
+ const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
743
+ axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
744
+ axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
745
+ return { at, crossing };
746
+ }
747
+
748
+ // Histogram (cx:binning or cx:aggregation) and Pareto (sorted columns with the cumulative-percentage line on a percentage axis).
749
+ function renderHistogramChart(c, spec) {
750
+ const pareto = spec.kind === "pareto";
751
+ const { hasCategories, column, values, categories } = chartexValues(c.rows, c.columns);
752
+ let bars;
753
+ if (hasCategories) {
754
+ bars = values.map((value, i) => ({ value, name: categories[i], path: `${c.path}.data.rows.${i}.${column}`, labelPath: `${c.path}.data.rows.${i}.0` })).filter((bar) => bar.value !== null);
755
+ } else {
756
+ bars = histogramBins(values).map((bin) => ({ value: bin.count, name: bin.label, path: `${c.path}.data.columns.${column}`, labelPath: c.path }));
757
+ }
758
+ if (pareto) bars = bars.slice().sort((a, b) => b.value - a.value);
759
+ const total = bars.reduce((sum, bar) => sum + Math.max(0, bar.value), 0);
760
+ if (!Number.isFinite(total)) throw chartAggregateError(c.path, "cumulative total");
761
+ const dataMax = bars.length ? Math.max(...bars.map((bar) => bar.value)) : 1, dataMin = bars.length ? Math.min(0, ...bars.map((bar) => bar.value)) : 0;
762
+ const probe = niceScale(dataMin, dataMax, 10);
763
+ const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick)), percentAxis: pareto });
764
+ const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
765
+ const { at, crossing } = valueAxis(c, plot, scale);
766
+ const width = band / 1.06, offset = (band - width) / 2;
767
+ bars.forEach((bar, i) => {
768
+ const y = at(bar.value);
769
+ c.mark("rect", { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y), fill: c.colors[0] }, bar.path);
770
+ });
771
+ if (pareto && bars.length) {
772
+ // Percentage axis on the right, 0-100%, and the cumulative line through the bar centres.
773
+ const percent = niceScale(0, 1, maxIntervalsFor(plot.height, c.lineHeight * 1.2), { percent: true });
774
+ const right = plot.x + plot.width;
775
+ axisLine(c, right, plot.y, right, plot.y + plot.height);
776
+ for (const tick of percent.ticks) {
777
+ const y = plot.y + axisFraction(tick, percent, true) * plot.height;
778
+ c.text(formatTick(tick, true), { x: right + c.fontPx * 0.35, y: y - c.lineHeight / 2, width: c.box.x + c.box.width - c.pad - right - c.fontPx * 0.35, height: c.lineHeight }, c.path, "left");
779
+ }
780
+ let running = 0;
781
+ const points = bars.map((bar, i) => {
782
+ running += Math.max(0, bar.value);
783
+ const fraction = total ? running / total : 0;
784
+ return `${c.num(plot.x + (i + 0.5) * band)},${c.num(plot.y + axisFraction(fraction, percent, true) * plot.height)}`;
785
+ });
786
+ c.mark("polyline", { points: points.join(" "), fill: "none", stroke: c.colors[1 % c.colors.length], "stroke-width": 2 * c.pt, "stroke-linejoin": "round", "stroke-linecap": "round" }, `${c.path}.data.columns.${column}`);
787
+ }
788
+ }
789
+
790
+ // Waterfall: floating bars from the running total, increases and decreases in the first two palette colours, connector lines between bars.
791
+ function renderWaterfallChart(c) {
792
+ const { column, values, categories } = chartexValues(c.rows, c.columns);
793
+ let running = 0;
794
+ const bars = values.map((value, i) => {
795
+ if (value === null) return null;
796
+ const from = running;
797
+ running += value;
798
+ if (!Number.isFinite(running)) throw chartAggregateError(c.path, "running total");
799
+ return { i, value, from, to: running };
800
+ }).filter(Boolean);
801
+ const extents = bars.flatMap((bar) => [bar.from, bar.to]);
802
+ const dataMin = extents.length ? Math.min(0, ...extents) : 0, dataMax = extents.length ? Math.max(0, ...extents) : 1;
803
+ const probe = niceScale(dataMin, dataMax, 10);
804
+ const { plot, band } = chartexPlot(c, { categories: categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` })), tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
805
+ const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
806
+ const { at } = valueAxis(c, plot, scale);
807
+ const width = band / 1.5, offset = (band - width) / 2;
808
+ bars.forEach((bar, k) => {
809
+ const a = at(bar.from), b = at(bar.to);
810
+ const x = plot.x + bar.i * band + offset;
811
+ c.mark("rect", { x, y: Math.min(a, b), width, height: Math.abs(b - a), fill: c.colors[bar.value < 0 ? 1 : 0] }, `${c.path}.data.rows.${bar.i}.${column}`);
812
+ const next = bars[k + 1];
813
+ if (next) c.mark("line", { x1: x + width, y1: b, x2: plot.x + next.i * band + offset, y2: b, stroke: c.axisColor, "stroke-width": c.pt });
814
+ });
815
+ }
816
+
817
+ // Funnel: centred bars from the top, widths proportional to the value, value labels inside and category labels on the left.
818
+ function renderFunnelChart(c) {
819
+ const { box, pad, fontPx } = c;
820
+ const { column, values, categories } = chartexValues(c.rows, c.columns);
821
+ const count = categories.length;
822
+ const max = Math.max(0, ...values.filter((value) => value !== null));
823
+ const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map(c.width)) + fontPx * 0.5);
824
+ const plot = { x: box.x + pad + labelWidth + pad / 2, y: box.y + pad, width: Math.max(1, box.width - 2 * pad - labelWidth - pad / 2), height: Math.max(1, box.height - 2 * pad) };
825
+ const band = plot.height / count, height = band / 1.06, offset = (band - height) / 2;
826
+ categories.forEach((name, i) => {
827
+ const y = plot.y + i * band;
828
+ c.text(name, { x: box.x + pad, y, width: labelWidth, height: band }, `${c.path}.data.rows.${i}.0`, "right");
829
+ const value = values[i];
830
+ if (value === null || !(max > 0) || value <= 0) return;
831
+ const width = plot.width * Math.min(1, value / max);
832
+ const x = plot.x + (plot.width - width) / 2;
833
+ c.mark("rect", { x, y: y + offset, width, height, fill: c.colors[0] }, `${c.path}.data.rows.${i}.${column}`);
834
+ c.text(formatTick(value), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
835
+ });
836
+ }
837
+
838
+ /**
839
+ * Squarified treemap layout (Bruls, Huizing and van Wijk): items are laid out
840
+ * in rows along the shorter side, each row closed when adding the next item
841
+ * would worsen its worst aspect ratio. Returns one rectangle per item, in the
842
+ * item order given (null for items without area).
843
+ */
844
+ export function squarify(items, x, y, width, height) {
845
+ const total = items.reduce((sum, item) => sum + item.value, 0);
846
+ const rects = new Array(items.length).fill(null);
847
+ if (!(total > 0) || !(width > 0) || !(height > 0)) return rects;
848
+ const scale = width * height / total;
849
+ const ordered = items.map((item, index) => ({ area: item.value * scale, index })).filter((item) => item.area > 0).sort((a, b) => b.area - a.area || a.index - b.index);
850
+ let free = { x, y, width, height };
851
+ let row = [];
852
+ const worst = (candidates, side) => {
853
+ const sum = candidates.reduce((s, item) => s + item.area, 0);
854
+ const min = Math.min(...candidates.map((item) => item.area)), max = Math.max(...candidates.map((item) => item.area));
855
+ return Math.max(side * side * max / (sum * sum), sum * sum / (side * side * min));
856
+ };
857
+ const layoutRow = () => {
858
+ const sum = row.reduce((s, item) => s + item.area, 0);
859
+ const horizontal = free.width >= free.height;
860
+ const side = horizontal ? free.height : free.width;
861
+ const thickness = side > 0 ? sum / side : 0;
862
+ let offset = 0;
863
+ for (const item of row) {
864
+ const length = thickness > 0 ? item.area / thickness : 0;
865
+ rects[item.index] = horizontal
866
+ ? { x: free.x, y: free.y + offset, width: thickness, height: length }
867
+ : { x: free.x + offset, y: free.y, width: length, height: thickness };
868
+ offset += length;
869
+ }
870
+ free = horizontal
871
+ ? { x: free.x + thickness, y: free.y, width: Math.max(0, free.width - thickness), height: free.height }
872
+ : { x: free.x, y: free.y + thickness, width: free.width, height: Math.max(0, free.height - thickness) };
873
+ row = [];
874
+ };
875
+ for (const item of ordered) {
876
+ const side = Math.min(free.width, free.height);
877
+ if (row.length && side > 0 && worst([...row, item], side) > worst(row, side)) layoutRow();
878
+ row.push(item);
879
+ }
880
+ if (row.length) layoutRow();
881
+ return rects;
882
+ }
883
+
884
+ // Treemap: one tile per category from the first value column (positive values only), one palette colour per tile, category labels inside.
885
+ function renderTreemapChart(c) {
886
+ const { box, pad, fontPx } = c;
887
+ const { column, values, categories } = chartexValues(c.rows, c.columns);
888
+ const items = values.map((value) => ({ value: value !== null && value > 0 ? value : 0 }));
889
+ const total = items.reduce((sum, item) => sum + item.value, 0);
890
+ if (!Number.isFinite(total)) throw chartAggregateError(c.path, "tile total");
891
+ const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad), height: Math.max(1, box.height - 2 * pad) };
892
+ if (!total) {
893
+ c.text("No positive chart values", area, c.path);
894
+ return;
895
+ }
896
+ const rects = squarify(items, area.x, area.y, area.width, area.height);
897
+ rects.forEach((rect, i) => {
898
+ if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;
899
+ c.mark("rect", { x: rect.x, y: rect.y, width: rect.width, height: rect.height, fill: c.colors[i % c.colors.length], stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
900
+ if (rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
901
+ c.text(categories[i], { x: rect.x + fontPx * 0.25, y: rect.y, width: rect.width - fontPx * 0.5, height: rect.height }, `${c.path}.data.rows.${i}.0`);
902
+ }
903
+ });
904
+ }
905
+
906
+ /**
907
+ * Box statistics as PowerPoint computes them with the exclusive quartile
908
+ * method (Excel QUARTILE.EXC): the rank p(n + 1) interpolates between sorted
909
+ * values and clamps to the extremes, whiskers reach the furthest values within
910
+ * 1.5 IQR of the box, and values beyond are outliers.
911
+ */
912
+ export function boxStatistics(values) {
913
+ const sorted = values.filter((value) => value !== null && Number.isFinite(value)).sort((a, b) => a - b);
914
+ const n = sorted.length;
915
+ if (!n) return null;
916
+ const quantile = (p) => {
917
+ const rank = p * (n + 1);
918
+ if (rank <= 1) return sorted[0];
919
+ if (rank >= n) return sorted[n - 1];
920
+ const lower = Math.floor(rank), fraction = rank - lower;
921
+ return sorted[lower - 1] + fraction * (sorted[lower] - sorted[lower - 1]);
922
+ };
923
+ const q1 = quantile(0.25), median = quantile(0.5), q3 = quantile(0.75);
924
+ const iqr = q3 - q1;
925
+ const inside = sorted.filter((value) => value >= q1 - 1.5 * iqr && value <= q3 + 1.5 * iqr);
926
+ const mean = sorted.reduce((sum, value) => sum + value, 0) / n;
927
+ return { q1, median, q3, mean, low: inside[0] ?? q1, high: inside[inside.length - 1] ?? q3, outliers: sorted.filter((value) => value < q1 - 1.5 * iqr || value > q3 + 1.5 * iqr) };
928
+ }
929
+
930
+ // Box and whisker: rows with the same category form one box per series; median line, mean marker and outliers, gap width 100%.
931
+ function renderBoxWhiskerChart(c) {
932
+ // A lone value column has no category column: like the exporter, its values plot against their row numbers (one box per row).
933
+ const lone = c.columns.length === 1;
934
+ const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
935
+ const groups = [];
936
+ c.rows.forEach((row, i) => {
937
+ const name = lone ? String(i + 1) : c.label(row[0]);
938
+ let group = groups.find((entry) => entry.name === name);
939
+ if (!group) groups.push(group = { name, first: i, rows: [] });
940
+ group.rows.push(i);
941
+ });
942
+ const boxes = series.map((s) => groups.map((group) => ({ stats: boxStatistics(group.rows.map((i) => s.values[i])), rows: group.rows })));
943
+ const all = boxes.flat().flatMap((entry) => entry.stats ? [entry.stats.low, entry.stats.high, ...entry.stats.outliers] : []);
944
+ const dataMin = all.length ? Math.min(...all) : 0, dataMax = all.length ? Math.max(...all) : 1;
945
+ const legendWidth = seriesLegend(c, series);
946
+ const probe = niceScale(dataMin, dataMax, 10);
947
+ const { plot, band } = chartexPlot(c, { categories: groups.map((group) => ({ name: group.name, path: lone ? c.path : `${c.path}.data.rows.${group.first}.0` })), legendWidth, tickLabels: probe.ticks.map((tick) => formatTick(tick)) });
948
+ const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
949
+ const { at } = valueAxis(c, plot, scale);
950
+ const group = band / 2, width = group / series.length, offset = (band - group) / 2;
951
+ series.forEach((s, j) => {
952
+ const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
953
+ boxes[j].forEach((entry, i) => {
954
+ const stats = entry.stats;
955
+ if (!stats) return;
956
+ const x = plot.x + i * band + offset + j * width, mid = x + width / 2;
957
+ const top = at(stats.q3), bottom = at(stats.q1);
958
+ c.mark("line", { x1: mid, y1: at(stats.high), x2: mid, y2: top, stroke: color, "stroke-width": c.pt }, path);
959
+ c.mark("line", { x1: mid, y1: bottom, x2: mid, y2: at(stats.low), stroke: color, "stroke-width": c.pt }, path);
960
+ c.mark("line", { x1: x + width * 0.25, y1: at(stats.high), x2: x + width * 0.75, y2: at(stats.high), stroke: color, "stroke-width": c.pt }, path);
961
+ c.mark("line", { x1: x + width * 0.25, y1: at(stats.low), x2: x + width * 0.75, y2: at(stats.low), stroke: color, "stroke-width": c.pt }, path);
962
+ c.mark("rect", { x, y: top, width, height: Math.max(c.pt, bottom - top), fill: color }, path);
963
+ c.mark("line", { x1: x, y1: at(stats.median), x2: x + width, y2: at(stats.median), stroke: c.labelColor, "stroke-width": c.pt }, path);
964
+ const r = 2.5 * c.pt, my = at(stats.mean);
965
+ c.mark("path", { d: `M ${c.num(mid - r)} ${c.num(my - r)} L ${c.num(mid + r)} ${c.num(my + r)} M ${c.num(mid - r)} ${c.num(my + r)} L ${c.num(mid + r)} ${c.num(my - r)}`, fill: "none", stroke: c.labelColor, "stroke-width": c.pt }, path);
966
+ for (const value of stats.outliers) {
967
+ const row = entry.rows.find((k) => s.values[k] === value);
968
+ c.mark("circle", { cx: mid, cy: at(value), r: 3 * c.pt, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${row}.${s.column}`);
969
+ }
970
+ });
971
+ });
972
+ }
973
+
974
+ /** Mix two hex colours (#RRGGBB) by `t` in [0, 1], rounded per channel. */
975
+ export function mixHex(from, to, t) {
976
+ const channel = (hex, i) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
977
+ const clamp = Math.min(1, Math.max(0, t));
978
+ return `#${[0, 1, 2].map((i) => Math.round(channel(from, i) + (channel(to, i) - channel(from, i)) * clamp).toString(16).toUpperCase().padStart(2, "0")).join("")}`;
979
+ }
980
+
981
+ // Region map: an honest non-geographic preview. PowerPoint draws the map from
982
+ // Bing geodata it fetches itself; the preview shows one tile per region, shaded
983
+ // from a light tint to the series colour by value, with the region name and
984
+ // its value, so labels and colours are traceable without shipping geography.
985
+ function renderRegionMapChart(c) {
986
+ const { box, pad, fontPx } = c;
987
+ const { column, values, categories } = chartexValues(c.rows, c.columns);
988
+ const count = categories.length;
989
+ const area = { x: box.x + pad, y: box.y + pad, width: Math.max(1, box.width - 2 * pad), height: Math.max(1, box.height - 2 * pad) };
990
+ const columns = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * area.width / Math.max(1, area.height)))));
991
+ const rowsCount = Math.ceil(count / columns);
992
+ const tileWidth = area.width / columns, tileHeight = area.height / rowsCount;
993
+ const finite = values.filter((value) => value !== null);
994
+ const min = finite.length ? Math.min(...finite) : 0, max = finite.length ? Math.max(...finite) : 0;
995
+ const light = mixHex(/^#[0-9A-Fa-f]{6}$/.test(c.surface) ? c.surface : "#FFFFFF", c.colors[0], 0.15);
996
+ categories.forEach((name, i) => {
997
+ const value = values[i];
998
+ const t = value === null ? 0 : max > min ? (value - min) / (max - min) : 1;
999
+ const x = area.x + (i % columns) * tileWidth, y = area.y + Math.floor(i / columns) * tileHeight;
1000
+ c.mark("rect", { x, y, width: tileWidth, height: tileHeight, fill: value === null ? c.surface : mixHex(light, c.colors[0], t), stroke: "#FFFFFF", "stroke-width": c.pt }, `${c.path}.data.rows.${i}.${column}`);
1001
+ const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
1002
+ if (tileHeight >= c.lineHeight * 2) {
1003
+ c.text(name, { ...inset, y: y + tileHeight / 2 - c.lineHeight, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
1004
+ if (value !== null) c.text(formatTick(value), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
1005
+ } else if (tileHeight >= c.lineHeight) {
1006
+ c.text(name, { ...inset, y: y + (tileHeight - c.lineHeight) / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
1007
+ }
1008
+ });
1009
+ }