@openpresentation/opf-render 0.13.0 → 0.14.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.
@@ -3,17 +3,16 @@
3
3
  // Core owns the normalisation and the per-type support table (`resolveChartOptions`); this module owns the drawing.
4
4
  // A chart that carries none of the three fields resolves to `active: false` and never reaches any code here, so
5
5
  // its SVG is exactly what it was before.
6
- import * as core from "@openpresentation/opf/composition";
6
+ import { chartLabelText, formatChartLabelNumber, formatChartLabelPercent, formatDataNumber, resolveChartOptions, textColorForFill } from "@openpresentation/opf/composition";
7
7
 
8
8
  const INACTIVE = Object.freeze({ active: false, axisTitles: {}, diagnostics: [] });
9
9
 
10
10
  /**
11
- * The resolved options of a chart. An older core without `resolveChartOptions` ignores the fields, like any other
12
- * field it does not know. `spec` is the catalog spec this module's charts.js resolved (`kind`, `grouping`).
11
+ * The resolved options of a chart. `spec` is the catalog spec this module's charts.js resolved (`kind`, `grouping`).
13
12
  */
14
13
  export function resolveOptions(chart, spec) {
15
- if (typeof core.resolveChartOptions !== "function" || !chart || typeof chart !== "object") return INACTIVE;
16
- return core.resolveChartOptions(chart, { kind: spec.kind, stacked: spec.grouping === "stacked" || spec.grouping === "percentStacked" });
14
+ if (!chart || typeof chart !== "object") return INACTIVE;
15
+ return resolveChartOptions(chart, { kind: spec.kind, stacked: spec.grouping === "stacked" || spec.grouping === "percentStacked" });
17
16
  }
18
17
 
19
18
  export function reportOptionDiagnostics(c, resolved) {
@@ -22,9 +21,13 @@ export function reportOptionDiagnostics(c, resolved) {
22
21
  }
23
22
  }
24
23
 
