@openpresentation/opf-render 0.12.2 → 0.13.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 +2 -0
- package/dist/chart-options.js +3 -3
- package/dist/charts.js +151 -47
- package/dist/svg.js +27 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -378,6 +378,8 @@ Chartex chart previews (FF-22b) draw the constructs opf-pptx exports as Office 2
|
|
|
378
378
|
|
|
379
379
|
Chart options (RR-35): a chart's optional `axisTitles`, `legend` and `dataLabels` (core `docs/chart-options.md`) are drawn. A named legend position is carved from the chart box before the plot is laid out, axis titles sit beside the axes (the value title rotated 270 degrees), and data labels follow the content (category, value, percent), position and separator the chart type supports. An option a type cannot show is dropped with a `chart-option-adapted` diagnostic; a chart without the fields draws as before.
|
|
380
380
|
|
|
381
|
+
Chart and table data (RR-54, needs the core release with `resolveChartData`; core `docs/chart-table-data.md`): every chart path plots the data core resolves, so `DataColumn` headers, `{ dataset, fields }` references to the top-level `datasets` map and `chart.mapping` (category, scatter X and series by column name) all draw, and a series is labelled with its column `name`. Chart numbers are core's strict `chartNumber` (`"12%"`, `"(5)"` and `"Q1"` are gaps, `"1e6"` is 1000000). A column's number `format` shapes its data labels and the value-axis tick labels (the first plotted series' format; the scatter X axis follows the X column; percent axes and pie percent labels stay percent; a 100% stacked chart's data labels show the values in their own format, as PowerPoint does), and table cells draw the formatted text. A formatted horizontal axis (horizontal bars, the scatter X axis) pulls its plot in until the first and last tick labels stay inside the chart; a General axis keeps its placement. A dataset chart or table draws exactly its inline equivalent and traces every part to its authored `slides.N.chart` or `slides.N.table` path; a chart with `mapping` traces each part to the authored column. The legacy sketch (a chart type outside the catalog) plots a gap as nothing. A document without the new fields draws byte for byte as before; a `ChartDataSource` still draws the "No chart data" placeholder.
|
|
382
|
+
|
|
381
383
|
Classic chart previews keep finite axis coordinates for subnormal values and values up to `Number.MAX_VALUE`, including mixed positive/negative ranges. At these limits, available numeric precision can merge adjacent axis ticks or prevent extra headroom. Authored values are unchanged. Stacked, percentage or pie/doughnut totals that overflow the finite numeric range are still unsupported: rendering throws a `RangeError` naming the chart path and asking the caller to rescale the values. It does not substitute an empty chart or silently discard the overflowing values.
|
|
382
384
|
|
|
383
385
|
Category-axis labels follow PowerPoint's automatic labelling instead of wrapping inside a band (column, bar, line and area charts, including stacked and 100%, and the chartex histogram, pareto, waterfall, funnel and box-and-whisker previews). A label is never broken inside a word: it stays on one line, or splits at its spaces onto two lines when a band is too narrow, so `Category 1` cannot become `Ca` over `t 1`. When the labels do not fit horizontally they turn to -45 degrees, then -90 degrees (the plot area shrinks to make room, by at most 40% of the chart height, and a slanted first label must stay inside the chart), and when even that collides the axis draws every n-th label (Office's automatic `tickLblSkip`): n is the smallest interval that leaves no two drawn labels overlapping, the first label is always drawn, and ties go to the less rotated arrangement. In horizontal bar charts and funnels, where the category axis is vertical, rows shorter than a line of text are skipped the same way, and the label gutter grows with the longest label up to 30% of the chart width. Only a label wider than that gutter (or wider than the whole chart on a horizontal axis) is ellipsized, and then the renderer reports one `chart-label-truncated` diagnostic per chart with the affected label paths in `labels`; nothing is shortened silently, and no arrangement raises `text-overflow` for an axis label. Radar spoke labels use the same one-line, two-line or ellipsis rule and leave out a spoke label that would collide with one already drawn. Rotated labels are wrapped in a `<g transform="rotate(...)">` around their usual text group, so the traced `data-opf-box-*` attributes describe the unrotated box. PowerPoint applies its own automatic rotation and skip to the exported chart: opf-pptx writes an automatic axis text body (`<a:bodyPr/>`, no `rot`) and no `tickLblSkip` for these axes, so nothing forces a wrap, and the preview and the export make the same kind of choice, not identical ones (the export uses 9 pt labels, the preview the readability floor).
|
package/dist/chart-options.js
CHANGED
|
@@ -79,12 +79,12 @@ export function drawAxisTitles(c) {
|
|
|
79
79
|
|
|
80
80
|
const INSIDE = new Set(["center", "inside-end", "inside-base"]);
|
|
81
81
|
|
|
82
|
-
/** The label text for one mark: the selected parts,
|
|
83
|
-
export function labelString(c, { category, value, share }) {
|
|
82
|
+
/** The label text for one mark: the selected parts, the series' number format (General when it has none), integer percent. */
|
|
83
|
+
export function labelString(c, { category, value, share, format }) {
|
|
84
84
|
const labels = c.dataLabels;
|
|
85
85
|
const parts = {};
|
|
86
86
|
if (category !== undefined && category !== null) parts.category = String(category);
|
|
87
|
-
if (typeof value === "number" && Number.isFinite(value)) parts.value = core.formatChartLabelNumber(value);
|
|
87
|
+
if (typeof value === "number" && Number.isFinite(value)) parts.value = format !== undefined && typeof core.formatDataNumber === "function" ? core.formatDataNumber(value, format) : core.formatChartLabelNumber(value);
|
|
88
88
|
if (typeof share === "number" && Number.isFinite(share)) parts.percent = core.formatChartLabelPercent(share);
|
|
89
89
|
return core.chartLabelText(parts, labels.content, labels.separator);
|
|
90
90
|
}
|
package/dist/charts.js
CHANGED
|
@@ -140,11 +140,9 @@ export function renderCatalogChart(item, box, bound, options, svg) {
|
|
|
140
140
|
const id = resolveChartType(item.value?.type);
|
|
141
141
|
const spec = id && CHART_TYPES[id];
|
|
142
142
|
const render = spec && RENDERERS[spec.kind];
|
|
143
|
-
const data = item
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
if (!render || !rows.length || columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
|
|
147
|
-
const c = chartContext(item, box, bound, options, svg, rows, columns);
|
|
143
|
+
const data = render ? chartData(item, bound) : null;
|
|
144
|
+
if (!data || !data.rows.length || data.columns.length < (CHARTEX_KINDS.has(spec.kind) ? 1 : 2)) return null;
|
|
145
|
+
const c = chartContext(item, box, bound, options, svg, data);
|
|
148
146
|
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);
|
|
149
147
|
// RR-35: a chart with axis titles, a legend position or data labels reserves their space before the plot is laid out. A chart
|
|
150
148
|
// with none of them skips this block entirely, so its output is unchanged.
|
|
@@ -161,7 +159,62 @@ export function renderCatalogChart(item, box, bound, options, svg) {
|
|
|
161
159
|
return svg.tag("g", { ...svg.traceAttrs(options, item.path), "data-opf-chart": options.trace ? id : undefined }, c.children.join("\n"));
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
|
|
162
|
+
// RR-54: the chart's data as the renderers plot it, from core's resolveChartData: inline columns (names or DataColumn objects),
|
|
163
|
+
// a dataset reference and chart.mapping all resolve to one positional table, [category, (x,) ...series], with each column's number
|
|
164
|
+
// 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.
|
|
167
|
+
export function chartData(item, bound) {
|
|
168
|
+
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 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The authored object at a composed path ("slides.0.blocks.1.chart"), or undefined.
|
|
182
|
+
function authoredAt(presentation, path) {
|
|
183
|
+
let value = presentation;
|
|
184
|
+
for (const key of String(path).split(".")) {
|
|
185
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
186
|
+
value = value[key];
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** True when the chart's authored data is a dataset reference: its parts then report the chart's own path. */
|
|
192
|
+
export function isDatasetChart(item, bound) {
|
|
193
|
+
const authored = authoredAt(bound?.presentation, item.path);
|
|
194
|
+
const data = authored && typeof authored === "object" ? authored.data : undefined;
|
|
195
|
+
return Boolean(data) && typeof data === "object" && typeof data.dataset === "string";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True when the table's authored form is a dataset reference (`{ dataset, fields }`): its cells then report the table's own path. */
|
|
199
|
+
export function isDatasetTable(item, bound) {
|
|
200
|
+
const authored = authoredAt(bound?.presentation, item.path);
|
|
201
|
+
return Boolean(authored) && typeof authored === "object" && typeof authored.dataset === "string";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 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).
|
|
207
|
+
function tracePaths(item, bound, resolved) {
|
|
208
|
+
if (isDatasetChart(item, bound)) return { collapse: true };
|
|
209
|
+
const authored = authoredAt(bound?.presentation, item.path);
|
|
210
|
+
const authoredData = authored && typeof authored === "object" ? authored.data : undefined;
|
|
211
|
+
if (!authored || typeof authored !== "object" || !authored.mapping || !Array.isArray(authoredData?.columns)) return null;
|
|
212
|
+
const names = authoredData.columns.map((column) => column !== null && typeof column === "object" ? column.name : column);
|
|
213
|
+
const columnMap = resolved.columns.map((name, index) => { const found = names.indexOf(name); return found < 0 ? index : found; });
|
|
214
|
+
return columnMap.every((column, index) => column === index) ? null : { columnMap };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function chartContext(item, box, bound, options, svg, { rows, columns, formats, trace }) {
|
|
165
218
|
const u = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
166
219
|
const composition = bound.composition ?? bound.geometry.composition ?? {};
|
|
167
220
|
const requested = 14, fontPx = Math.max(requested, composition.minFontSize ?? 16) * u;
|
|
@@ -172,8 +225,14 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
|
|
|
172
225
|
const children = [];
|
|
173
226
|
const n = svg.stableNumber;
|
|
174
227
|
const numericAttrs = new Set(["x", "y", "width", "height", "cx", "cy", "r", "x1", "x2", "y1", "y2", "stroke-width"]);
|
|
228
|
+
const prefix = `${item.path}.data.`;
|
|
229
|
+
const tracePath = !trace ? (path) => path : (path) => {
|
|
230
|
+
if (typeof path !== "string" || !path.startsWith(prefix)) return path;
|
|
231
|
+
if (trace.collapse) return item.path;
|
|
232
|
+
return path.replace(/^(.*\.data\.(?:columns\.|rows\.\d+\.))(\d+)$/, (whole, head, column) => `${head}${trace.columnMap[Number(column)] ?? column}`);
|
|
233
|
+
};
|
|
175
234
|
const c = {
|
|
176
|
-
item, box, bound, options, svg, rows, columns, children, u, fontPx, surface, labelColor,
|
|
235
|
+
item, box, bound, options, svg, rows, columns, formats, children, u, fontPx, surface, labelColor,
|
|
177
236
|
path: item.path,
|
|
178
237
|
pt: u * 4 / 3,
|
|
179
238
|
pad: 10 * u,
|
|
@@ -192,12 +251,12 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
|
|
|
192
251
|
mark(name, attrs, path) {
|
|
193
252
|
const out = {};
|
|
194
253
|
for (const [key, value] of Object.entries(attrs)) out[key] = numericAttrs.has(key) && typeof value === "number" ? n(value) : value;
|
|
195
|
-
children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, path) : {}) }));
|
|
254
|
+
children.push(svg.tag(name, { ...out, ...(path ? svg.traceAttrs(options, tracePath(path)) : {}) }));
|
|
196
255
|
},
|
|
197
256
|
textElement(value, rect, path, align = "center", fill = labelColor) {
|
|
198
257
|
if (!(rect.width > 0 && rect.height > 0)) return "";
|
|
199
258
|
return svg.renderTextBox(String(value), rect, bound, {
|
|
200
|
-
path, fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
|
|
259
|
+
path: tracePath(path), fontSize: requested, fontFamily: bound.design.fonts.body, fontWeight: 400,
|
|
201
260
|
fill, options, align, verticalAlign: "middle"
|
|
202
261
|
});
|
|
203
262
|
},
|
|
@@ -208,6 +267,7 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
|
|
|
208
267
|
series(first = 1) {
|
|
209
268
|
return columns.slice(first).map((name, offset) => ({
|
|
210
269
|
name: c.label(name), column: first + offset, index: offset,
|
|
270
|
+
...(formats[first + offset] !== undefined ? { format: formats[first + offset] } : {}),
|
|
211
271
|
values: rows.map((row) => chartNumber(row[first + offset]))
|
|
212
272
|
}));
|
|
213
273
|
}
|
|
@@ -215,7 +275,10 @@ function chartContext(item, box, bound, options, svg, rows, columns) {
|
|
|
215
275
|
return c;
|
|
216
276
|
}
|
|
217
277
|
|
|
278
|
+
// 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.
|
|
218
280
|
export function chartNumber(value) {
|
|
281
|
+
if (typeof opfComposition.chartNumber === "function") return opfComposition.chartNumber(value);
|
|
219
282
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
220
283
|
if (typeof value === "string" && value.trim()) {
|
|
221
284
|
const parsed = Number(value);
|
|
@@ -224,6 +287,12 @@ export function chartNumber(value) {
|
|
|
224
287
|
return null;
|
|
225
288
|
}
|
|
226
289
|
|
|
290
|
+
/** A number as its column's format shows it (core's formatDataNumber); without a format, the General form the preview always drew. */
|
|
291
|
+
export function formatChartValue(value, format) {
|
|
292
|
+
if (format === undefined || typeof opfComposition.formatDataNumber !== "function") return String(clean(value));
|
|
293
|
+
return opfComposition.formatDataNumber(clean(value), format);
|
|
294
|
+
}
|
|
295
|
+
|
|
227
296
|
// ---------------------------------------------------------------------------
|
|
228
297
|
// Shared axis and legend helpers
|
|
229
298
|
|
|
@@ -308,10 +377,28 @@ function clean(value) {
|
|
|
308
377
|
return Object.is(rounded, -0) ? 0 : rounded;
|
|
309
378
|
}
|
|
310
379
|
|
|
311
|
-
/** Office General / 0% tick labels. */
|
|
312
|
-
export function formatTick(value, percent = false) {
|
|
380
|
+
/** Office General / 0% tick labels; a value axis takes its first plotted series' number format (RR-54). */
|
|
381
|
+
export function formatTick(value, percent = false, format = undefined) {
|
|
313
382
|
if (percent) return `${clean(value * 100)}%`;
|
|
314
|
-
return String(clean(value));
|
|
383
|
+
return format === undefined ? String(clean(value)) : formatChartValue(value, format);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// RR-54: a formatted horizontal axis (horizontal bars, the scatter X axis) can have end tick labels far wider than the margin the layout
|
|
387
|
+
// leaves beside the plot (a General label is a few characters, a long format code is not). Pull the plot in until the first and
|
|
388
|
+
// last labels, centred on their ticks, stay inside the chart, and lay the ticks out again for the new width. Only formatted axes call it,
|
|
389
|
+
// so a General axis keeps its placement.
|
|
390
|
+
function insetForEndLabels(c, x, width, format, scaleFor) {
|
|
391
|
+
const left = c.box.x + c.pad, right = c.box.x + c.box.width - c.pad;
|
|
392
|
+
let scale = scaleFor(width);
|
|
393
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
394
|
+
const ticks = scale.ticks;
|
|
395
|
+
if (!ticks.length) break;
|
|
396
|
+
const first = c.width(formatTick(ticks[0], false, format)) / 2, last = c.width(formatTick(ticks[ticks.length - 1], false, format)) / 2;
|
|
397
|
+
const nextX = Math.max(x, left + first), nextWidth = Math.max(1, Math.min(x + width, right - last) - nextX);
|
|
398
|
+
if (nextX === x && nextWidth === width) break;
|
|
399
|
+
x = nextX; width = nextWidth; scale = scaleFor(width);
|
|
400
|
+
}
|
|
401
|
+
return { x, width, scale };
|
|
315
402
|
}
|
|
316
403
|
|
|
317
404
|
function legendMetrics(c, entries) {
|
|
@@ -352,7 +439,7 @@ function legendSource(c, spec) {
|
|
|
352
439
|
switch (spec.kind) {
|
|
353
440
|
case "pie":
|
|
354
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` }));
|
|
355
|
-
case "scatter": return fromSeries(scatterSeries(c.rows, c.columns).series, "marker");
|
|
442
|
+
case "scatter": return fromSeries(scatterSeries(c.rows, c.columns, c.formats).series, "marker");
|
|
356
443
|
case "line": return fromSeries(c.series(), "line", spec.markers);
|
|
357
444
|
case "radar": return fromSeries(c.series(), spec.style === "filled" ? undefined : "line", spec.markers);
|
|
358
445
|
case "box": return fromSeries(c.columns.length === 1 ? [{ name: c.label(c.columns[0]), column: 0, index: 0 }] : c.series());
|
|
@@ -661,6 +748,7 @@ function renderCategoryChart(c, spec) {
|
|
|
661
748
|
const horizontal = spec.kind === "bar" && spec.dir === "bar";
|
|
662
749
|
const percent = spec.grouping === "percentStacked";
|
|
663
750
|
const series = c.series();
|
|
751
|
+
const valueFormat = series[0]?.format;
|
|
664
752
|
const categories = c.rows.map((row) => c.label(row[0]));
|
|
665
753
|
const count = categories.length;
|
|
666
754
|
// Right to left (RR-05): column, line and area charts run their category axis from the right (PowerPoint's reversed categories,
|
|
@@ -680,11 +768,14 @@ function renderCategoryChart(c, spec) {
|
|
|
680
768
|
let plot, scale, valueTickWidth;
|
|
681
769
|
if (horizontal) {
|
|
682
770
|
const categoryWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
|
|
683
|
-
|
|
771
|
+
let x = box.x + pad + categoryWidth + c.pad / 2;
|
|
684
772
|
const bottom = box.y + box.height - pad - c.lineHeight;
|
|
685
|
-
|
|
686
|
-
const tickWidth = Math.max(c.width(formatTick(dataMax, percent)), c.width(formatTick(dataMin, percent)), c.width("100%")) + fontPx;
|
|
687
|
-
|
|
773
|
+
let width = Math.max(1, right - x - fontPx);
|
|
774
|
+
const tickWidth = Math.max(c.width(formatTick(dataMax, percent, valueFormat)), c.width(formatTick(dataMin, percent, valueFormat)), c.width("100%")) + fontPx;
|
|
775
|
+
const scaleFor = (w) => niceScale(dataMin, dataMax, maxIntervalsFor(w, tickWidth), { percent });
|
|
776
|
+
scale = scaleFor(width);
|
|
777
|
+
// RR-54: a formatted value axis keeps its end labels inside the chart.
|
|
778
|
+
if (valueFormat !== undefined && !percent) ({ x, width, scale } = insetForEndLabels(c, x, width, valueFormat, scaleFor));
|
|
688
779
|
plot = { x, y: top, width, height: Math.max(1, bottom - top) };
|
|
689
780
|
// Office bar charts draw the first category nearest the origin (bottom).
|
|
690
781
|
const entries = categories.map((name, i) => ({ name, path: `${c.path}.data.rows.${i}.0` }));
|
|
@@ -696,7 +787,7 @@ function renderCategoryChart(c, spec) {
|
|
|
696
787
|
const axis = planCategoryAxis(c, entries, (reserve) => {
|
|
697
788
|
const height = Math.max(1, box.y + box.height - pad - reserve - top);
|
|
698
789
|
const fitted = niceScale(dataMin, dataMax, maxIntervalsFor(height, c.lineHeight * 1.2), { percent });
|
|
699
|
-
const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent)))) + fontPx * 0.5;
|
|
790
|
+
const tickWidth = Math.max(...fitted.ticks.map((tick) => c.width(formatTick(tick, percent, valueFormat)))) + fontPx * 0.5;
|
|
700
791
|
if (rtl) {
|
|
701
792
|
const x = box.x + pad + fontPx / 2;
|
|
702
793
|
return { scale: fitted, plot: { x, y: top, width: Math.max(1, right - tickWidth - x), height }, tickWidth };
|
|
@@ -713,7 +804,7 @@ function renderCategoryChart(c, spec) {
|
|
|
713
804
|
// Category axis crosses at zero when zero is on the axis (autoZero).
|
|
714
805
|
const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
715
806
|
for (const tick of scale.ticks) {
|
|
716
|
-
const position = at(tick), label = formatTick(tick, percent);
|
|
807
|
+
const position = at(tick), label = formatTick(tick, percent, valueFormat);
|
|
717
808
|
if (horizontal) {
|
|
718
809
|
gridline(c, position, plot.y, position, plot.y + plot.height);
|
|
719
810
|
const width = c.width(label) + fontPx;
|
|
@@ -771,7 +862,7 @@ function drawBars(c, spec, series, stacks, { plot, band, at, horizontal, scale,
|
|
|
771
862
|
rect = { x, y: Math.min(a, b), width: geometry.width, height: Math.abs(b - a) };
|
|
772
863
|
c.mark("rect", { ...rect, fill: color }, path);
|
|
773
864
|
}
|
|
774
|
-
if (c.dataLabels) labels.push({ rect, direction: horizontal ? (point.to >= point.from ? "right" : "left") : (point.to >= point.from ? "up" : "down"), path, color, text: labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i] }) });
|
|
865
|
+
if (c.dataLabels) labels.push({ rect, direction: horizontal ? (point.to >= point.from ? "right" : "left") : (point.to >= point.from ? "up" : "down"), path, color, text: 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 }) });
|
|
775
866
|
});
|
|
776
867
|
});
|
|
777
868
|
// RR-35: data labels sit on top of every bar.
|
|
@@ -798,7 +889,7 @@ function drawLines(c, spec, series, stacks, { plot, band, at, categorySlot = (i)
|
|
|
798
889
|
});
|
|
799
890
|
// RR-35: data labels sit beside each point, over every line.
|
|
800
891
|
if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
|
|
801
|
-
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] }), [plot.x + (i + 0.5) * band, at(point.to)], `${c.path}.data.rows.${i}.${s.column}`);
|
|
892
|
+
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}`);
|
|
802
893
|
}));
|
|
803
894
|
}
|
|
804
895
|
|
|
@@ -813,7 +904,7 @@ function drawAreas(c, series, stacks, { plot, band, at, crossing, categorySlot =
|
|
|
813
904
|
if (c.dataLabels) series.forEach((s, j) => stacks[j].forEach((point, i) => {
|
|
814
905
|
if (!point) return;
|
|
815
906
|
const lowerEdge = point.from === 0 ? crossing : at(point.from);
|
|
816
|
-
drawCenteredLabel(c, labelString(c, { category: c.rows[i][0] === null || c.rows[i][0] === undefined ? "" : String(c.rows[i][0]), value: s.values[i] }), plot.x + (i + 0.5) * band, (lowerEdge + at(point.to)) / 2, `${c.path}.data.rows.${i}.${s.column}`, c.colors[j % c.colors.length]);
|
|
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]);
|
|
817
908
|
}));
|
|
818
909
|
}
|
|
819
910
|
|
|
@@ -832,7 +923,7 @@ function renderCircularChart(c, spec) {
|
|
|
832
923
|
const total = values.reduce((sum, value) => sum + value, 0);
|
|
833
924
|
if (!Number.isFinite(total)) throw chartAggregateError(c.path, "slice total");
|
|
834
925
|
// RR-35: a slice's label text (category, the signed value, the share of the total); outside-end labels shrink the pie to leave room.
|
|
835
|
-
const sliceTexts = c.dataLabels ? values.map((value, i) => labelString(c, { category: categories[i], value: chartNumber(c.rows[i][1]) ?? 0, share: total ? value / total : 0 })) : null;
|
|
926
|
+
const sliceTexts = c.dataLabels ? values.map((value, i) => labelString(c, { category: categories[i], value: chartNumber(c.rows[i][1]) ?? 0, share: total ? value / total : 0, format: c.formats[1] })) : null;
|
|
836
927
|
const outside = sliceTexts && spec.kind === "pie" && (c.dataLabels.position ?? "outside-end") === "outside-end";
|
|
837
928
|
const reserve = outside ? outsideLabelReserve(c, sliceTexts) : { x: 0, y: 0 };
|
|
838
929
|
const r = outside ? Math.max(1, Math.min(area.width / 2 - reserve.x, area.height / 2 - reserve.y)) : Math.max(1, Math.min(area.width, area.height) / 2 * 0.9);
|
|
@@ -884,12 +975,13 @@ function slicePath(c, cx, cy, r, inner, start, end) {
|
|
|
884
975
|
// ---------------------------------------------------------------------------
|
|
885
976
|
// Scatter: X from columns[1], Y series from columns[2..], markers only.
|
|
886
977
|
|
|
887
|
-
export function scatterSeries(rows, columns) {
|
|
978
|
+
export function scatterSeries(rows, columns, formats = []) {
|
|
888
979
|
const numericX = columns.length > 2;
|
|
889
980
|
const xs = rows.map((row, i) => numericX ? chartNumber(row[1]) : i + 1);
|
|
890
981
|
const first = numericX ? 2 : 1;
|
|
891
982
|
const series = columns.slice(first).map((name, offset) => ({
|
|
892
983
|
name: name === null || name === undefined ? "" : String(name), column: first + offset, index: offset,
|
|
984
|
+
...(formats[first + offset] !== undefined ? { format: formats[first + offset] } : {}),
|
|
893
985
|
points: rows.map((row, i) => ({ row: i, x: xs[i], y: chartNumber(row[first + offset]) })).filter((p) => p.x !== null && p.y !== null)
|
|
894
986
|
}));
|
|
895
987
|
return { series, xs };
|
|
@@ -897,28 +989,32 @@ export function scatterSeries(rows, columns) {
|
|
|
897
989
|
|
|
898
990
|
function renderScatterChart(c) {
|
|
899
991
|
const { box, pad, fontPx } = c;
|
|
900
|
-
const { series } = scatterSeries(c.rows, c.columns);
|
|
992
|
+
const { series } = scatterSeries(c.rows, c.columns, c.formats);
|
|
993
|
+
const yFormat = series[0]?.format, xFormat = c.columns.length > 2 ? c.formats[1] : undefined;
|
|
901
994
|
const points = series.flatMap((s) => s.points);
|
|
902
995
|
const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
|
|
903
996
|
const legendWidth = seriesLegend(c, series, "marker");
|
|
904
997
|
const top = box.y + pad + c.lineHeight / 2, bottom = box.y + box.height - pad - c.lineHeight;
|
|
905
998
|
const height = Math.max(1, bottom - top);
|
|
906
999
|
const yScale = niceScale(ys.length ? Math.min(...ys) : 0, ys.length ? Math.max(...ys) : 1, maxIntervalsFor(height, c.lineHeight * 1.2));
|
|
907
|
-
const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick)))) + fontPx * 0.5;
|
|
1000
|
+
const tickWidth = Math.max(...yScale.ticks.map((tick) => c.width(formatTick(tick, false, yFormat)))) + fontPx * 0.5;
|
|
908
1001
|
const plot = { x: box.x + pad + tickWidth, y: top, height };
|
|
909
1002
|
plot.width = Math.max(1, box.x + box.width - pad - legendWidth - fontPx - plot.x);
|
|
910
|
-
const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1)), c.width(formatTick(xs.length ? Math.min(...xs) : 0))) + fontPx;
|
|
911
|
-
const
|
|
1003
|
+
const xLabelWidth = Math.max(c.width(formatTick(xs.length ? Math.max(...xs) : 1, false, xFormat)), c.width(formatTick(xs.length ? Math.min(...xs) : 0, false, xFormat))) + fontPx;
|
|
1004
|
+
const scaleForX = (w) => niceScale(xs.length ? Math.min(...xs) : 0, xs.length ? Math.max(...xs) : 1, maxIntervalsFor(w, xLabelWidth));
|
|
1005
|
+
let xScale = scaleForX(plot.width);
|
|
1006
|
+
// RR-54: a formatted X axis keeps its end labels inside the chart.
|
|
1007
|
+
if (xFormat !== undefined) ({ x: plot.x, width: plot.width, scale: xScale } = insetForEndLabels(c, plot.x, plot.width, xFormat, scaleForX));
|
|
912
1008
|
c.plotArea = plot;
|
|
913
1009
|
const xAt = (v) => plot.x + axisFraction(v, xScale) * plot.width;
|
|
914
1010
|
const yAt = (v) => plot.y + axisFraction(v, yScale, true) * plot.height;
|
|
915
1011
|
for (const tick of yScale.ticks) {
|
|
916
1012
|
const y = yAt(tick);
|
|
917
1013
|
gridline(c, plot.x, y, plot.x + plot.width, y);
|
|
918
|
-
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");
|
|
1014
|
+
c.text(formatTick(tick, false, yFormat), { x: box.x + pad, y: y - c.lineHeight / 2, width: plot.x - box.x - pad - fontPx * 0.35, height: c.lineHeight }, c.path, "right");
|
|
919
1015
|
}
|
|
920
1016
|
for (const tick of xScale.ticks) {
|
|
921
|
-
const label = formatTick(tick), width = c.width(label) + fontPx;
|
|
1017
|
+
const label = formatTick(tick, false, xFormat), width = c.width(label) + fontPx;
|
|
922
1018
|
c.text(label, { x: xAt(tick) - width / 2, y: plot.y + plot.height, width, height: c.lineHeight }, c.path);
|
|
923
1019
|
}
|
|
924
1020
|
const yCross = yAt(Math.min(yScale.max, Math.max(yScale.min, 0))), xCross = xAt(Math.min(xScale.max, Math.max(xScale.min, 0)));
|
|
@@ -930,7 +1026,7 @@ function renderScatterChart(c) {
|
|
|
930
1026
|
});
|
|
931
1027
|
// RR-35: a scatter label shows the Y value, and the X value as its category.
|
|
932
1028
|
if (c.dataLabels) series.forEach((s) => {
|
|
933
|
-
for (const point of s.points) drawPointLabel(c, labelString(c, { category:
|
|
1029
|
+
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}`);
|
|
934
1030
|
});
|
|
935
1031
|
}
|
|
936
1032
|
|
|
@@ -940,6 +1036,7 @@ function renderScatterChart(c) {
|
|
|
940
1036
|
function renderRadarChart(c, spec) {
|
|
941
1037
|
const { box, pad, fontPx } = c;
|
|
942
1038
|
const series = c.series();
|
|
1039
|
+
const valueFormat = series[0]?.format;
|
|
943
1040
|
const categories = c.rows.map((row) => c.label(row[0]));
|
|
944
1041
|
const count = categories.length;
|
|
945
1042
|
const values = series.flatMap((s) => s.values).filter((v) => v !== null);
|
|
@@ -1018,12 +1115,12 @@ function renderRadarChart(c, spec) {
|
|
|
1018
1115
|
});
|
|
1019
1116
|
// Value axis labels sit on top of filled series, as in PowerPoint.
|
|
1020
1117
|
for (const tick of scale.ticks) {
|
|
1021
|
-
const label = formatTick(tick), width = c.width(label) + fontPx * 0.5;
|
|
1118
|
+
const label = formatTick(tick, false, valueFormat), width = c.width(label) + fontPx * 0.5;
|
|
1022
1119
|
c.text(label, { x: cx - width - fontPx * 0.2, y: cy - rAt(tick) - c.lineHeight / 2, width, height: c.lineHeight }, c.path, "right");
|
|
1023
1120
|
}
|
|
1024
1121
|
// RR-35: radar data labels sit above each point (PowerPoint has no position choice for a radar).
|
|
1025
1122
|
if (c.dataLabels) series.forEach((s) => s.values.forEach((v, i) => {
|
|
1026
|
-
if (v !== null) drawPointLabel(c, labelString(c, { category: categories[i], value: v }), point(i, v), `${c.path}.data.rows.${i}.${s.column}`);
|
|
1123
|
+
if (v !== null) drawPointLabel(c, labelString(c, { category: categories[i], value: v, format: s.format }), point(i, v), `${c.path}.data.rows.${i}.${s.column}`);
|
|
1027
1124
|
}));
|
|
1028
1125
|
}
|
|
1029
1126
|
|
|
@@ -1104,12 +1201,12 @@ function chartexPlot(c, { categories, legendWidth = 0, tickLabels = [], percentA
|
|
|
1104
1201
|
return { plot, band: plot.width / Math.max(1, categories.length) };
|
|
1105
1202
|
}
|
|
1106
1203
|
|
|
1107
|
-
function valueAxis(c, plot, scale) {
|
|
1204
|
+
function valueAxis(c, plot, scale, format) {
|
|
1108
1205
|
const at = (value) => plot.y + axisFraction(value, scale, true) * plot.height;
|
|
1109
1206
|
for (const tick of scale.ticks) {
|
|
1110
1207
|
const y = at(tick);
|
|
1111
1208
|
gridline(c, plot.x, y, plot.x + plot.width, y);
|
|
1112
|
-
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");
|
|
1209
|
+
c.text(formatTick(tick, false, format), { 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");
|
|
1113
1210
|
}
|
|
1114
1211
|
const crossing = at(Math.min(scale.max, Math.max(scale.min, 0)));
|
|
1115
1212
|
axisLine(c, plot.x, plot.y, plot.x, plot.y + plot.height);
|
|
@@ -1121,6 +1218,8 @@ function valueAxis(c, plot, scale) {
|
|
|
1121
1218
|
function renderHistogramChart(c, spec) {
|
|
1122
1219
|
const pareto = spec.kind === "pareto";
|
|
1123
1220
|
const { hasCategories, column, values, categories } = chartexValues(c.rows, c.columns);
|
|
1221
|
+
// A histogram of a lone column plots bin counts, which carry no number format; with categories the bars are the column's values.
|
|
1222
|
+
const format = hasCategories ? c.formats[column] : undefined;
|
|
1124
1223
|
let bars;
|
|
1125
1224
|
if (hasCategories) {
|
|
1126
1225
|
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);
|
|
@@ -1132,9 +1231,9 @@ function renderHistogramChart(c, spec) {
|
|
|
1132
1231
|
if (!Number.isFinite(total)) throw chartAggregateError(c.path, "cumulative total");
|
|
1133
1232
|
const dataMax = bars.length ? Math.max(...bars.map((bar) => bar.value)) : 1, dataMin = bars.length ? Math.min(0, ...bars.map((bar) => bar.value)) : 0;
|
|
1134
1233
|
const probe = niceScale(dataMin, dataMax, 10);
|
|
1135
|
-
const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick)), percentAxis: pareto });
|
|
1234
|
+
const { plot, band } = chartexPlot(c, { categories: bars.map((bar) => ({ name: bar.name, path: bar.labelPath })), tickLabels: probe.ticks.map((tick) => formatTick(tick, false, format)), percentAxis: pareto });
|
|
1136
1235
|
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
1137
|
-
const { at, crossing } = valueAxis(c, plot, scale);
|
|
1236
|
+
const { at, crossing } = valueAxis(c, plot, scale, format);
|
|
1138
1237
|
const width = band / 1.06, offset = (band - width) / 2;
|
|
1139
1238
|
bars.forEach((bar, i) => {
|
|
1140
1239
|
const y = at(bar.value);
|
|
@@ -1143,7 +1242,7 @@ function renderHistogramChart(c, spec) {
|
|
|
1143
1242
|
// RR-35: data labels on the columns (a histogram label is the bin's count).
|
|
1144
1243
|
if (c.dataLabels) bars.forEach((bar, i) => {
|
|
1145
1244
|
const y = at(bar.value);
|
|
1146
|
-
drawBarLabel(c, labelString(c, { category: bar.name, value: bar.value }), { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y) }, bar.value >= 0 ? "up" : "down", bar.path, c.colors[0]);
|
|
1245
|
+
drawBarLabel(c, labelString(c, { category: bar.name, value: bar.value, format }), { x: plot.x + i * band + offset, y: Math.min(y, crossing), width, height: Math.abs(crossing - y) }, bar.value >= 0 ? "up" : "down", bar.path, c.colors[0]);
|
|
1147
1246
|
});
|
|
1148
1247
|
if (pareto && bars.length) {
|
|
1149
1248
|
// Percentage axis on the right, 0-100%, and the cumulative line through the bar centres.
|
|
@@ -1167,6 +1266,7 @@ function renderHistogramChart(c, spec) {
|
|
|
1167
1266
|
// Waterfall: floating bars from the running total, increases and decreases in the first two palette colours, connector lines between bars.
|
|
1168
1267
|
function renderWaterfallChart(c) {
|
|
1169
1268
|
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
1269
|
+
const format = c.formats[column];
|
|
1170
1270
|
let running = 0;
|
|
1171
1271
|
const bars = values.map((value, i) => {
|
|
1172
1272
|
if (value === null) return null;
|
|
@@ -1178,9 +1278,9 @@ function renderWaterfallChart(c) {
|
|
|
1178
1278
|
const extents = bars.flatMap((bar) => [bar.from, bar.to]);
|
|
1179
1279
|
const dataMin = extents.length ? Math.min(0, ...extents) : 0, dataMax = extents.length ? Math.max(0, ...extents) : 1;
|
|
1180
1280
|
const probe = niceScale(dataMin, dataMax, 10);
|
|
1181
|
-
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)) });
|
|
1281
|
+
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, false, format)) });
|
|
1182
1282
|
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
1183
|
-
const { at } = valueAxis(c, plot, scale);
|
|
1283
|
+
const { at } = valueAxis(c, plot, scale, format);
|
|
1184
1284
|
const width = band / 1.5, offset = (band - width) / 2;
|
|
1185
1285
|
bars.forEach((bar, k) => {
|
|
1186
1286
|
const a = at(bar.from), b = at(bar.to);
|
|
@@ -1192,7 +1292,7 @@ function renderWaterfallChart(c) {
|
|
|
1192
1292
|
// RR-35: data labels show each step's value (not the running total) on the floating bars.
|
|
1193
1293
|
if (c.dataLabels) bars.forEach((bar) => {
|
|
1194
1294
|
const a = at(bar.from), b = at(bar.to);
|
|
1195
|
-
drawBarLabel(c, labelString(c, { category: categories[bar.i], value: bar.value }), { x: plot.x + bar.i * band + offset, y: Math.min(a, b), width, height: Math.abs(b - a) }, bar.value >= 0 ? "up" : "down", `${c.path}.data.rows.${bar.i}.${column}`, c.colors[bar.value < 0 ? 1 : 0]);
|
|
1295
|
+
drawBarLabel(c, labelString(c, { category: categories[bar.i], value: bar.value, format }), { x: plot.x + bar.i * band + offset, y: Math.min(a, b), width, height: Math.abs(b - a) }, bar.value >= 0 ? "up" : "down", `${c.path}.data.rows.${bar.i}.${column}`, c.colors[bar.value < 0 ? 1 : 0]);
|
|
1196
1296
|
});
|
|
1197
1297
|
}
|
|
1198
1298
|
|
|
@@ -1200,6 +1300,7 @@ function renderWaterfallChart(c) {
|
|
|
1200
1300
|
function renderFunnelChart(c) {
|
|
1201
1301
|
const { box, pad, fontPx } = c;
|
|
1202
1302
|
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
1303
|
+
const format = c.formats[column];
|
|
1203
1304
|
const count = categories.length;
|
|
1204
1305
|
const max = Math.max(0, ...values.filter((value) => value !== null));
|
|
1205
1306
|
const labelWidth = Math.min(box.width * 0.3, Math.max(0, ...categories.map((name) => c.width(flatLabel(name)))) + fontPx * 0.5);
|
|
@@ -1217,7 +1318,7 @@ function renderFunnelChart(c) {
|
|
|
1217
1318
|
c.mark("rect", { x, y: y + offset, width, height, fill: c.colors[0] }, `${c.path}.data.rows.${i}.${column}`);
|
|
1218
1319
|
// RR-35: the funnel labels its bars with values by default; dataLabels picks the content, false removes them.
|
|
1219
1320
|
if (c.dataLabelsOff) return;
|
|
1220
|
-
c.text(c.dataLabels ? labelString(c, { category: name, value }) : formatTick(value), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
|
|
1321
|
+
c.text(c.dataLabels ? labelString(c, { category: name, value, format }) : formatTick(value, false, format), { x: plot.x, y: y + offset, width: plot.width, height }, `${c.path}.data.rows.${i}.${column}`);
|
|
1221
1322
|
});
|
|
1222
1323
|
}
|
|
1223
1324
|
|
|
@@ -1271,6 +1372,7 @@ export function squarify(items, x, y, width, height) {
|
|
|
1271
1372
|
function renderTreemapChart(c) {
|
|
1272
1373
|
const { box, pad, fontPx } = c;
|
|
1273
1374
|
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
1375
|
+
const format = c.formats[column];
|
|
1274
1376
|
const items = values.map((value) => ({ value: value !== null && value > 0 ? value : 0 }));
|
|
1275
1377
|
const total = items.reduce((sum, item) => sum + item.value, 0);
|
|
1276
1378
|
if (!Number.isFinite(total)) throw chartAggregateError(c.path, "tile total");
|
|
@@ -1286,7 +1388,7 @@ function renderTreemapChart(c) {
|
|
|
1286
1388
|
// RR-35: the treemap labels its tiles with category names by default; dataLabels picks the content, false removes them. PowerPoint
|
|
1287
1389
|
// draws the label at the bottom left of its tile whatever the position attribute says (native check 2026-10-01), so the preview does.
|
|
1288
1390
|
if (!c.dataLabelsOff && rect.width >= fontPx * 2 && rect.height >= c.lineHeight) {
|
|
1289
|
-
c.text(c.dataLabels ? labelString(c, { category: categories[i], value: values[i] }) : categories[i], { x: rect.x + fontPx * 0.25, y: rect.y + rect.height - c.lineHeight - fontPx * 0.2, width: rect.width - fontPx * 0.5, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`, "left");
|
|
1391
|
+
c.text(c.dataLabels ? labelString(c, { category: categories[i], value: values[i], format }) : categories[i], { x: rect.x + fontPx * 0.25, y: rect.y + rect.height - c.lineHeight - fontPx * 0.2, width: rect.width - fontPx * 0.5, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`, "left");
|
|
1290
1392
|
}
|
|
1291
1393
|
});
|
|
1292
1394
|
}
|
|
@@ -1319,7 +1421,8 @@ export function boxStatistics(values) {
|
|
|
1319
1421
|
function renderBoxWhiskerChart(c) {
|
|
1320
1422
|
// A lone value column has no category column: like the exporter, its values plot against their row numbers (one box per row).
|
|
1321
1423
|
const lone = c.columns.length === 1;
|
|
1322
|
-
const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
|
|
1424
|
+
const series = lone ? [{ name: c.label(c.columns[0]), column: 0, index: 0, ...(c.formats[0] !== undefined ? { format: c.formats[0] } : {}), values: c.rows.map((row) => chartNumber(row[0])) }] : c.series();
|
|
1425
|
+
const format = series[0]?.format;
|
|
1323
1426
|
const groups = [];
|
|
1324
1427
|
c.rows.forEach((row, i) => {
|
|
1325
1428
|
const name = lone ? String(i + 1) : c.label(row[0]);
|
|
@@ -1332,9 +1435,9 @@ function renderBoxWhiskerChart(c) {
|
|
|
1332
1435
|
const dataMin = all.length ? Math.min(...all) : 0, dataMax = all.length ? Math.max(...all) : 1;
|
|
1333
1436
|
const legendWidth = seriesLegend(c, series);
|
|
1334
1437
|
const probe = niceScale(dataMin, dataMax, 10);
|
|
1335
|
-
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)) });
|
|
1438
|
+
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, false, format)) });
|
|
1336
1439
|
const scale = niceScale(dataMin, dataMax, maxIntervalsFor(plot.height, c.lineHeight * 1.2));
|
|
1337
|
-
const { at } = valueAxis(c, plot, scale);
|
|
1440
|
+
const { at } = valueAxis(c, plot, scale, format);
|
|
1338
1441
|
const group = band / 2, width = group / series.length, offset = (band - group) / 2;
|
|
1339
1442
|
series.forEach((s, j) => {
|
|
1340
1443
|
const color = c.colors[j % c.colors.length], path = `${c.path}.data.columns.${s.column}`;
|
|
@@ -1373,6 +1476,7 @@ export function mixHex(from, to, t) {
|
|
|
1373
1476
|
function renderRegionMapChart(c) {
|
|
1374
1477
|
const { box, pad, fontPx } = c;
|
|
1375
1478
|
const { column, values, categories } = chartexValues(c.rows, c.columns);
|
|
1479
|
+
const format = c.formats[column];
|
|
1376
1480
|
const count = categories.length;
|
|
1377
1481
|
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) };
|
|
1378
1482
|
const columns = Math.max(1, Math.min(count, Math.round(Math.sqrt(count * area.width / Math.max(1, area.height)))));
|
|
@@ -1389,7 +1493,7 @@ function renderRegionMapChart(c) {
|
|
|
1389
1493
|
const inset = { x: x + fontPx * 0.25, width: Math.max(1, tileWidth - fontPx * 0.5) };
|
|
1390
1494
|
if (tileHeight >= c.lineHeight * 2) {
|
|
1391
1495
|
c.text(name, { ...inset, y: y + tileHeight / 2 - c.lineHeight, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
|
|
1392
|
-
if (value !== null) c.text(formatTick(value), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
|
|
1496
|
+
if (value !== null) c.text(formatTick(value, false, format), { ...inset, y: y + tileHeight / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.${column}`);
|
|
1393
1497
|
} else if (tileHeight >= c.lineHeight) {
|
|
1394
1498
|
c.text(name, { ...inset, y: y + (tileHeight - c.lineHeight) / 2, height: c.lineHeight }, `${c.path}.data.rows.${i}.0`);
|
|
1395
1499
|
}
|
package/dist/svg.js
CHANGED
|
@@ -10,7 +10,7 @@ import * as opfCore from "@openpresentation/opf";
|
|
|
10
10
|
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
11
11
|
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
12
12
|
import { fontPolicyFor } from "./font-policy.js";
|
|
13
|
-
import { renderCatalogChart } from "./charts.js";
|
|
13
|
+
import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
|
|
14
14
|
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
15
15
|
|
|
16
16
|
export const packageName = "@openpresentation/opf-render";
|
|
@@ -737,6 +737,8 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
737
737
|
textMeasurement,
|
|
738
738
|
geometry,
|
|
739
739
|
assets: presentation.assets ?? {},
|
|
740
|
+
// RR-54: the whole document, so a chart reads its dataset and a trace path can tell a dataset-backed chart from an inline one.
|
|
741
|
+
presentation,
|
|
740
742
|
index,
|
|
741
743
|
path: slidePath,
|
|
742
744
|
slide,
|
|
@@ -1429,8 +1431,10 @@ function renderTimeline(item, box, bound, options) {
|
|
|
1429
1431
|
|
|
1430
1432
|
function renderTable(item, box, bound, options) {
|
|
1431
1433
|
const scale = Math.min(bound.design.dimensions.width, bound.design.dimensions.height) / 720;
|
|
1432
|
-
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1434
|
+
const layout = layoutTable(item.value, box, {scale, minFontSize:(bound.composition ?? bound.geometry.composition).minFontSize, fontFamily:bound.design.fonts.body, textMeasurement:options.textMeasurement, path:item.path, presentation:bound.presentation, ...(bound.geometry?.direction === "rtl" ? {direction:"rtl"} : {})});
|
|
1433
1435
|
const children = [];
|
|
1436
|
+
// RR-54: a dataset table has no rows or columns of its own to point at; every cell reports the table's authored path.
|
|
1437
|
+
const own = isDatasetTable(item, bound) ? () => item.path : (path) => path;
|
|
1434
1438
|
const separateBorders = layout.rows.some(row => row.cells.some(cell => cell.style?.borders));
|
|
1435
1439
|
const defaultEdges = [], explicitEdges = [];
|
|
1436
1440
|
for (const row of layout.rows) for (const cell of row.cells) {
|
|
@@ -1448,19 +1452,19 @@ function renderTable(item, box, bound, options) {
|
|
|
1448
1452
|
width: stableNumber(cell.box.width), height: stableNumber(cell.box.height),
|
|
1449
1453
|
fill,
|
|
1450
1454
|
stroke: separateBorders ? undefined : bound.design.colors.border, "stroke-width":separateBorders ? undefined : 1,
|
|
1451
|
-
...traceAttrs(options, cell.sourcePath ?? cell.path)
|
|
1455
|
+
...traceAttrs(options, own(cell.sourcePath ?? cell.path))
|
|
1452
1456
|
}));
|
|
1453
1457
|
if (separateBorders) {
|
|
1454
1458
|
const {x, y, width, height} = cell.box;
|
|
1455
1459
|
const edges = {top:[x,y,x+width,y],right:[x+width,y,x+width,y+height],bottom:[x,y+height,x+width,y+height],left:[x,y,x,y+height]};
|
|
1456
1460
|
for (const [edge, coordinates] of Object.entries(edges)) {
|
|
1457
1461
|
const border = style.borders?.[edge];
|
|
1458
|
-
(border ? explicitEdges : defaultEdges).push({coordinates, border, path
|
|
1462
|
+
(border ? explicitEdges : defaultEdges).push({coordinates, border, path:own(`${cell.sourcePath ?? cell.path}.style.borders.${edge}`)});
|
|
1459
1463
|
}
|
|
1460
1464
|
}
|
|
1461
1465
|
// Core layout has already applied vertical alignment to cell.textBox.y.
|
|
1462
1466
|
children.push((cell.rich ? renderRichTextBox : renderTextBox)(cell.rich ? cell.value : flattenText(cell.value ?? ""), cell.textBox, bound, {
|
|
1463
|
-
path:cell.path, fontSize:15, fontFamily:bound.design.fonts.body,
|
|
1467
|
+
path:own(cell.path), fontSize:15, fontFamily:bound.design.fonts.body,
|
|
1464
1468
|
fontWeight:cell.header ? 700 : 400, textStyle:cell.textStyle, fit:cell.fit,
|
|
1465
1469
|
fill:textFill, align:style.align, options
|
|
1466
1470
|
}));
|
|
@@ -1514,7 +1518,9 @@ function renderChart(item, box, bound, options) {
|
|
|
1514
1518
|
if (rendered) return rendered;
|
|
1515
1519
|
const chart = item.value ?? {};
|
|
1516
1520
|
const chartType = chart.type ?? engineDefaults.chartTypes[0];
|
|
1517
|
-
const data = inlineChartRows(chart
|
|
1521
|
+
const data = inlineChartRows(legacyChartData(chart, bound));
|
|
1522
|
+
// A dataset-backed chart has no data.rows of its own: its parts report the chart's authored path.
|
|
1523
|
+
const part = isDatasetChart(item, bound) ? () => item.path : (path) => path;
|
|
1518
1524
|
const plot = inset(box, 28);
|
|
1519
1525
|
const max = Math.max(1, ...data.map((row) => Math.abs(row.value)));
|
|
1520
1526
|
const panelFill = bound.design.colors.surface;
|
|
@@ -1559,14 +1565,14 @@ function renderChart(item, box, bound, options) {
|
|
|
1559
1565
|
fill: "none",
|
|
1560
1566
|
stroke: primary,
|
|
1561
1567
|
"stroke-width": 4,
|
|
1562
|
-
...traceAttrs(options, `${item.path}.data`)
|
|
1568
|
+
...traceAttrs(options, part(`${item.path}.data`))
|
|
1563
1569
|
}));
|
|
1564
1570
|
points.forEach(([x, y], index) => children.push(tag("circle", {
|
|
1565
1571
|
cx: stableNumber(x),
|
|
1566
1572
|
cy: stableNumber(y),
|
|
1567
1573
|
r: 5,
|
|
1568
1574
|
fill: primary,
|
|
1569
|
-
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1575
|
+
...traceAttrs(options, part(`${item.path}.data.rows.${index}`))
|
|
1570
1576
|
})));
|
|
1571
1577
|
} else {
|
|
1572
1578
|
const gap = 10;
|
|
@@ -1581,7 +1587,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1581
1587
|
width: stableNumber(barWidth),
|
|
1582
1588
|
height: stableNumber(barHeight),
|
|
1583
1589
|
fill: index % 2 === 0 ? primary : secondary,
|
|
1584
|
-
...traceAttrs(options, `${item.path}.data.rows.${index}`)
|
|
1590
|
+
...traceAttrs(options, part(`${item.path}.data.rows.${index}`))
|
|
1585
1591
|
}));
|
|
1586
1592
|
});
|
|
1587
1593
|
}
|
|
@@ -1592,7 +1598,7 @@ function renderChart(item, box, bound, options) {
|
|
|
1592
1598
|
width: plot.width,
|
|
1593
1599
|
height: 24
|
|
1594
1600
|
}, bound, {
|
|
1595
|
-
path: `${item.path}.data
|
|
1601
|
+
path: part(`${item.path}.data`),
|
|
1596
1602
|
fontSize: 12,
|
|
1597
1603
|
fontFamily: bound.design.fonts.body,
|
|
1598
1604
|
fontWeight: 400,
|
|
@@ -1604,11 +1610,21 @@ function renderChart(item, box, bound, options) {
|
|
|
1604
1610
|
return tag("g", traceAttrs(options, item.path), children.join("\n"));
|
|
1605
1611
|
}
|
|
1606
1612
|
|
|
1613
|
+
// RR-54: the legacy sketch reads the same resolved rows as the catalog charts (mapping, datasets and strict numbers). Data that does
|
|
1614
|
+
// not resolve, or a core without the resolver, is read as authored.
|
|
1615
|
+
function legacyChartData(chart, bound) {
|
|
1616
|
+
if (typeof opfCore.resolveChartData !== "function") return chart.data;
|
|
1617
|
+
const resolved = opfCore.resolveChartData(chart, bound.presentation);
|
|
1618
|
+
return resolved.ok ? { rows: resolved.rows, resolved: true } : chart.data;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1607
1621
|
function inlineChartRows(data) {
|
|
1608
1622
|
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
|
1609
1623
|
return rows.map((row, index) => {
|
|
1610
1624
|
const cells = Array.isArray(row) ? row : [row];
|
|
1611
|
-
|
|
1625
|
+
// Resolved rows already hold strict numbers (a gap is null), so only a number cell is a value: reading a numeric-looking label
|
|
1626
|
+
// ("2020") or a boolean as the value of a gap would plot it.
|
|
1627
|
+
const value = cells.find((cell) => typeof cell === "number") ?? (data?.resolved ? 0 : Number(cells.find((cell) => Number.isFinite(Number(cell))) ?? 0));
|
|
1612
1628
|
return {
|
|
1613
1629
|
label: flattenText(cells.find((cell) => typeof cell === "string") ?? `Row ${index + 1}`),
|
|
1614
1630
|
value: Number.isFinite(value) ? value : 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-render",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
"@expo-google-fonts/roboto": "0.4.3",
|
|
139
139
|
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
140
140
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
141
|
-
"@openpresentation/opf": "^0.
|
|
141
|
+
"@openpresentation/opf": "^0.13.0",
|
|
142
142
|
"@resvg/resvg-js": "^2.6.2",
|
|
143
143
|
"bidi-js": "1.1.0",
|
|
144
144
|
"fontkit": "^2.0.4",
|