@openpresentation/opf-render 0.13.1 → 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.
- package/README.md +106 -76
- package/dist/chart-options.js +26 -21
- package/dist/charts.js +204 -117
- package/dist/deck-runtime.js +21 -34
- package/dist/element.d.ts +11 -6
- package/dist/element.js +28 -18
- package/dist/export-browser.d.ts +7 -1
- package/dist/export-browser.js +7 -6
- package/dist/font-compatibility.js +4 -4
- package/dist/font-registry.js +324 -0
- package/dist/fonts-browser.d.ts +25 -3
- package/dist/fonts-browser.js +54 -3
- package/dist/fonts-node.d.ts +32 -16
- package/dist/fonts-node.js +110 -50
- package/dist/fonts.d.ts +48 -27
- package/dist/fonts.js +7 -320
- package/dist/index.d.ts +36 -16
- package/dist/lazy-font-list.js +1 -1
- package/dist/lazy-fonts.js +7 -7
- package/dist/pdf-fonts.js +1 -1
- package/dist/player.d.ts +4 -4
- package/dist/player.js +5 -5
- package/dist/preview-fonts.d.ts +4 -4
- package/dist/preview-fonts.js +6 -5
- package/dist/raster.js +24 -9
- package/dist/script-font-pack.js +19 -26
- package/dist/script-fonts.js +6 -17
- package/dist/svg.d.ts +30 -14
- package/dist/svg.js +286 -266
- package/dist/symbol-fonts.js +22 -55
- package/package.json +2 -2
- package/dist/font-policy.js +0 -2734
- package/dist/symbol-encodings.js +0 -7
package/dist/chart-options.js
CHANGED
|
@@ -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
|
|
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.
|
|
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 (
|
|
16
|
-
return
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
if (typeof share === "number" && Number.isFinite(share)) parts.percent =
|
|
89
|
-
return
|
|
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 ?
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
38
|
-
"100pct-stacked-column
|
|
55
|
+
"stacked-column": bar("col", "stacked"),
|
|
56
|
+
"100pct-stacked-column": bar("col", "percentStacked"),
|
|
39
57
|
bar: bar("bar", "clustered"),
|
|
40
|
-
"stacked-bar
|
|
41
|
-
"100pct-stacked-bar
|
|
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
|
|
45
|
-
"stacked-line-with-markers
|
|
62
|
+
"stacked-line": line("stacked", false),
|
|
63
|
+
"stacked-line-with-markers": line("stacked", true),
|
|
46
64
|
area: area("standard"),
|
|
47
|
-
"stacked-area
|
|
48
|
-
"100pct-stacked-area
|
|
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] ??
|
|
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.
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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`
|
|
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.
|
|
280
|
-
export
|
|
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
|
|
293
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
875
|
-
const
|
|
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
|
-
|
|
887
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
1024
|
-
const
|
|
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.
|
|
1096
|
-
const
|
|
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
|
-
|
|
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;
|