25
- /** Which edge carries which axis title: the category axis of a bar or funnel chart is vertical (left). */
26
- function titleSides(spec) {
24
+ /**
25
+ * Which edge carries which axis title: the category axis of a bar or funnel chart is vertical (left). A combo chart titles its
26
+ * secondary value axis at the right, next to that axis; right to left, its two value axes trade sides (FA-15).
27
+ */
28
+ function titleSides(spec, rtl = false) {
27
29
  const verticalCategory = (spec.kind === "bar" && spec.dir === "bar") || spec.kind === "funnel";
30
+ if (spec.kind === "combo") return rtl ? { category: "bottom", value: "right", secondary: "left" } : { category: "bottom", value: "left", secondary: "right" };
28
31
  return verticalCategory ? { category: "left", value: "bottom" } : { category: "bottom", value: "left" };
29
32
  }
30
33
 
@@ -33,12 +36,13 @@ function titleSides(spec) {
33
36
  * The renderers lay the plot out inside `c.box`, so they need no knowledge of the bands.
34
37
  */
35
38
  export function reserveAxisTitles(c, spec, titles) {
36
- const sides = titleSides(spec);
37
- const bands = { left: 0, bottom: 0 };
39
+ const sides = titleSides(spec, spec.kind === "combo" && c.bound.geometry?.direction === "rtl");
40
+ const bands = { left: 0, bottom: 0, right: 0 };
38
41
  c.axisTitleBands = [];
39
- for (const axis of ["category", "value"]) {
42
+ for (const axis of ["category", "value", "secondary"]) {
40
43
  const text = titles[axis];
41
- if (!text) continue;
44
+ // A secondary title is drawn only when the combo chart has a secondary axis (core reports the title it drops).
45
+ if (!text || !sides[axis] || (axis === "secondary" && !c.combo?.some((entry) => entry.axis === "secondary"))) continue;
42
46
  const side = sides[axis];
43
47
  bands[side] = c.lineHeight;
44
48
  c.axisTitleBands.push({ axis, side, text });
@@ -46,7 +50,7 @@ export function reserveAxisTitles(c, spec, titles) {
46
50
  if (!c.axisTitleBands.length) return;
47
51
  const outer = c.box;
48
52
  c.axisTitleOuter = outer;
49
- c.box = { x: outer.x + bands.left, y: outer.y, width: Math.max(1, outer.width - bands.left), height: Math.max(1, outer.height - bands.bottom) };
53
+ c.box = { x: outer.x + bands.left, y: outer.y, width: Math.max(1, outer.width - bands.left - bands.right), height: Math.max(1, outer.height - bands.bottom) };
50
54
  }
51
55
 
52
56
  /**
@@ -65,8 +69,10 @@ export function drawAxisTitles(c) {
65
69
  c.text(text, rect, path, "center");
66
70
  } else {
67
71
  const span = plot ? { y: plot.y, height: plot.height } : { y: inner.y, height: inner.height };
68
- // The rotated text box is laid out horizontally, `length` wide and one line tall, then turned about its centre.
69
- const length = Math.max(1, span.height), centreX = outer.x + c.lineHeight / 2, centreY = span.y + span.height / 2;
72
+ // The rotated text box is laid out horizontally, `length` wide and one line tall, then turned about its centre. A right title
73
+ // (a combo chart's secondary axis) is rotated the same way, as PowerPoint draws every vertical axis title by default.
74
+ const length = Math.max(1, span.height), centreY = span.y + span.height / 2;
75
+ const centreX = side === "right" ? outer.x + outer.width - (c.axisTitleRightInset ?? 0) - c.lineHeight / 2 : outer.x + c.lineHeight / 2;
70
76
  const rect = { x: centreX - length / 2, y: centreY - c.lineHeight / 2, width: length, height: c.lineHeight };
71
77
  const element = c.textElement(text, rect, path, "center");
72
78
  if (element) c.children.push(c.svg.tag("g", { transform: `rotate(-90 ${c.num(centreX)} ${c.num(centreY)})` }, element));
@@ -84,9 +90,9 @@ export function labelString(c, { category, value, share, format }) {
84
90
  const labels = c.dataLabels;
85
91
  const parts = {};
86
92
  if (category !== undefined && category !== null) parts.category = String(category);
87
- if (typeof value === "number" && Number.isFinite(value)) parts.value = format !== undefined && typeof core.formatDataNumber === "function" ? core.formatDataNumber(value, format) : core.formatChartLabelNumber(value);
88
- if (typeof share === "number" && Number.isFinite(share)) parts.percent = core.formatChartLabelPercent(share);
89
- return core.chartLabelText(parts, labels.content, labels.separator);
93
+ if (typeof value === "number" && Number.isFinite(value)) parts.value = format !== undefined ? formatDataNumber(value, format) : formatChartLabelNumber(value);
94
+ if (typeof share === "number" && Number.isFinite(share)) parts.percent = formatChartLabelPercent(share);
95
+ return chartLabelText(parts, labels.content, labels.separator);
90
96
  }
91
97
 
92
98
  const labelSize = (c, text) => ({ width: c.width(text) + c.fontPx * 0.5, height: c.lineHeight });
@@ -99,7 +105,7 @@ function drawLabel(c, text, rect, path, fill) {
99
105
 
100
106
  /** The text colour for a label placed inside a mark of colour `fill`: the contrasting one; outside marks keep the chart text colour. */
101
107
  export function labelFill(c, position, fill) {
102
- return INSIDE.has(position) && fill ? core.textColorForFill?.(fill, c.labelColor) ?? c.labelColor : c.labelColor;
108
+ return INSIDE.has(position) && fill ? textColorForFill(fill, c.labelColor) : c.labelColor;
103
109
  }
104
110
 
105
111
  /**
@@ -133,9 +139,8 @@ export function drawBarLabel(c, text, rect, direction, path, fill) {
133
139
  drawLabel(c, text, { x, y, width, height }, path, labelFill(c, position, fill));
134
140
  }
135
141
 
136
- /** A label beside a point mark (line and scatter): above, below, left, right or centred on it. */
137
- export function drawPointLabel(c, text, point, path) {
138
- const position = c.dataLabels.position ?? "above";
142
+ /** A label beside a point mark (line and scatter): above, below, left, right or centred on it. `position` overrides the chart's (a combo chart's line series). */
143
+ export function drawPointLabel(c, text, point, path, position = c.dataLabels.position ?? "above") {
139
144
  const { width, height } = labelSize(c, text);
140
145
  const gap = 3 * c.pt + c.fontPx * 0.25;
141
146
  const [px, py] = point;
package/dist/charts.js CHANGED
@@ -1,15 +1,11 @@
1
1
  // Chart previews for the OPF chart type catalog (FF-22).
2
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
- // chartPaletteForFill ships with core RR-29 (opf#270); an older published core still loads and clamps each colour on its own.
12
- import * as opfComposition from "@openpresentation/opf/composition";
3
+ // The core catalog keeps one chart type per Office chart construct (`mappings.openxml`).
4
+ // The table below is carried here so the preview stays synchronous and
5
+ // self-contained; test/chart-catalog.mjs checks it against the installed core
6
+ // catalog. Catalog ids render the construct PowerPoint shows for the exported
7
+ // chart; any other id returns null so the caller keeps its legacy single-series preview.
8
+ import { chartHighlightColors, chartHighlightMarks, chartNumber, chartPaletteForFill, formatDataNumber, numberFormatError, resolveChartData, resolveTextStyle, textColorForFill, textWidthMeasurer } from "@openpresentation/opf/composition";
13
9
  import { drawAxisTitles, drawBarLabel, drawCenteredLabel, drawPointLabel, drawSliceLabel, labelString, outsideLabelReserve, reportOptionDiagnostics, reserveAxisTitles, resolveOptions } from "./chart-options.js";
14
10
 
15
11
  // Ordered series palette written by opf-pptx (`CHART_COLORS`), before the same
@@ -19,12 +15,34 @@ export const CHART_SERIES_COLORS = Object.freeze([
19
15
  "#F59E0B", "#EF4444", "#8B5CF6", "#14B8A6", "#0F172A", "#64748B"
20
16
  ]);
21
17
 
22
- // The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge)
23
- // when the installed core has it, else each colour clamped on its own.
18
+ // The series colours for a chart panel: core's `chartPaletteForFill` (contrast against the panel without letting two series merge).
24
19
  export function chartSeriesPalette(surface) {
25
- return typeof opfComposition.chartPaletteForFill === "function"
26
- ? opfComposition.chartPaletteForFill(surface, CHART_SERIES_COLORS)
27
- : CHART_SERIES_COLORS.map((color) => chartColorForFill(surface, color));
20
+ return chartPaletteForFill(surface, CHART_SERIES_COLORS);
21
+ }
22
+
23
+ // FA-14: chart.highlight. Core resolves which series and categories are named (chartHighlightMarks) and the two colours
24
+ // (chartHighlightColors); a highlighted mark takes the accent, every other mark the muted neutral. A chart without a
25
+ // highlight has no `c.highlight`, and every colour below is the palette colour it always was.
26
+ function highlightFor(c, resolved, spec, data) {
27
+ if (!resolved.highlight) return undefined;
28
+ const marks = chartHighlightMarks(resolved.highlight, { columns: data.columns, hasX: spec.kind === "scatter" && data.columns.length > 2, rows: data.rows });
29
+ if (!marks) return undefined;
30
+ return { ...marks, ...chartHighlightColors(c.surface, c.bound.design.colors.primary, c.labelColor) };
31
+ }
32
+
33
+ /** The colour of series `j` as a whole (a line, an area, a legend key): the accent when highlighted, else muted. */
34
+ function seriesColor(c, j) {
35
+ return c.highlight ? (c.highlight.series[j] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
36
+ }
37
+
38
+ /** The colour of the mark of series `j` at row `i`: highlighted when its series or its category is named. */
39
+ function markColor(c, j, i) {
40
+ return c.highlight ? (c.highlight.series[j] || c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[j % c.colors.length];
41
+ }
42
+
43
+ /** The colour of the pie or doughnut slice of row `i`. */
44
+ function sliceColor(c, i) {
45
+ return c.highlight ? (c.highlight.categories[i] ? c.highlight.accent : c.highlight.muted) : c.colors[i % c.colors.length];
28
46
  }
29
47
 
30
48
  const bar = (dir, grouping) => ({ kind: "bar", dir, grouping });
@@ -34,18 +52,18 @@ const radar = (style) => ({ kind: "radar", style, markers: style === "marker" })
34
52
 
35
53
  export const CHART_TYPES = Object.freeze({
36
54
  column: bar("col", "clustered"),
37
- "stacked-column-3x": bar("col", "stacked"),
38
- "100pct-stacked-column-3x": bar("col", "percentStacked"),
55
+ "stacked-column": bar("col", "stacked"),
56
+ "100pct-stacked-column": bar("col", "percentStacked"),
39
57
  bar: bar("bar", "clustered"),
40
- "stacked-bar-3x": bar("bar", "stacked"),
41
- "100pct-stacked-bar-3x": bar("bar", "percentStacked"),
58
+ "stacked-bar": bar("bar", "stacked"),
59
+ "100pct-stacked-bar": bar("bar", "percentStacked"),
42
60
  line: line("standard", false),
43
61
  "line-with-markers": line("standard", true),
44
- "stacked-line-3x": line("stacked", false),
45
- "stacked-line-with-markers-3x": line("stacked", true),
62
+ "stacked-line": line("stacked", false),
63
+ "stacked-line-with-markers": line("stacked", true),
46
64
  area: area("standard"),
47
- "stacked-area-3x": area("stacked"),
48
- "100pct-stacked-area-3x": area("percentStacked"),
65
+ "stacked-area": area("stacked"),
66
+ "100pct-stacked-area": area("percentStacked"),
49
67
  pie: { kind: "pie" },
50
68
  doughnut: { kind: "doughnut" },
51
69
  scatter: { kind: "scatter" },
@@ -58,46 +76,9 @@ export const CHART_TYPES = Object.freeze({
58
76
  "box-and-whisker": { kind: "box" },
59
77
  waterfall: { kind: "waterfall" },
60
78
  funnel: { kind: "funnel" },
61
- world: { kind: "map" }
62
- });
63
-
64
- const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
65
- const suffixes = (base, target) => Object.fromEntries(["", "-2x", "-3x", "-4x", "-5x", "-6x"].map((suffix) => [`${base}${suffix}`, target]));
66
-
67
- // Deprecated core ids -> kept replacement (core spec/catalogs/chart-types `deprecation.replacedBy`).
68
- export const DEPRECATED_CHART_TYPES = Object.freeze({
69
- ...variants("100pct-bullet-bar", "100pct-stacked-bar-3x"),
70
- "100pct-progress-bar": "100pct-stacked-bar-3x",
71
- "100pct-stacked-bar-2x": "100pct-stacked-bar-3x",
72
- ...variants("100pct-bullet-column", "100pct-stacked-column-3x"),
73
- "100pct-stacked-column-2x": "100pct-stacked-column-3x",
74
- "100pct-stacked-area-2x": "100pct-stacked-area-3x",
75
- australia: "world",
76
- canada: "world",
77
- "united-kingdom": "world",
78
- "united-states": "world",
79
- "box-and-whisker-2x": "box-and-whisker",
80
- "box-and-whisker-3x": "box-and-whisker",
81
- ...variants("bullet-bar", "bar"),
82
- "clustered-bar-2x": "bar",
83
- ...variants("bullet-column", "column"),
84
- "clustered-column": "column",
85
- ...suffixes("dot-plot", "scatter"),
86
- dumbbell: "scatter",
87
- "line-2x": "line",
88
- "line-3x": "line",
89
- "line-with-high-low": "line",
90
- ...suffixes("sparkline", "line"),
91
- "line-with-high-low-and-markers": "line-with-markers",
92
- "line-with-markers-2x": "line-with-markers",
93
- "line-with-markers-3x": "line-with-markers",
94
- "stacked-area-2x": "stacked-area-3x",
95
- "stacked-bar-2x": "stacked-bar-3x",
96
- "stacked-column-2x": "stacked-column-3x",
97
- "stacked-line-2x": "stacked-line-3x",
98
- "stacked-line-with-markers-2x": "stacked-line-with-markers-3x",
99
- "treemap-2x": "treemap",
100
- "treemap-3x": "treemap"
79
+ world: { kind: "map" },
80
+ // FA-15: clustered columns with line series (markers), the lines optionally on a secondary value axis (core resolves the plan).
81
+ combo: { kind: "combo", dir: "col", grouping: "clustered", markers: true }
101
82
  });
102
83
 
103
84
  const ALIASES = Object.freeze({ donut: "doughnut" });
@@ -105,7 +86,7 @@ const ALIASES = Object.freeze({ donut: "doughnut" });
105
86
  /** Resolve a chart type id to its kept catalog id, or null for ids outside the catalog. */
106
87
  export function resolveChartType(type) {
107
88
  const raw = String(type ?? "").trim().toLowerCase();
108
- const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
89
+ const id = ALIASES[raw] ?? raw;
109
90
  return Object.hasOwn(CHART_TYPES, id) ? id : null;
110
91
  }
111
92
 
@@ -124,7 +105,8 @@ const RENDERERS = {
124
105
  box: renderBoxWhiskerChart,
125
106
  waterfall: renderWaterfallChart,
126
107
  funnel: renderFunnelChart,
127
- map: renderRegionMapChart
108
+ map: renderRegionMapChart,
109
+ combo: renderComboChart
128
110
  };
129
111
 
130
112
  // Chartex constructs also accept a lone value column (the exporter plots it
@@ -147,6 +129,9 @@ export function renderCatalogChart(item, box, bound, options, svg) {
147
129
  // RR-35: a chart with axis titles, a legend position or data labels reserves their space before the plot is laid out. A chart
148
130
  // with none of them skips this block entirely, so its output is unchanged.
149
131
  const resolved = resolveOptions(item.value, spec);
132
+ // FA-14: a highlight changes colours only (no space is reserved), so it is resolved whether or not an option is active.
133
+ c.highlight = highlightFor(c, resolved, spec, data);
134
+ if (!resolved.active) reportOptionDiagnostics(c, resolved);
150
135
  if (resolved.active) {
151
136
  reportOptionDiagnostics(c, resolved);
152
137
  c.dataLabels = resolved.dataLabels;
@@ -162,20 +147,13 @@ export function renderCatalogChart(item, box, bound, options, svg) {
162
147
  // RR-54: the chart's data as the renderers plot it, from core's resolveChartData: inline columns (names or DataColumn objects),
163
148
  // a dataset reference and chart.mapping all resolve to one positional table, [category, (x,) ...series], with each column's number
164
149
  // format. Series cells have already gone through core's strict chartNumber. Returns null when the data does not resolve (an
165
- // external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder. A core without the resolver
166
- // reads the inline data as before.
150
+ // external data source, an unknown dataset, no rows or columns), so the caller keeps its placeholder.
167
151
  export function chartData(item, bound) {
168
152
  const chart = item.value;
169
- if (typeof opfComposition.resolveChartData === "function") {
170
- const resolved = opfComposition.resolveChartData(chart, bound?.presentation, { path: item.path });
171
- if (!resolved.ok) return null;
172
- const formats = resolved.formats.map((format) => typeof format === "string" && format !== "" && !opfComposition.numberFormatError?.(format) ? format : undefined);
173
- return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved) };
174
- }
175
- const data = chart?.data;
176
- const rows = Array.isArray(data?.rows) ? data.rows.map((row) => Array.isArray(row) ? row : [row]) : [];
177
- const columns = Array.isArray(data?.columns) ? data.columns : [];
178
- return { columns, rows, formats: [], trace: null };
153
+ const resolved = resolveChartData(chart, bound?.presentation, { path: item.path });
154
+ if (!resolved.ok) return null;
155
+ const formats = resolved.formats.map((format) => typeof format === "string" && format !== "" && !numberFormatError(format) ? format : undefined);
156
+ return { columns: resolved.columns, rows: resolved.rows, formats, trace: tracePaths(item, bound, resolved), ...(resolved.combo ? { combo: resolved.combo } : {}) };
179
157
  }
180
158
 
181
159
  // The authored object at a composed path ("slides.0.blocks.1.chart"), or undefined.
@@ -202,19 +180,19 @@ export function isDatasetTable(item, bound) {
202
180
  }
203
181
 
204
182
  // Where a mark's trace path points. A dataset-backed chart has no `data.rows` or `data.columns` of its own, so every part reports
205
- // the chart's authored path; a chart with `mapping` reports the authored column index of each plotted column (the resolved
206
- // order is category, X, then the mapped series).
183
+ // the chart's authored path; a chart with `mapping` (or a combo chart, whose column series come before its lines) reports the
184
+ // authored column index of each plotted column (the resolved order is category, X, then the mapped series).
207
185
  function tracePaths(item, bound, resolved) {
208
186
  if (isDatasetChart(item, bound)) return { collapse: true };
209
187
  const authored = authoredAt(bound?.presentation, item.path);
210
188
  const authoredData = authored && typeof authored === "object" ? authored.data : undefined;
211
- if (!authored || typeof authored !== "object" || !authored.mapping || !Array.isArray(authoredData?.columns)) return null;
189
+ if (!authored || typeof authored !== "object" || (!authored.mapping && !resolved.combo) || !Array.isArray(authoredData?.columns)) return null;
212
190
  const names = authoredData.columns.map((column) => column !== null && typeof column === "object" ? column.name : column);
213
191
  const columnMap = resolved.columns.map((name, index) => { const found = names.indexOf(name); return found < 0 ? index : found; });
214
192
  return columnMap.every((column, index) => column === index) ? null : { columnMap };
215
193
  }
216
194
 
217
- function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace }) {
195
+ function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace, combo }) {
218
196
  const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
219
197
  const composition = bound.composition ?? bound.geometry.composition ?? {};
220
198
  const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
@@ -232,7 +210,7 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
232
210
  return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
233
211
  };
234
212
  const c = {
235
- item, box, bound, options, svg, rows, columns, formats, children, u, fontPx, surface, labelColor,
213
+ item, box, bound, options, svg, rows, columns, formats, combo, children, u, fontPx, surface, labelColor,
236
214
  path: item.path,
237
215
  pt: u * 4 / 3,
238
216
  pad: 10 * u,
@@ -276,21 +254,13 @@ function chartContext(item, box, bound, options, svg, { rows, columns, formats,
276
254
  }
277
255
 
278
256
  // RR-54: core's strict chart number (finite numbers and strict decimal strings; everything else is a gap), so the preview, the PPTX
279
- // export and the validator agree on which cells are numbers. A core without it keeps the previous lenient read.
280
- export function chartNumber(value) {
281
- if (typeof opfComposition.chartNumber === "function") return opfComposition.chartNumber(value);
282
- if (typeof value === "number") return Number.isFinite(value) ? value : null;
283
- if (typeof value === "string" && value.trim()) {
284
- const parsed = Number(value);
285
- return Number.isFinite(parsed) ? parsed : null;
286
- }
287
- return null;
288
- }
257
+ // export and the validator agree on which cells are numbers.
258
+ export { chartNumber };
289
259
 
290
260
  /** A number as its column's format shows it (core's formatDataNumber); without a format, the General form the preview always drew. */
291
261
  export function formatChartValue(value, format) {
292
- if (format === undefined || typeof opfComposition.formatDataNumber !== "function") return String(clean(value));
293
- return opfComposition.formatDataNumber(clean(value), format);
262
+ if (format === undefined) return String(clean(value));
263
+ return formatDataNumber(clean(value), format);
294
264
  }
295
265
 
296
266
  // ---------------------------------------------------------------------------
@@ -435,16 +405,17 @@ function legendEntries(c, entries) {
435
405
 
436
406
  // RR-35: the legend entries of a chart whatever the series count (an explicit legend position shows a single series too).
437
407
  function legendSource(c, spec) {
438
- const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, key, marker }));
408
+ const fromSeries = (series, key, marker = false) => series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker }));
439
409
  switch (spec.kind) {
440
410
  case "pie":
441
- case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0` }));
411
+ case "doughnut": return c.rows.map((row, i) => ({ name: c.label(row[0]), color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0` }));
442
412
  case "scatter": return fromSeries(scatterSeries(c.rows, c.columns, c.formats).series, "marker");
443
413
  case "line": return fromSeries(c.series(), "line", spec.markers);
444
414
  case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
445
415
  case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
446
416
  case "bar":
447
417
  case "area": return fromSeries(c.series());
418
+ case "combo": return comboLegendEntries(c, c.series());
448
419
  default: return [];
449
420
  }
450
421
  }
@@ -494,7 +465,7 @@ function placeLegend(c, spec, position) {
494
465
 
495
466
  function seriesLegend(c, series, key, marker = false) {
496
467
  return series.length > 1
497
- ? 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 })))
468
+ ? legendEntries(c, series.map((s) => ({ name: s.name, color: seriesColor(c, s.index), path: `${c.path}.data.columns.${s.column}`, key, marker })))
498
469
  : 0;
499
470
  }
500
471
 
@@ -845,9 +816,9 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
845
816
  const geometry = barGeometry(band, series.length, spec.grouping);
846
817
  const labels = [];
847
818
  series.forEach((s, j) => {
848
- const color = c.colors[j % c.colors.length];
849
819
  stacks[j].forEach((point, i) => {
850
820
  if (!point) return;
821
+ const color = markColor(c, j, i);
851
822
  const slot = geometry.clustered ? j * geometry.width : 0;
852
823
  const a = at(base(point.from)), b = at(point.to);
853
824
  const path = `${c.path}.data.rows.${i}.${s.column}`;
@@ -871,8 +842,8 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
871
842
 
872
843
  function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i) => i }) {
873
844
  const radius = 3 * c.pt;
874
- series.forEach((s, j) => {
875
- const color = c.colors[j % c.colors.length];
845
+ for (let j = 0; j < series.length; j++) {
846
+ const s = series[j], color = seriesColor(c, j);
876
847
  let run = [];
877
848
  const flush = () => {
878
849
  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}`);
@@ -883,10 +854,13 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
883
854
  run.push([plot.x + (categorySlot(i) + 0.5) * band, at(point.to)]);
884
855
  });
885
856
  flush();
886
- if (spec.markers) stacks[j].forEach((point, i) => {
887
- if (point) c.mark("circle", { cx: plot.x + (categorySlot(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}`);
857
+ // FA-14: with a highlight each point takes its own colour, and a line without markers marks the highlighted categories' points.
858
+ if (spec.markers || c.highlight?.categories.some(Boolean)) stacks[j].forEach((point, i) => {
859
+ if (!point || (!spec.markers && !c.highlight.categories[i])) return;
860
+ const fill = markColor(c, j, i);
861
+ c.mark("circle", { cx: plot.x + (categorySlot(i) + 0.5) * band, cy: at(point.to), r: radius, fill, stroke: fill, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
888
862
  });
889
- });
863
+ }
890
864
  // RR-35: data labels sit beside each point, over every line.
891
865
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
892
866
  if (point) drawPointLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
@@ -894,20 +868,133 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
894
868
  }
895
869
 
896
870
  function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot = (i) => i }) {
897
- series.forEach((s, j) => {
871
+ for (let j = 0; j < series.length; j++) {
872
+ const s = series[j];
898
873
  const points = stacks[j].map((point, i) => ({ x: plot.x + (categorySlot(i) + 0.5) * band, from: point.from, to: point.to }));
899
874
  const upper = points.map((p) => `${c.num(p.x)} ${c.num(at(p.to))}`);
900
875
  const lower = points.slice().reverse().map((p) => `${c.num(p.x)} ${c.num(p.from === 0 ? crossing : at(p.from))}`);
901
- 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}`);
902
- });
876
+ c.mark("path", { d: `M ${upper.join(" L ")} L ${lower.join(" L ")} Z`, fill: seriesColor(c, j) }, `${c.path}.data.columns.${s.column}`);
877
+ }
903
878
  // RR-35: an area's label sits in the area at each category, halfway between its lower and upper edge.
904
879
  if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
905
880
  if (!point) return;
906
881
  const lowerEdge = point.from === 0 ? crossing : at(point.from);
907
- drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, c.colors[j % c.colors.length]);
882
+ drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i], format: s.format }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, seriesColor(c, j));
908
883
  }));
909
884
  }
910
885
 
886
+ // ---------------------------------------------------------------------------
887
+ // Combo (FA-15): clustered columns and line series with markers in one plot. Core's resolveChartData orders the series
888
+ // (column series first, then lines) and says which lines use the secondary value axis. That axis sits at the right (the left,
889
+ // right to left) with its own scale and its tick labels in the first secondary series' number format; the gridlines follow
890
+ // the primary axis, whose ticks take the first column series' format. The PPTX export writes the same plan natively.
891
+
892
+ /** The combo plan of a chart: core's, or (a core without it) every series a column except the last, a line, all on the primary axis. */
893
+ function comboPlan(c, series) {
894
+ if (Array.isArray(c.combo) && c.combo.length === series.length) return c.combo;
895
+ return series.map((_, index) => ({ role: series.length > 1 && index === series.length - 1 ? "line" : "bar", axis: "primary" }));
896
+ }
897
+
898
+ /** Legend entries in series order: a square key for a column series, a line with a marker for a line series. */
899
+ function comboLegendEntries(c, series) {
900
+ const plan = comboPlan(c, series);
901
+ return series.map((s, index) => ({ name: s.name, color: c.colors[s.index % c.colors.length], path: `${c.path}.data.columns.${s.column}`, ...(plan[index].role === "line" ? { key: "line", marker: true } : {}) }));
902
+ }
903
+
904
+ function renderComboChart(c, spec) {
905
+ const { box, pad, fontPx } = c;
906
+ const series = c.series();
907
+ const plan = comboPlan(c, series);
908
+ const bars = series.filter((_, index) => plan[index].role === "bar");
909
+ const lines = series.filter((_, index) => plan[index].role === "line");
910
+ const onSecondary = (s) => plan[s.index].axis === "secondary";
911
+ const primaryLines = lines.filter((s) => !onSecondary(s)), secondaryLines = lines.filter(onSecondary);
912
+ const categories = c.rows.map((row) => c.label(row[0]));
913
+ const count = categories.length;
914
+ // Right to left (RR-05): the categories run from the right, the primary value axis moves to the right and the secondary one to the left.
915
+ const rtl = c.bound.geometry?.direction === "rtl";
916
+ const categorySlot = (i) => rtl ? count - 1 - i : i;
917
+ const extent = (list) => {
918
+ const values = list.flatMap((s) => s.values).filter((value) => value !== null);
919
+ return values.length ? [Math.min(...values), Math.max(...values)] : [0, 1];
920
+ };
921
+ const [primaryMin, primaryMax] = extent([...bars, ...primaryLines]);
922
+ const [secondaryMin, secondaryMax] = extent(secondaryLines);
923
+ const primaryFormat = (bars[0] ?? primaryLines[0])?.format, secondaryFormat = secondaryLines[0]?.format;
924
+ // The default legend sits at the far right, outside a right axis title (the secondary axis title, or right to left the primary
925
+ // one), as PowerPoint places it: the legend is laid out against the box before the title band was carved, and the title moves in.
926
+ const rightTitle = c.axisTitleBands?.some((band) => band.side === "right") && !c.legendManaged;
927
+ let legendWidth = 0;
928
+ if (series.length > 1) {
929
+ const inner = c.box;
930
+ if (rightTitle) c.box = { ...inner, width: inner.width + c.lineHeight };
931
+ legendWidth = legendEntries(c, comboLegendEntries(c, series));
932
+ c.box = inner;
933
+ if (rightTitle) c.axisTitleRightInset = legendWidth + c.pad / 2;
934
+ }
935
+ const right = box.x + box.width - pad - legendWidth;
936
+ const top = box.y + pad + c.lineHeight / 2;
937
+ const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
938
+ if (rtl) entries.reverse();
939
+ const tickWidthOf = (scale, format) => Math.max(...scale.ticks.map((tick) => c.width(formatTick(tick, false, format)))) + fontPx * 0.5;
940
+ const axis = planCategoryAxis(c, entries, (reserve) => {
941
+ const height = Math.max(1, box.y + box.height - pad - reserve - top);
942
+ const intervals = maxIntervalsFor(height, c.lineHeight * 1.2);
943
+ const scale = niceScale(primaryMin, primaryMax, intervals);
944
+ const secondary = secondaryLines.length ? niceScale(secondaryMin, secondaryMax, intervals) : null;
945
+ const primaryWidth = tickWidthOf(scale, primaryFormat), secondaryWidth = secondary ? tickWidthOf(secondary, secondaryFormat) : 0;
946
+ const leftWidth = rtl ? secondaryWidth : primaryWidth, rightWidth = rtl ? primaryWidth : secondaryWidth;
947
+ const x = box.x + pad + (leftWidth || fontPx / 2);
948
+ const plotRight = right - (rightWidth || fontPx / 2);
949
+ return { scale, secondary, primaryWidth, secondaryWidth, plot: { x, y: top, width: Math.max(1, plotRight - x), height } };
950
+ });
951
+ const { plot, scale, secondary } = axis;
952
+ drawBottomLabels(c, axis.plan, plot, entries);
953
+ c.plotArea = plot;
954
+ const valueAt = (axisScale) => (value) => plot.y + axisFraction(value, axisScale, true) * plot.height;
955
+ const at = valueAt(scale), atSecondary = secondary ? valueAt(secondary) : at;
956
+ const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
957
+ const tickLabel = (label, position, side, width) => side === "left"
958
+ ? c.text(label, { x: box.x + pad, y: position - c.lineHeight / 2, width: Math.max(1, plot.x - box.x - pad - fontPx * 0.35), height: c.lineHeight }, c.path, "right")
959
+ : c.text(label, { x: plot.x + plot.width + fontPx * 0.35, y: position - c.lineHeight / 2, width: Math.max(1, width - fontPx * 0.35), height: c.lineHeight }, c.path, "left");
960
+ for (const tick of scale.ticks) {
961
+ const position = at(tick);
962
+ gridline(c, plot.x, position, plot.x + plot.width, position);
963
+ tickLabel(formatTick(tick, false, primaryFormat), position, rtl ? "right" : "left", axis.primaryWidth);
964
+ }
965
+ if (secondary) for (const tick of secondary.ticks) tickLabel(formatTick(tick, false, secondaryFormat), atSecondary(tick), rtl ? "left" : "right", axis.secondaryWidth);
966
+ const band = plot.width / count;
967
+ // Column series come first in core's order, so a column's palette index is its series index.
968
+ if (bars.length) drawBars(c, spec, bars, stackCategoryValues(bars, count, "bar", "clustered", c.path), { plot, band, at, horizontal: false, scale, categorySlot });
969
+ const point = (s, i, value) => [plot.x + (categorySlot(i) + 0.5) * band, (onSecondary(s) ? atSecondary : at)(value)];
970
+ const radius = 3 * c.pt;
971
+ for (const s of lines) {
972
+ const color = c.colors[s.index % c.colors.length];
973
+ let run = [];
974
+ const flush = () => {
975
+ 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}`);
976
+ run = [];
977
+ };
978
+ s.values.forEach((value, i) => { if (value === null) flush(); else run.push(point(s, i, value)); });
979
+ flush();
980
+ s.values.forEach((value, i) => {
981
+ if (value === null) return;
982
+ const [cx, cy] = point(s, i, value);
983
+ c.mark("circle", { cx, cy, r: radius, fill: color, stroke: color, "stroke-width": 0.75 * c.pt }, `${c.path}.data.rows.${i}.${s.column}`);
984
+ });
985
+ }
986
+ // Line labels sit beside each point at the combo's line position (core: above unless a point position was asked for).
987
+ if (c.dataLabels) for (const s of lines) s.values.forEach((value, i) => {
988
+ if (value === null) return;
989
+ const category = c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]);
990
+ drawPointLabel(c, labelString(c, { category, value, format: s.format }), point(s, i, value), `${c.path}.data.rows.${i}.${s.column}`, c.dataLabels.linePosition ?? "above");
991
+ });
992
+ const primaryX = rtl ? plot.x + plot.width : plot.x, secondaryX = rtl ? plot.x : plot.x + plot.width;
993
+ axisLine(c, primaryX, plot.y, primaryX, plot.y + plot.height);
994
+ if (secondary) axisLine(c, secondaryX, plot.y, secondaryX, plot.y + plot.height);
995
+ axisLine(c, plot.x, crossing, plot.x + plot.width, crossing);
996
+ }
997
+
911
998
  // ---------------------------------------------------------------------------
912
999
  // Pie and doughnut: first series only, clockwise from 12 o'clock, one colour per category.
913
1000
 
@@ -916,7 +1003,7 @@ function renderCircularChart(c, spec) {
916
1003
  const categories = c.rows.map((row) => c.label(row[0]));
917
1004
  const values = c.rows.map((row) => Math.abs(chartNumber(row[1]) ?? 0));
918
1005
  const legendWidth = legendEntries(c, categories.map((name, i) => ({
919
- name, color: c.colors[i % c.colors.length], path: `${c.path}.data.rows.${i}.0`
1006
+ name, color: sliceColor(c, i), path: `${c.path}.data.rows.${i}.0`
920
1007
  })));
921
1008
  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) };
922
1009
  const cx = area.x + area.width / 2, cy = area.y + area.height / 2;
@@ -936,7 +1023,7 @@ function renderCircularChart(c, spec) {
936
1023
  let angle = -Math.PI / 2;
937
1024
  values.forEach((value, i) => {
938
1025
  const delta = value / total * Math.PI * 2, end = angle + delta;
939
- const fill = c.colors[i % c.colors.length], path = `${c.path}.data.rows.${i}.1`;
1026
+ const fill = sliceColor(c, i), path = `${c.path}.data.rows.${i}.1`;
940
1027
  if (delta >= Math.PI * 2 - 1e-9) {
941
1028
  if (inner) c.mark("path", { d: `${circlePath(c, cx, cy, r)} ${circlePath(c, cx, cy, inner)}`, fill, "fill-rule": "evenodd", ...border }, path);
942
1029
  else c.mark("circle", { cx, cy, r, fill, ...border }, path);
@@ -949,7 +1036,7 @@ function renderCircularChart(c, spec) {
949
1036
  let start = -Math.PI / 2;
950
1037
  values.forEach((value, i) => {
951
1038
  const delta = value / total * Math.PI * 2;
952
- if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, c.colors[i % c.colors.length]);
1039
+ if (delta > 0) drawSliceLabel(c, sliceTexts[i], { cx, cy, r, inner, mid: start + delta / 2 }, `${c.path}.data.rows.${i}.1`, sliceColor(c, i));
953
1040
  start += delta;
954
1041
  });
955
1042
  }
@@ -1020,10 +1107,10 @@ function renderScatterChart(c) {
1020
1107
  const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
1021
1108
  axisLine(c, xCross, plot.y, xCross, plot.y + plot.height);
1022
1109
  axisLine(c, plot.x, yCross, plot.x + plot.width, yCross);
1023
- series.forEach((s, j) => {
1024
- const color = c.colors[j % c.colors.length];
1110
+ for (let j = 0; j < series.length; j++) {
1111
+ const s = series[j], color = seriesColor(c, j);
1025
1112
  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}`);
1026
- });
1113
+ }
1027
1114
  // RR-35: a scatter label shows the Y value, and the X value as its category.
1028
1115
  if (c.dataLabels) series.forEach((s) => {
1029
1116
  for (const point of s.points) drawPointLabel(c, labelString(c, { category: formatChartValue(point.x, xFormat), value: point.y, format: s.format }), [xAt(point.x), yAt(point.y)], `${c.path}.data.rows.${point.row}.${s.column}`);
@@ -1092,12 +1179,12 @@ function renderRadarChart(c, spec) {
1092
1179
  c.text(text, rect, label.path, align);
1093
1180
  });
1094
1181
  reportTruncatedLabels(c, truncated);
1095
- series.forEach((s, j) => {
1096
- const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
1182
+ for (let j = 0; j < series.length; j++) {
1183
+ const s = series[j], color = seriesColor(c, j), path = `${c.path}.data.columns.${s.column}`;
1097
1184
  if (spec.style === "filled") {
1098
1185
  const outline = s.values.map((v, i) => point(i, v ?? scale.min).map(n).join(" "));
1099
1186
  c.mark("path", { d: `M ${outline.join(" L ")} Z`, fill: color }, path);
1100
- return;
1187
+ continue;
1101
1188
  }
1102
1189
  const at = (i) => point(i, s.values[i]).map(n).join(" ");
1103
1190
  let d = "";
@@ -1112,7 +1199,7 @@ function renderRadarChart(c, spec) {
1112
1199
  const [x, y] = point(i, v);
1113
1200
  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}`);
1114
1201
  });
1115
- });
1202
+ }
1116
1203
  // Value axis labels sit on top of filled series, as in PowerPoint.
1117
1204
  for (const tick of scale.ticks) {
1118
1205
  const label = formatTick(tick, false, valueFormat), width = c.width(label) + fontPx * 0.5;