@openpresentation/opf-editor 0.11.2 → 0.12.1
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 +15 -2
- package/dist/block-convert.js +3 -2
- package/dist/chart-data.d.ts +2 -0
- package/dist/chart-data.js +1 -0
- package/dist/data-grid.js +177 -7
- package/dist/data.d.ts +21 -0
- package/dist/data.js +39 -0
- package/dist/dataset-refs.d.ts +17 -0
- package/dist/dataset-refs.js +45 -0
- package/dist/design-controls.js +5 -3
- package/dist/find-replace.js +14 -2
- package/dist/grid-model.d.ts +83 -6
- package/dist/grid-model.js +533 -47
- package/dist/switches.js +24 -7
- package/dist/table-options.js +13 -3
- package/dist/transfer.js +30 -0
- package/package.json +14 -8
package/dist/grid-model.js
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
// The data model behind the data grid (RR-24): one table-shaped view of a chart's inline data and of a
|
|
2
2
|
// table, and every edit to it as one validated, undoable patch.
|
|
3
3
|
//
|
|
4
|
-
// A chart is `{ type, data: { columns, rows } }`: `columns` are labels
|
|
5
|
-
// first column holds the categories (the x axis, or
|
|
6
|
-
// named by its column label, in the order the renderer draws them.
|
|
7
|
-
//
|
|
8
|
-
// `{ columns?, rows }` whose cells are plain values,
|
|
9
|
-
// describes.
|
|
4
|
+
// A chart is `{ type, data: { columns, rows }, mapping? }`: `columns` are labels (a string, or a `DataColumn`
|
|
5
|
+
// `{ name, format? }`), `rows` are arrays aligned to them. By default the first column holds the categories (the x axis, or
|
|
6
|
+
// the slice names) and every further column is one series, named by its column label, in the order the renderer draws them.
|
|
7
|
+
// (A scatter chart reads its x values from the second column; a chart type that needs one column only reads the first.)
|
|
8
|
+
// `mapping` names the category, X and series columns instead. A table is `{ columns?, rows }` whose cells are plain values,
|
|
9
|
+
// rich runs or styled cells with spans, as table-options.js describes.
|
|
10
|
+
//
|
|
11
|
+
// RR-54: a chart (`data: { dataset, fields? }`) or table (`{ dataset, fields? }`) may take its data from a shared top-level
|
|
12
|
+
// dataset. The grid then edits `/datasets/<id>` through the `fields` selection: edits map back to the dataset's own column
|
|
13
|
+
// indices, a column added while `fields` is set goes to the dataset and to `fields`, and a deleted column leaves `fields` (the
|
|
14
|
+
// dataset keeps it). Renaming a column keeps every `fields` and `mapping` that names it in step.
|
|
10
15
|
//
|
|
11
16
|
// Both are read as "lines": the header line (when there is one) followed by the body rows. The operations below
|
|
12
17
|
// change lines and columns, keep merged cells whole or refuse with a reason, and return a `{ document, patches,
|
|
13
18
|
// changed }` description like table-options.js does. Nothing here touches a DOM.
|
|
19
|
+
import * as core from "@openpresentation/opf";
|
|
14
20
|
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
|
|
15
|
-
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
21
|
+
import { checkedDocument, fail, same } from "./edit-helpers.js";
|
|
22
|
+
import { datasetUsage, isDatasetRef, walkDatasetItems } from "./dataset-refs.js";
|
|
16
23
|
import { richTextContent, updateRichTextInput } from "./rich-text.js";
|
|
17
24
|
import { formatGridNumber, isCanonicalNumber, parseDelimited, parseGridNumber, resolveNumberFormat, toDelimited } from "./grid-text.js";
|
|
18
25
|
import { applyTableStyleToTable, readTableStyleOfTable } from "./table-options.js";
|
|
19
26
|
|
|
20
27
|
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
21
28
|
const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
|
|
22
|
-
|
|
29
|
+
// A header may be a DataColumn `{ name, format? }`: its text is `name`.
|
|
30
|
+
const isColumnObject = (cell) => isObject(cell) && Object.hasOwn(cell, "name") && !Object.hasOwn(cell, "value");
|
|
31
|
+
const valueOf = (cell) => (isStyled(cell) ? cell.value : isColumnObject(cell) ? cell.name : cell);
|
|
32
|
+
const withValue = (raw, value) => (isStyled(raw) ? { ...raw, value } : isColumnObject(raw) ? { ...raw, name: value === null || value === undefined ? "" : String(value) } : value);
|
|
33
|
+
const formatOf = (raw) => ((isStyled(raw) || isColumnObject(raw)) && typeof raw.format === "string" ? raw.format : undefined);
|
|
23
34
|
const clone = (value) => structuredClone(value);
|
|
35
|
+
const columnNameOf = (column) => (typeof column === "string" ? column : isObject(column) && typeof column.name === "string" ? column.name : "");
|
|
36
|
+
// A body row remembers which dataset row it came from, so a row that moves, sorts or is deleted keeps the columns `fields` hides.
|
|
37
|
+
const LINE_ORIGIN = Symbol("opf.grid.row");
|
|
38
|
+
const cloneLines = (lines) =>
|
|
39
|
+
lines.map((line) => {
|
|
40
|
+
const copy = clone(line);
|
|
41
|
+
if (line[LINE_ORIGIN] !== undefined) copy[LINE_ORIGIN] = line[LINE_ORIGIN];
|
|
42
|
+
return copy;
|
|
43
|
+
});
|
|
24
44
|
const spansOf = (raw) => ({ rs: isStyled(raw) ? (raw.rowSpan ?? 1) : 1, cs: isStyled(raw) ? (raw.colSpan ?? 1) : 1 });
|
|
25
45
|
|
|
26
46
|
/** Spreadsheet column letters: 0 is A, 25 is Z, 26 is AA. */
|
|
@@ -38,10 +58,33 @@ const decimalOf = (options = {}) => (options.decimal === "," || options.decimal
|
|
|
38
58
|
|
|
39
59
|
// --- locating ----------------------------------------------------------------------------------
|
|
40
60
|
|
|
61
|
+
const datasetsOf = (document) => (isObject(document) && isObject(document.datasets) ? document.datasets : {});
|
|
62
|
+
|
|
63
|
+
/** The dataset a chart or table reference points at, and the dataset columns its `fields` select (by index). */
|
|
64
|
+
function datasetOf(document, ref, path, refParts) {
|
|
65
|
+
const id = ref.dataset;
|
|
66
|
+
const datasets = datasetsOf(document);
|
|
67
|
+
const data = Object.hasOwn(datasets, id) ? datasets[id] : undefined;
|
|
68
|
+
if (!isObject(data) || !Array.isArray(data.columns) || !Array.isArray(data.rows))
|
|
69
|
+
throw fail("dataset-unavailable", `This item uses the shared dataset '${id}', which the document does not hold.`, { path, dataset: id });
|
|
70
|
+
const names = data.columns.map(columnNameOf);
|
|
71
|
+
const fields = Array.isArray(ref.fields) ? ref.fields : undefined;
|
|
72
|
+
let indices = names.map((_, index) => index);
|
|
73
|
+
if (fields) {
|
|
74
|
+
indices = fields.map((field) => names.indexOf(field));
|
|
75
|
+
const missing = fields.find((_, index) => indices[index] < 0);
|
|
76
|
+
if (missing !== undefined) throw fail("dataset-unavailable", `This item selects the column ${JSON.stringify(missing)}, which dataset '${id}' does not have.`, { path, dataset: id, field: missing });
|
|
77
|
+
}
|
|
78
|
+
return { data, dataset: { id, fields, indices, refParts, ref } };
|
|
79
|
+
}
|
|
80
|
+
|
|
41
81
|
/**
|
|
42
82
|
* Find the data a path points at. A chart path ends in `chart` (or `chart.data`) and a table path in `table`.
|
|
43
|
-
* Throws `grid-target-not-found`, `table-not-found
|
|
44
|
-
* has no inline data to edit).
|
|
83
|
+
* Throws `grid-target-not-found`, `table-not-found`, `chart-data-source` (a chart that reads its data from a source
|
|
84
|
+
* has no inline data to edit) or `dataset-unavailable` (the shared dataset or one of its `fields` is missing).
|
|
85
|
+
* A chart or table that takes its data from a dataset (RR-54) is located at the dataset: `data` is the dataset, `dataParts`
|
|
86
|
+
* is `["datasets", id]` and `dataset` is `{ id, fields, indices, refParts, ref }` (`indices` maps each shown column to the
|
|
87
|
+
* dataset's own column).
|
|
45
88
|
*/
|
|
46
89
|
export function locateGridData(document, path) {
|
|
47
90
|
let parts;
|
|
@@ -55,21 +98,32 @@ export function locateGridData(document, path) {
|
|
|
55
98
|
if (parts.at(-1) === "chart") {
|
|
56
99
|
if (!isObject(owner) || !isObject(owner.data)) throw fail("grid-target-not-found", "This chart has no data.", { path });
|
|
57
100
|
const data = owner.data;
|
|
101
|
+
const chartType = typeof owner.type === "string" ? owner.type : undefined;
|
|
102
|
+
if (isDatasetRef(data)) {
|
|
103
|
+
const found = datasetOf(document, data, path, [...parts, "data"]);
|
|
104
|
+
return { kind: "chart", parts, dataParts: ["datasets", data.dataset], data: found.data, chartType, owner, dataset: found.dataset };
|
|
105
|
+
}
|
|
58
106
|
if (!Array.isArray(data.columns) || !Array.isArray(data.rows))
|
|
59
107
|
throw fail("chart-data-source", "This chart reads its data from a source (src), not from inline columns and rows. The data grid edits inline chart data.", { path });
|
|
60
|
-
return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType
|
|
108
|
+
return { kind: "chart", parts, dataParts: [...parts, "data"], data, chartType, owner };
|
|
61
109
|
}
|
|
62
110
|
if (parts.at(-1) === "table") {
|
|
111
|
+
if (isObject(owner) && isDatasetRef(owner) && !Array.isArray(owner.rows)) {
|
|
112
|
+
const found = datasetOf(document, owner, path, parts);
|
|
113
|
+
return { kind: "table", parts, dataParts: ["datasets", owner.dataset], data: found.data, owner, dataset: found.dataset };
|
|
114
|
+
}
|
|
63
115
|
if (!isObject(owner) || !Array.isArray(owner.rows)) throw fail("table-not-found", "Choose a table (a path ending in .table).", { path });
|
|
64
|
-
return { kind: "table", parts, dataParts: parts, data: owner };
|
|
116
|
+
return { kind: "table", parts, dataParts: parts, data: owner, owner };
|
|
65
117
|
}
|
|
66
118
|
throw fail("grid-target-not-found", "Choose a chart or a table (a path ending in .chart or .table).", { path });
|
|
67
119
|
}
|
|
68
120
|
|
|
69
121
|
/**
|
|
70
122
|
* The chart or table a selection path belongs to: `{ kind, path }` for `…chart`, `…chart.data.rows.1.0`, `…table`,
|
|
71
|
-
* `…table.rows.1.2.value`, and so on. A chart whose data comes from a source
|
|
72
|
-
* reason }`.
|
|
123
|
+
* `…table.rows.1.2.value`, and so on. A chart whose data comes from a source (or from a dataset the document lacks) is
|
|
124
|
+
* `{ kind: "chart", path, editable: false, reason }`. A chart or table that takes its data from a shared dataset also has
|
|
125
|
+
* `dataset: { id, fields, count, items }` (`items` are the paths of every chart and table that uses the dataset).
|
|
126
|
+
* Returns undefined when the path is not inside a chart or table.
|
|
73
127
|
*/
|
|
74
128
|
export function resolveDataGridTarget(document, selectedPath) {
|
|
75
129
|
let parts;
|
|
@@ -83,9 +137,14 @@ export function resolveDataGridTarget(document, selectedPath) {
|
|
|
83
137
|
const head = parts.slice(0, index + 1);
|
|
84
138
|
try {
|
|
85
139
|
const found = locateGridData(document, head);
|
|
86
|
-
|
|
140
|
+
const target = { kind: found.kind, path: head.join(".") };
|
|
141
|
+
if (found.dataset) {
|
|
142
|
+
const items = datasetUsage(document, found.dataset.id).map((entry) => entry.path);
|
|
143
|
+
target.dataset = { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), count: items.length, items };
|
|
144
|
+
}
|
|
145
|
+
return target;
|
|
87
146
|
} catch (error) {
|
|
88
|
-
if (error.code === "chart-data-source") return { kind:
|
|
147
|
+
if (error.code === "chart-data-source" || error.code === "dataset-unavailable") return { kind: parts[index], path: head.join("."), editable: false, reason: error.message };
|
|
89
148
|
}
|
|
90
149
|
}
|
|
91
150
|
return undefined;
|
|
@@ -94,14 +153,29 @@ export function resolveDataGridTarget(document, selectedPath) {
|
|
|
94
153
|
// --- lines -------------------------------------------------------------------------------------
|
|
95
154
|
|
|
96
155
|
function toModel(found) {
|
|
97
|
-
const { kind, data } = found;
|
|
98
|
-
const hasHeader = kind === "chart" ? true : Array.isArray(data.columns);
|
|
99
|
-
|
|
100
|
-
|
|
156
|
+
const { kind, data, dataset } = found;
|
|
157
|
+
const hasHeader = kind === "chart" || Boolean(dataset) ? true : Array.isArray(data.columns);
|
|
158
|
+
let lines;
|
|
159
|
+
if (dataset) {
|
|
160
|
+
// The dataset as the item shows it: only the columns `fields` selects, in that order. Each body row remembers its dataset row.
|
|
161
|
+
const pick = (row) => dataset.indices.map((index) => (Array.isArray(row) && index < row.length ? clone(row[index]) : null));
|
|
162
|
+
lines = [clone(dataset.indices.map((index) => data.columns[index])), ...data.rows.map((row, r) => Object.assign(pick(row), { [LINE_ORIGIN]: r }))];
|
|
163
|
+
} else lines = [...(hasHeader ? [clone(data.columns)] : []), ...clone(data.rows).map((row) => (Array.isArray(row) ? row : [row]))];
|
|
164
|
+
const mapping = kind === "chart" && isObject(found.owner?.mapping) ? clone(found.owner.mapping) : undefined;
|
|
165
|
+
// `datasetNames` are every column of the shared dataset, also the ones `fields` hides: a new column's name must differ from them.
|
|
166
|
+
const model = { kind, hasHeader, lines, width: 0, chartType: found.chartType, dataset, mapping, colOrigin: [], origNames: [], datasetNames: dataset ? data.columns.map(columnNameOf) : [] };
|
|
101
167
|
model.width = widthOf(model);
|
|
168
|
+
// Where each column came from, so renames, deletions and moves can be followed after an operation (null: a new column).
|
|
169
|
+
model.colOrigin = Array.from({ length: model.width }, (_, index) => index);
|
|
170
|
+
model.origNames = namesOfModel(model);
|
|
102
171
|
return model;
|
|
103
172
|
}
|
|
104
173
|
|
|
174
|
+
/** The header texts of a model (the column names `fields` and `mapping` address). */
|
|
175
|
+
function namesOfModel(model) {
|
|
176
|
+
return model.hasHeader ? Array.from({ length: model.width }, (_, column) => String(valueOf(model.lines[0]?.[column]) ?? "")) : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
105
179
|
function fromModel(model, original) {
|
|
106
180
|
const rest = Object.fromEntries(Object.entries(original).filter(([key]) => key !== "columns" && key !== "rows"));
|
|
107
181
|
const out = {};
|
|
@@ -113,13 +187,25 @@ function fromModel(model, original) {
|
|
|
113
187
|
const widthOf = (model) => model.lines.reduce((most, line) => Math.max(most, line.length), 0);
|
|
114
188
|
const offset = (model) => (model.hasHeader ? 1 : 0);
|
|
115
189
|
const bodyCount = (model) => model.lines.length - offset(model);
|
|
116
|
-
|
|
190
|
+
// Column names must be unique where a dataset, `fields` or a chart `mapping` addresses them, so a new column of such a
|
|
191
|
+
// target gets a distinct placeholder name instead of "".
|
|
192
|
+
const needsNames = (model) => Boolean(model.dataset) || model.mapping !== undefined;
|
|
193
|
+
function uniqueName(model, taken = []) {
|
|
194
|
+
const used = new Set([...namesOfModel(model), ...model.datasetNames, ...taken]);
|
|
195
|
+
let n = 1;
|
|
196
|
+
while (used.has(n === 1 ? "New column" : `New column ${n}`)) n += 1;
|
|
197
|
+
return n === 1 ? "New column" : `New column ${n}`;
|
|
198
|
+
}
|
|
199
|
+
// What a new, empty cell holds: a chart's and a dataset's cells are null (a gap), a table's are "".
|
|
200
|
+
const emptyCell = (model) => (model.kind === "chart" || model.dataset ? null : "");
|
|
201
|
+
const filler = (model, u) => (model.hasHeader && u === 0 ? (needsNames(model) ? uniqueName(model) : "") : emptyCell(model));
|
|
117
202
|
|
|
118
203
|
function padLines(model) {
|
|
119
204
|
model.width = widthOf(model);
|
|
120
205
|
model.lines.forEach((line, u) => {
|
|
121
206
|
while (line.length < model.width) line.push(filler(model, u));
|
|
122
207
|
});
|
|
208
|
+
while (model.colOrigin.length < model.width) model.colOrigin.push(null);
|
|
123
209
|
}
|
|
124
210
|
|
|
125
211
|
const addressOf = (model, u) => (model.hasHeader && u === 0 ? { section: "header", row: 0 } : { section: "body", row: u - offset(model) });
|
|
@@ -208,17 +294,94 @@ export function cellText(raw, kind, decimal = ".") {
|
|
|
208
294
|
return String(value);
|
|
209
295
|
}
|
|
210
296
|
|
|
211
|
-
const looksNumeric = (value) => typeof value === "number" ? Number.isFinite(value) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value));
|
|
212
297
|
const isScatter = (type) => typeof type === "string" && /^(scatter|bubble)/.test(type);
|
|
213
298
|
|
|
214
|
-
|
|
215
|
-
|
|
299
|
+
// RR-54: core's strict chart number and XY test. A core that predates them (the installed range still allows one) keeps the
|
|
300
|
+
// editor working on documents that use none of the new fields: numbers and scatter-like type ids read as the editor always read them.
|
|
301
|
+
const chartNumber = (value) =>
|
|
302
|
+
typeof core.chartNumber === "function" ? core.chartNumber(value) : typeof value === "number" ? (Number.isFinite(value) ? value : null) : typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) ? Number(value) : null;
|
|
303
|
+
/** Whether the installed core has the RR-54 chart and table data contract (number formats, datasets, `chartNumber`); an older core edits documents that use none of it. */
|
|
304
|
+
export const supportsChartTableData = ["chartNumber", "numberFormatError", "inlineChartData", "inlineTableData", "isXYChartType"].every((name) => typeof core[name] === "function");
|
|
305
|
+
const isXYType = (type) => (typeof core.isXYChartType === "function" ? core.isXYChartType(type) : isScatter(type));
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A number as the slide draws it: the column's format (a table body cell's own format wins) through core's `formatDataNumber`, or undefined when
|
|
309
|
+
* the cell is not a number, has no valid format, or the format changes nothing. A chart cell is read by `chartNumber`, as the renderer does, and
|
|
310
|
+
* only in a column the chart plots as numbers; a table cell is formatted only when it holds a number. The cell's `text` (the raw value) is never
|
|
311
|
+
* replaced: editing and copying use it.
|
|
312
|
+
*/
|
|
313
|
+
function displayText(model, raw, column, role, text) {
|
|
314
|
+
if (!supportsChartTableData || typeof core.formatDataNumber !== "function") return undefined;
|
|
315
|
+
const format = (model.kind === "table" ? formatOf(raw) : undefined) ?? (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined);
|
|
316
|
+
if (format === undefined || core.numberFormatError(format) !== undefined) return undefined;
|
|
317
|
+
const value = valueOf(raw);
|
|
318
|
+
let number = null;
|
|
319
|
+
if (model.kind === "chart") number = isNumericRole(role) && (typeof value === "number" || typeof value === "string") ? chartNumber(value) : null;
|
|
320
|
+
else if (typeof value === "number" && Number.isFinite(value)) number = value;
|
|
321
|
+
if (number === null) return undefined;
|
|
322
|
+
const shown = core.formatDataNumber(number, format);
|
|
323
|
+
return shown === text ? undefined : shown;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The default X column of an XY chart (core's rule): the second column, or the first when the category is the second, and only
|
|
328
|
+
* with three or more columns. With two, the second column is the one series, plotted against row numbers.
|
|
329
|
+
*/
|
|
330
|
+
const defaultX = (category, count) => (count > 2 ? (category === 1 ? 0 : 1) : undefined);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The columns a chart plots, by index: `{ category, x, series }`. `mapping` names them (`{ category?, x?, series? }`); a missing or
|
|
334
|
+
* unknown name falls back to core's positional default. `x` is only set for an XY chart.
|
|
335
|
+
*/
|
|
336
|
+
function mappingColumns(count, xy, mapping, names) {
|
|
337
|
+
const known = (name) => (typeof name === "string" ? names.indexOf(name) : -1);
|
|
338
|
+
const map = isObject(mapping) ? mapping : {};
|
|
339
|
+
let category = known(map.category);
|
|
340
|
+
if (category < 0) category = 0;
|
|
341
|
+
let x;
|
|
342
|
+
if (xy) {
|
|
343
|
+
x = known(map.x);
|
|
344
|
+
if (x < 0 || x === category) x = defaultX(category, count);
|
|
345
|
+
if (x !== undefined && (x >= count || x === category)) x = undefined;
|
|
346
|
+
}
|
|
347
|
+
let series;
|
|
348
|
+
if (Array.isArray(map.series)) {
|
|
349
|
+
series = [];
|
|
350
|
+
for (const name of map.series) {
|
|
351
|
+
const index = known(name);
|
|
352
|
+
if (index >= 0 && index !== category && index !== x && !series.includes(index)) series.push(index);
|
|
353
|
+
}
|
|
354
|
+
} else series = Array.from({ length: count }, (_, index) => index).filter((index) => index !== category && index !== x);
|
|
355
|
+
// An X column needs a series beside it; otherwise core plots it as the series against row numbers.
|
|
356
|
+
if (x !== undefined && !series.length) {
|
|
357
|
+
series = [x];
|
|
358
|
+
x = undefined;
|
|
359
|
+
}
|
|
360
|
+
return { category, x, series };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* What each column of a chart is for: "category" (column A), "series", and for a scatter chart with three or more columns
|
|
365
|
+
* "label" and "x". With a `mapping` (and the column `names` it addresses) the roles follow it: "category" (the label column of a
|
|
366
|
+
* chart with no X axis), "label" and "x" for an XY chart, "series" for a plotted column and "other" for a column nothing plots.
|
|
367
|
+
*/
|
|
368
|
+
export function chartColumnRoles(columnCount, chartType, mapping, names) {
|
|
369
|
+
if (isObject(mapping) && Array.isArray(names) && columnCount > 0) {
|
|
370
|
+
const xy = isXYType(chartType);
|
|
371
|
+
const { category, x, series } = mappingColumns(columnCount, xy, mapping, names);
|
|
372
|
+
const roles = Array(columnCount).fill("other");
|
|
373
|
+
for (const index of series) roles[index] = "series";
|
|
374
|
+
roles[category] = xy ? "label" : "category";
|
|
375
|
+
if (x !== undefined) roles[x] = "x";
|
|
376
|
+
return roles;
|
|
377
|
+
}
|
|
216
378
|
if (columnCount <= 1) return Array(columnCount).fill("series");
|
|
217
379
|
if (isScatter(chartType) && columnCount >= 3) return ["label", "x", ...Array(columnCount - 2).fill("series")];
|
|
218
380
|
return ["category", ...Array(columnCount - 1).fill("series")];
|
|
219
381
|
}
|
|
220
382
|
|
|
221
|
-
const
|
|
383
|
+
const rolesOf = (model) => (model.kind === "chart" ? chartColumnRoles(model.width, model.chartType, model.mapping, namesOfModel(model)) : []);
|
|
384
|
+
const isNumericRole = (role) => role === undefined || role === "series" || role === "x";
|
|
222
385
|
|
|
223
386
|
// --- describing --------------------------------------------------------------------------------
|
|
224
387
|
|
|
@@ -234,7 +397,7 @@ export function describeDataGrid(document, path, options = {}) {
|
|
|
234
397
|
const model = toModel(found);
|
|
235
398
|
padLines(model);
|
|
236
399
|
const owners = ownersOf(model);
|
|
237
|
-
const roles =
|
|
400
|
+
const roles = rolesOf(model);
|
|
238
401
|
const warnings = [];
|
|
239
402
|
const names = new Map();
|
|
240
403
|
const lines = model.lines.map((line, u) =>
|
|
@@ -256,21 +419,27 @@ export function describeDataGrid(document, path, options = {}) {
|
|
|
256
419
|
};
|
|
257
420
|
if (Array.isArray(value)) cell.runs = value;
|
|
258
421
|
if (isStyled(raw) && raw.style) cell.style = raw.style;
|
|
422
|
+
if (!owner && address.section === "body") {
|
|
423
|
+
const display = displayText(model, raw, column, roles[column], cell.text);
|
|
424
|
+
if (display !== undefined) cell.display = display;
|
|
425
|
+
}
|
|
426
|
+
if (address.section === "header" && formatOf(raw) !== undefined) cell.format = formatOf(raw);
|
|
259
427
|
if (owner) cell.owner = { line: owner.u, column: owner.c };
|
|
260
428
|
if (model.kind === "chart") {
|
|
261
429
|
let warning;
|
|
262
430
|
if (u === 0) {
|
|
263
431
|
const name = String(value ?? "");
|
|
264
|
-
if (column
|
|
432
|
+
if (roles[column] !== "category" && roles[column] !== "label") {
|
|
265
433
|
if (name.trim() === "") warning = "This series has no name.";
|
|
266
434
|
else if (names.has(name)) warning = `Another series is also named "${name}".`;
|
|
267
435
|
names.set(name, column);
|
|
268
436
|
}
|
|
269
437
|
} else if (roles[column] === "category" || roles[column] === "label") {
|
|
270
438
|
if (roles[column] === "category" && (value === null || value === undefined || value === "")) warning = "This row has no category label.";
|
|
439
|
+
} else if (roles[column] === "other") {
|
|
440
|
+
// A column the chart does not plot may hold anything.
|
|
271
441
|
} else if (typeof value === "boolean") warning = "A yes/no value is not a number, so the chart draws a gap here.";
|
|
272
|
-
else if (value !== null && value !== undefined &&
|
|
273
|
-
warning = "This is not a number, so the chart draws a gap here.";
|
|
442
|
+
else if (value !== null && value !== undefined && chartNumber(value) === null && String(value).trim() !== "") warning = "This is not a number, so the chart draws a gap here.";
|
|
274
443
|
if (warning) {
|
|
275
444
|
cell.warning = warning;
|
|
276
445
|
warnings.push({ section: address.section, row: address.row, column, message: `${describeAddress({ ...address, column })}: ${warning}` });
|
|
@@ -287,6 +456,10 @@ export function describeDataGrid(document, path, options = {}) {
|
|
|
287
456
|
rowCount: bodyCount(model),
|
|
288
457
|
columnCount: model.width,
|
|
289
458
|
columnRoles: roles,
|
|
459
|
+
columnFormats: Array.from({ length: model.width }, (_, column) => (model.hasHeader ? formatOf(model.lines[0]?.[column]) : undefined)),
|
|
460
|
+
...(model.hasHeader ? { columnNames: namesOfModel(model) } : {}),
|
|
461
|
+
...(found.dataset ? { dataset: { id: found.dataset.id, ...(found.dataset.fields ? { fields: [...found.dataset.fields] } : {}), items: datasetUsage(document, found.dataset.id).map((entry) => entry.path) } } : {}),
|
|
462
|
+
...(model.mapping ? { mapping: clone(model.mapping) } : {}),
|
|
290
463
|
lines,
|
|
291
464
|
warnings,
|
|
292
465
|
merges: rectsOf(model).map((rect) => ({ ...addressOf(model, rect.u), column: rect.c, rowSpan: rect.rs, colSpan: rect.cs })),
|
|
@@ -336,24 +509,164 @@ export function gridPatches(parts, before, after) {
|
|
|
336
509
|
return patches;
|
|
337
510
|
}
|
|
338
511
|
|
|
512
|
+
/** Like {@link gridPatches}, and also for a value that is added (`before` is undefined) or removed (`after` is undefined). */
|
|
513
|
+
function valuePatches(parts, before, after) {
|
|
514
|
+
if (before === undefined && after === undefined) return [];
|
|
515
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
516
|
+
if (before === undefined) return [{ op: "add", path: pointer, value: clone(after) }];
|
|
517
|
+
if (after === undefined) return [{ op: "test", path: pointer, value: clone(before) }, { op: "remove", path: pointer }];
|
|
518
|
+
return gridPatches(parts, before, after);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --- following columns across an operation -------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/** The column renames an operation made: a Map from the old name to the new one, for the columns that survived with another name. */
|
|
524
|
+
function renamesOf(model) {
|
|
525
|
+
const names = namesOfModel(model);
|
|
526
|
+
const renames = new Map();
|
|
527
|
+
model.colOrigin.forEach((origin, column) => {
|
|
528
|
+
if (origin === null || origin === undefined) return;
|
|
529
|
+
const from = model.origNames[origin];
|
|
530
|
+
const to = names[column];
|
|
531
|
+
if (from !== undefined && to !== undefined && from !== to) renames.set(from, to);
|
|
532
|
+
});
|
|
533
|
+
return renames;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* A chart `mapping` after an operation: a renamed column keeps its role, a column that no longer exists leaves the mapping
|
|
538
|
+
* (an emptied `series` is dropped, which restores "every other column"). A name the data never had is left alone. Returns
|
|
539
|
+
* undefined when nothing is left.
|
|
540
|
+
*/
|
|
541
|
+
function reconcileMapping(mapping, renames, before, after) {
|
|
542
|
+
if (!isObject(mapping)) return mapping;
|
|
543
|
+
const keep = (name) => {
|
|
544
|
+
const next = renames.has(name) ? renames.get(name) : name;
|
|
545
|
+
if (after.includes(next)) return next;
|
|
546
|
+
return before.includes(name) ? undefined : name;
|
|
547
|
+
};
|
|
548
|
+
const out = {};
|
|
549
|
+
for (const key of ["category", "x"]) {
|
|
550
|
+
if (typeof mapping[key] !== "string") continue;
|
|
551
|
+
const name = keep(mapping[key]);
|
|
552
|
+
if (name !== undefined) out[key] = name;
|
|
553
|
+
}
|
|
554
|
+
if (Array.isArray(mapping.series)) {
|
|
555
|
+
const series = mapping.series.map(keep).filter((name) => name !== undefined);
|
|
556
|
+
if (series.length) out.series = series;
|
|
557
|
+
}
|
|
558
|
+
return Object.keys(out).length ? out : undefined;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** The changes to an inline chart or table: its data object, and the chart's `mapping` when a rename or deletion touches it. */
|
|
562
|
+
function inlineChanges(found, model, style) {
|
|
563
|
+
const after = fromModel(model, found.data);
|
|
564
|
+
if (style && style.preset !== "custom") applyTableStyleToTable(after, style.preset);
|
|
565
|
+
const changes = [{ parts: found.dataParts, before: found.data, after }];
|
|
566
|
+
if (found.kind === "chart" && found.owner.mapping !== undefined) {
|
|
567
|
+
const next = reconcileMapping(found.owner.mapping, renamesOf(model), model.origNames, namesOfModel(model));
|
|
568
|
+
if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
|
|
569
|
+
}
|
|
570
|
+
return changes;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The changes to a chart or table that shows a dataset: the dataset itself (the shown columns mapped back to its own column
|
|
575
|
+
* indices, each row to the dataset row it came from, so columns `fields` hides keep their data), the item's `fields` and `mapping`,
|
|
576
|
+
* and the `fields` and `mapping` of every other item that shares the dataset when a column is renamed. A column that
|
|
577
|
+
* `fields` selects leaves only `fields` when deleted; without `fields` it leaves the dataset, which another item's `fields` or
|
|
578
|
+
* `mapping` that names it forbids.
|
|
579
|
+
*/
|
|
580
|
+
function datasetChanges(document, found, model) {
|
|
581
|
+
const { dataset, data } = found;
|
|
582
|
+
padLines(model);
|
|
583
|
+
const header = model.lines[0];
|
|
584
|
+
const names = namesOfModel(model);
|
|
585
|
+
const body = model.lines.slice(1);
|
|
586
|
+
const origin = model.colOrigin;
|
|
587
|
+
let columns;
|
|
588
|
+
let rows;
|
|
589
|
+
if (!dataset.fields) {
|
|
590
|
+
columns = header.map((raw) => raw);
|
|
591
|
+
rows = body.map((line) => line.map((raw) => raw));
|
|
592
|
+
} else {
|
|
593
|
+
const survivors = new Map();
|
|
594
|
+
origin.forEach((o, column) => {
|
|
595
|
+
if (o !== null && o !== undefined) survivors.set(dataset.indices[o], column);
|
|
596
|
+
});
|
|
597
|
+
columns = data.columns.map((column, index) => (survivors.has(index) ? header[survivors.get(index)] : column));
|
|
598
|
+
const position = new Map();
|
|
599
|
+
origin.forEach((o, column) => {
|
|
600
|
+
if (o !== null && o !== undefined) position.set(column, dataset.indices[o]);
|
|
601
|
+
else {
|
|
602
|
+
position.set(column, columns.length);
|
|
603
|
+
columns.push(header[column]);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
rows = body.map((line) => {
|
|
607
|
+
const source = line[LINE_ORIGIN] !== undefined && Array.isArray(data.rows[line[LINE_ORIGIN]]) ? [...data.rows[line[LINE_ORIGIN]]] : [];
|
|
608
|
+
while (source.length < columns.length) source.push(null);
|
|
609
|
+
line.forEach((raw, column) => {
|
|
610
|
+
source[position.get(column)] = raw;
|
|
611
|
+
});
|
|
612
|
+
return source;
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
const renames = renamesOf(model);
|
|
616
|
+
const others = [];
|
|
617
|
+
const ownPath = found.parts.join(".");
|
|
618
|
+
walkDatasetItems(document, (entry) => {
|
|
619
|
+
if (entry.id === dataset.id && entry.parts.join(".") !== ownPath) others.push(entry);
|
|
620
|
+
});
|
|
621
|
+
if (!dataset.fields) {
|
|
622
|
+
const gone = model.origNames.filter((_, index) => !origin.includes(index));
|
|
623
|
+
for (const name of gone) {
|
|
624
|
+
const users = others.filter((entry) => (Array.isArray(entry.ref.fields) && entry.ref.fields.includes(name)) || (isObject(entry.item.mapping) && [entry.item.mapping.category, entry.item.mapping.x, ...(entry.item.mapping.series ?? [])].includes(name)));
|
|
625
|
+
if (users.length)
|
|
626
|
+
throw fail("dataset-column-in-use", `The column ${JSON.stringify(name)} is used by ${users.length === 1 ? "another chart or table" : `${users.length} other charts and tables`} that share the dataset '${dataset.id}'. Remove it there first, or use a copy of the data here.`, { column: name, items: users.map((entry) => entry.parts.join(".")) });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const changes = [{ parts: found.dataParts, before: data, after: { ...data, columns, rows } }];
|
|
630
|
+
if (dataset.fields) changes.push({ parts: [...dataset.refParts, "fields"], before: dataset.ref.fields, after: names });
|
|
631
|
+
if (found.kind === "chart" && found.owner.mapping !== undefined) {
|
|
632
|
+
const next = reconcileMapping(found.owner.mapping, renames, model.origNames, names);
|
|
633
|
+
if (!same(found.owner.mapping, next)) changes.push({ parts: [...found.parts, "mapping"], before: found.owner.mapping, after: next });
|
|
634
|
+
}
|
|
635
|
+
if (renames.size) {
|
|
636
|
+
for (const entry of others) {
|
|
637
|
+
if (Array.isArray(entry.ref.fields) && entry.ref.fields.some((name) => renames.has(name)))
|
|
638
|
+
changes.push({ parts: [...entry.refParts, "fields"], before: entry.ref.fields, after: entry.ref.fields.map((name) => (renames.has(name) ? renames.get(name) : name)) });
|
|
639
|
+
if (entry.kind === "chart" && isObject(entry.item.mapping)) {
|
|
640
|
+
const mapping = entry.item.mapping;
|
|
641
|
+
const rename = (name) => (renames.has(name) ? renames.get(name) : name);
|
|
642
|
+
const next = { ...mapping };
|
|
643
|
+
if (typeof mapping.category === "string") next.category = rename(mapping.category);
|
|
644
|
+
if (typeof mapping.x === "string") next.x = rename(mapping.x);
|
|
645
|
+
if (Array.isArray(mapping.series)) next.series = mapping.series.map(rename);
|
|
646
|
+
if (!same(mapping, next)) changes.push({ parts: [...entry.parts, "mapping"], before: mapping, after: next });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return changes;
|
|
651
|
+
}
|
|
652
|
+
|
|
339
653
|
function transact(document, path, action, mutate, extra = {}) {
|
|
340
654
|
const found = locateGridData(document, path);
|
|
341
655
|
if (extra.kind && extra.kind !== found.kind) throw fail("grid-wrong-kind", `This operation works on a ${extra.kind}, and the path points at a ${found.kind}.`, { path });
|
|
342
656
|
const model = toModel(found);
|
|
343
657
|
const problemsBefore = mergeProblems(model).length;
|
|
344
658
|
const info = mutate(model, found) ?? {};
|
|
345
|
-
const style = info.restyle && found.kind === "table" ? readTableStyleOfTable(found.data) : undefined;
|
|
659
|
+
const style = info.restyle && found.kind === "table" && !found.dataset ? readTableStyleOfTable(found.data) : undefined;
|
|
346
660
|
if (info.touchesStructure) assertMerges(model, problemsBefore, info.what ?? "This change");
|
|
347
661
|
if (style && style.preset !== "custom") padLines(model);
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
const patches = gridPatches(found.dataParts, found.data, after);
|
|
662
|
+
const changes = found.dataset ? datasetChanges(document, found, model) : inlineChanges(found, model, style);
|
|
663
|
+
const patches = changes.flatMap((entry) => valuePatches(entry.parts, entry.before, entry.after));
|
|
351
664
|
const changed = patches.length > 0;
|
|
352
665
|
const before = validateOpfDocument(document);
|
|
353
666
|
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
354
667
|
// `restyle`, `touchesStructure` and `what` steer this function only; the rest of what the operation reports is the caller's.
|
|
355
668
|
const { restyle: _restyle, touchesStructure: _structure, what: _what, ...summary } = info;
|
|
356
|
-
return { action, ...summary, kind: found.kind, path: found.parts.join("."), document: clone(result), patches, changed };
|
|
669
|
+
return { action, ...summary, kind: found.kind, path: found.parts.join("."), ...(found.dataset ? { dataset: found.dataset.id } : {}), document: clone(result), patches, changed };
|
|
357
670
|
}
|
|
358
671
|
|
|
359
672
|
// --- row and column structure ------------------------------------------------------------------
|
|
@@ -370,7 +683,7 @@ function insertRows(model, at, count) {
|
|
|
370
683
|
const u = at + offset(model);
|
|
371
684
|
const crossing = rectsOf(model).filter((rect) => rect.u < u && u < rect.u + rect.rs);
|
|
372
685
|
const reference = u - 1 >= offset(model) ? model.lines[u - 1] : u < model.lines.length ? model.lines[u] : undefined;
|
|
373
|
-
const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" ? null : referenceCell(model, reference, column))));
|
|
686
|
+
const fresh = Array.from({ length: count }, () => Array.from({ length: model.width }, (_, column) => (model.kind === "chart" || model.dataset ? null : referenceCell(model, reference, column))));
|
|
374
687
|
model.lines.splice(u, 0, ...fresh);
|
|
375
688
|
for (const rect of crossing) {
|
|
376
689
|
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs + count, rect.cs);
|
|
@@ -403,10 +716,14 @@ function insertColumns(model, at, count) {
|
|
|
403
716
|
if (!Number.isInteger(at) || at < 0 || at > model.width) throw fail("grid-column-out-of-range", `A column can be inserted at positions 1 to ${model.width + 1}.`, { at });
|
|
404
717
|
const crossing = rectsOf(model).filter((rect) => rect.c < at && at < rect.c + rect.cs);
|
|
405
718
|
const reference = at - 1 >= 0 ? at - 1 : at < model.width ? at : -1;
|
|
719
|
+
// New columns of a dataset (or of a chart with a mapping) are named at once: names must stay unique.
|
|
720
|
+
const named = [];
|
|
721
|
+
if (model.hasHeader && needsNames(model)) for (let i = 0; i < count; i += 1) named.push(uniqueName(model, named));
|
|
406
722
|
model.lines.forEach((line, u) => {
|
|
407
|
-
const fresh = Array.from({ length: count }, () => (model.kind === "chart" || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
|
|
723
|
+
const fresh = Array.from({ length: count }, (_, i) => (model.hasHeader && u === 0 && needsNames(model) ? named[i] : model.kind === "chart" || model.dataset || (model.hasHeader && u === 0) ? filler(model, u) : referenceCell(model, line, reference)));
|
|
408
724
|
line.splice(at, 0, ...fresh);
|
|
409
725
|
});
|
|
726
|
+
model.colOrigin.splice(at, 0, ...Array(count).fill(null));
|
|
410
727
|
for (const rect of crossing) {
|
|
411
728
|
model.lines[rect.u][rect.c] = withSpans(model.lines[rect.u][rect.c], rect.rs, rect.cs + count);
|
|
412
729
|
for (let y = rect.u; y < rect.u + rect.rs; y += 1) for (let i = 0; i < count; i += 1) model.lines[y][at + i] = null;
|
|
@@ -422,6 +739,7 @@ function deleteColumn(model, x) {
|
|
|
422
739
|
else model.lines[rect.u][x + 1] = withSpans(anchor, rect.rs, rect.cs - 1);
|
|
423
740
|
}
|
|
424
741
|
for (const line of model.lines) line.splice(x, 1);
|
|
742
|
+
model.colOrigin.splice(x, 1);
|
|
425
743
|
model.width -= 1;
|
|
426
744
|
}
|
|
427
745
|
|
|
@@ -453,6 +771,7 @@ function moveColumns(model, from, count, to) {
|
|
|
453
771
|
throw fail("grid-column-out-of-range", "That move leaves the table.", { from, count, to });
|
|
454
772
|
if (from === to) return;
|
|
455
773
|
for (const line of model.lines) moveBlock(line, from, count, to);
|
|
774
|
+
moveBlock(model.colOrigin, from, count, to);
|
|
456
775
|
}
|
|
457
776
|
|
|
458
777
|
// --- sorting -----------------------------------------------------------------------------------
|
|
@@ -529,6 +848,7 @@ function sortRows(model, column, options) {
|
|
|
529
848
|
|
|
530
849
|
function setHeader(model, enabled, options) {
|
|
531
850
|
if (model.kind !== "table") throw fail("grid-wrong-kind", "Only a table has a header row to turn on or off. A chart's first line always names its series.", {});
|
|
851
|
+
if (model.dataset) throw fail("grid-dataset-shared", `A table that shows the dataset '${model.dataset.id}' always has a header row: the dataset's column names.`, { dataset: model.dataset.id });
|
|
532
852
|
if (enabled === model.hasHeader) return {};
|
|
533
853
|
padLines(model);
|
|
534
854
|
if (enabled) {
|
|
@@ -547,17 +867,23 @@ function setHeader(model, enabled, options) {
|
|
|
547
867
|
model.hasHeader = true;
|
|
548
868
|
return { header: true, use, restyle: true, touchesStructure: true, what: "A header row" };
|
|
549
869
|
}
|
|
870
|
+
// A DataColumn header (`{ name, format }`) is not a body cell: it becomes its name. Its column format has nowhere to go and is dropped.
|
|
871
|
+
const dropped = model.lines[0].filter((raw) => isColumnObject(raw) && formatOf(raw) !== undefined).length;
|
|
872
|
+
model.lines[0] = model.lines[0].map((raw) => (isColumnObject(raw) ? raw.name : raw));
|
|
550
873
|
model.hasHeader = false;
|
|
551
|
-
return { header: false, restyle: true };
|
|
874
|
+
return { header: false, restyle: true, ...(dropped ? { droppedFormats: dropped } : {}) };
|
|
552
875
|
}
|
|
553
876
|
|
|
554
877
|
// --- transpose ---------------------------------------------------------------------------------
|
|
555
878
|
|
|
556
879
|
function transposeChart(model) {
|
|
557
880
|
if (model.kind !== "chart") throw fail("grid-wrong-kind", "Only chart data can be transposed.", {});
|
|
881
|
+
if (model.dataset) throw fail("grid-dataset-shared", `Swapping categories and series would reshape the shared dataset '${model.dataset.id}' for every chart and table that uses it. Use a copy of the data first.`, { dataset: model.dataset.id });
|
|
558
882
|
padLines(model);
|
|
559
883
|
if (model.width < 2) throw fail("chart-transpose-empty", "Transposing needs at least one series column besides the categories.", {});
|
|
560
|
-
const [
|
|
884
|
+
const [headers, ...rows] = model.lines;
|
|
885
|
+
// A column's number format belongs to its numbers: the old series names become category labels, as text.
|
|
886
|
+
const names = headers.map((cell) => String(valueOf(cell) ?? ""));
|
|
561
887
|
let relabelled = 0;
|
|
562
888
|
const categories = rows.map((row) => {
|
|
563
889
|
const category = row[0];
|
|
@@ -568,6 +894,8 @@ function transposeChart(model) {
|
|
|
568
894
|
const lines = [[names[0], ...categories], ...names.slice(1).map((name, index) => [name, ...rows.map((row) => row[index + 1] ?? null)])];
|
|
569
895
|
model.lines = lines;
|
|
570
896
|
model.width = widthOf(model);
|
|
897
|
+
// Every column is new: a mapping that named the old columns no longer applies.
|
|
898
|
+
model.colOrigin = Array(model.width).fill(null);
|
|
571
899
|
return { rowsBefore: rows.length, columnsBefore: names.length, relabelled };
|
|
572
900
|
}
|
|
573
901
|
|
|
@@ -575,20 +903,29 @@ function transposeChart(model) {
|
|
|
575
903
|
|
|
576
904
|
function growTo(model, lines, width) {
|
|
577
905
|
padLines(model);
|
|
578
|
-
while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => (model
|
|
906
|
+
while (model.lines.length < lines) model.lines.push(Array.from({ length: model.width }, () => emptyCell(model)));
|
|
579
907
|
while (model.width < width) {
|
|
580
908
|
model.lines.forEach((line, u) => line.push(filler(model, u)));
|
|
581
909
|
model.width += 1;
|
|
910
|
+
model.colOrigin.push(null);
|
|
582
911
|
}
|
|
583
912
|
}
|
|
584
913
|
|
|
585
|
-
|
|
914
|
+
// The column names of a shared dataset (and of a chart with a mapping) are how `fields` and `mapping` address columns: never blank.
|
|
915
|
+
const blankNameError = (model, address, text) =>
|
|
916
|
+
address.section === "header" && model.hasHeader && needsNames(model) && String(text ?? "").trim() === ""
|
|
917
|
+
? `A column name cannot be empty here: ${model.dataset ? "the shared dataset" : "the chart's column mapping"} addresses columns by name.`
|
|
918
|
+
: undefined;
|
|
919
|
+
|
|
920
|
+
function newRaw(model, raw, text, decimal, address, roles) {
|
|
586
921
|
const value = valueOf(raw);
|
|
922
|
+
const blank = blankNameError(model, address, text);
|
|
923
|
+
if (blank) return { error: blank };
|
|
587
924
|
// Text that is already what the cell shows is left alone: a number kept as text stays text, runs keep their styling.
|
|
588
925
|
const shown = cellText(raw, model.kind, decimal);
|
|
589
926
|
if (Array.isArray(value)) {
|
|
590
927
|
if (text.replace(/\r\n?/g, "\n") === shown.replace(/\r\n?/g, "\n")) return { skip: true };
|
|
591
|
-
return { raw:
|
|
928
|
+
return { raw: withValue(raw, updateRichTextInput(value, text)) };
|
|
592
929
|
}
|
|
593
930
|
if (text === shown) return { skip: true };
|
|
594
931
|
let next;
|
|
@@ -596,17 +933,21 @@ function newRaw(model, raw, text, decimal, address) {
|
|
|
596
933
|
if (model.hasHeader && address.section === "header") next = text;
|
|
597
934
|
else next = text === "" ? "" : isCanonicalNumber(text) ? Number(text) : text;
|
|
598
935
|
} else if (address.section === "header") next = text;
|
|
599
|
-
else if (
|
|
936
|
+
else if (isNumericRole(roles[address.column])) {
|
|
937
|
+
// A chart number is read in the grid's number format and kept as a number. Text that is not a number is refused with
|
|
938
|
+
// a reason; it is never stored as a guess (core's chartNumber would draw it as a gap).
|
|
600
939
|
const parsed = parseGridNumber(text, { decimal });
|
|
601
940
|
if (parsed.error) return { error: parsed.error };
|
|
602
941
|
next = parsed.empty ? null : parsed.value;
|
|
603
942
|
if (next === null && (value === null || value === undefined)) return { skip: true };
|
|
604
|
-
} else next = text === "" ? null : text;
|
|
605
|
-
|
|
943
|
+
} else if (roles[address.column] === "other") next = text === "" ? null : isCanonicalNumber(text) ? Number(text) : text;
|
|
944
|
+
else next = text === "" ? null : text;
|
|
945
|
+
return { raw: withValue(raw, next) };
|
|
606
946
|
}
|
|
607
947
|
|
|
608
948
|
function applyEdits(model, edits, decimal, owners) {
|
|
609
949
|
const issues = [];
|
|
950
|
+
const roles = rolesOf(model);
|
|
610
951
|
for (const edit of edits) {
|
|
611
952
|
const address = { section: edit.section, row: edit.row ?? 0, column: edit.column };
|
|
612
953
|
try {
|
|
@@ -620,11 +961,14 @@ function applyEdits(model, edits, decimal, owners) {
|
|
|
620
961
|
const value = edit.value;
|
|
621
962
|
if (model.kind === "chart" && !(value === null || ["string", "number", "boolean"].includes(typeof value))) throw fail("invalid-grid-value", "A chart cell is text, a number, true or false, or empty.", { cell: address });
|
|
622
963
|
if (typeof value === "number" && !Number.isFinite(value)) throw fail("invalid-grid-value", "A cell cannot hold Infinity or NaN.", { cell: address });
|
|
623
|
-
model.
|
|
964
|
+
if (model.dataset && address.section === "header" && typeof value !== "string") throw fail("invalid-grid-value", "A dataset column name is text.", { cell: address });
|
|
965
|
+
const blank = blankNameError(model, address, value);
|
|
966
|
+
if (blank && !(valueOf(raw) === "" && value === "")) throw fail("invalid-grid-value", blank, { cell: address });
|
|
967
|
+
model.lines[u][address.column] = withValue(raw, value);
|
|
624
968
|
continue;
|
|
625
969
|
}
|
|
626
970
|
if (typeof edit.text !== "string") throw fail("invalid-grid-value", "Give the cell's new text.", { cell: address });
|
|
627
|
-
const outcome = newRaw(model, raw, edit.text, decimal, address);
|
|
971
|
+
const outcome = newRaw(model, raw, edit.text, decimal, address, roles);
|
|
628
972
|
if (outcome.error) issues.push({ ...address, message: `${describeAddress(address)}: ${outcome.error}` });
|
|
629
973
|
else if (!outcome.skip) model.lines[u][address.column] = outcome.raw;
|
|
630
974
|
} catch (error) {
|
|
@@ -651,7 +995,7 @@ function pasteRows(model, anchor, matrix, options) {
|
|
|
651
995
|
if (!Array.isArray(matrix) || !matrix.length) throw fail("invalid-grid-value", "There is nothing to paste.", {});
|
|
652
996
|
const first = decimalOf(options);
|
|
653
997
|
const attempt = (decimal) => {
|
|
654
|
-
const trial = { ...model, lines:
|
|
998
|
+
const trial = { ...model, lines: cloneLines(model.lines), colOrigin: [...model.colOrigin] };
|
|
655
999
|
const u0 = lineIndex(trial, anchor);
|
|
656
1000
|
const width = matrix.reduce((most, row) => Math.max(most, row.length), 0);
|
|
657
1001
|
growTo(trial, u0 + matrix.length, anchor.column + width);
|
|
@@ -678,6 +1022,7 @@ function pasteRows(model, anchor, matrix, options) {
|
|
|
678
1022
|
if (result.issues.length) throw refuse(result.issues);
|
|
679
1023
|
model.lines = result.trial.lines;
|
|
680
1024
|
model.width = result.trial.width;
|
|
1025
|
+
model.colOrigin = result.trial.colOrigin;
|
|
681
1026
|
return { cells: result.cells, decimal, rows: matrix.length, restyle: true, touchesStructure: model.kind === "table", what: "Pasting" };
|
|
682
1027
|
}
|
|
683
1028
|
|
|
@@ -781,6 +1126,141 @@ export function prepareTranspose(document, path) {
|
|
|
781
1126
|
return transact(document, path, "transpose", (model) => transposeChart(model), { kind: "chart" });
|
|
782
1127
|
}
|
|
783
1128
|
|
|
1129
|
+
// --- number format of a column (RR-54) ---------------------------------------------------------
|
|
1130
|
+
|
|
1131
|
+
/** Why `format` is not a valid number format ("#,##0", "0.0%", "$#,##0.00"), or undefined when it is valid or empty (empty clears the format). */
|
|
1132
|
+
export function columnFormatError(format) {
|
|
1133
|
+
if (format === undefined || format === null || format === "") return undefined;
|
|
1134
|
+
if (!supportsChartTableData) return "This version of OPF has no number formats; update @openpresentation/opf.";
|
|
1135
|
+
return core.numberFormatError(format);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** The header `raw` with `format` set, or cleared (`undefined`). A string header becomes `{ name, format }` and goes back to a string when the format is cleared. */
|
|
1139
|
+
function withColumnFormat(raw, format) {
|
|
1140
|
+
if (isColumnObject(raw)) {
|
|
1141
|
+
const next = { ...raw };
|
|
1142
|
+
if (format === undefined) delete next.format;
|
|
1143
|
+
else next.format = format;
|
|
1144
|
+
return Object.keys(next).length === 1 ? next.name : next;
|
|
1145
|
+
}
|
|
1146
|
+
if (isStyled(raw)) {
|
|
1147
|
+
const next = { ...raw };
|
|
1148
|
+
if (format === undefined) delete next.format;
|
|
1149
|
+
else next.format = format;
|
|
1150
|
+
return Object.keys(next).length === 1 ? next.value : next;
|
|
1151
|
+
}
|
|
1152
|
+
if (format === undefined) return raw;
|
|
1153
|
+
if (Array.isArray(raw)) return { value: raw, format };
|
|
1154
|
+
return { name: raw === null || raw === undefined ? "" : String(raw), format };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function setColumnFormat(model, column, format) {
|
|
1158
|
+
if (!model.hasHeader) throw fail("grid-no-header", "This table has no header row, so there is no column heading to hold a number format. Turn on the header row first.", {});
|
|
1159
|
+
padLines(model);
|
|
1160
|
+
if (!Number.isInteger(column) || column < 0 || column >= model.width) throw fail("grid-column-out-of-range", "Choose a column.", { column });
|
|
1161
|
+
if (format !== undefined && format !== null && typeof format !== "string") throw fail("number-format-invalid", 'A number format is text such as "#,##0" or "0.0%".', { format });
|
|
1162
|
+
const next = typeof format === "string" && format !== "" ? format : undefined;
|
|
1163
|
+
const error = columnFormatError(next);
|
|
1164
|
+
if (error) throw fail("number-format-invalid", error, { format: next });
|
|
1165
|
+
if (ownersOf(model).has(`0:${column}`)) throw fail("table-cell-covered", `${describeAddress({ section: "header", row: 0, column })} is covered by a merged cell. Choose the merged heading.`, { column });
|
|
1166
|
+
model.lines[0][column] = withColumnFormat(model.lines[0][column], next);
|
|
1167
|
+
return { column, format: next ?? null };
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Set or clear (`null` or "") the number format of column `column` (its header cell). A string header becomes `{ name, format }` and returns to a string when the format is cleared. Refused with the reason when the format is invalid or the table has no header row. */
|
|
1171
|
+
export function prepareGridColumnFormat(document, path, column, format, options = {}) {
|
|
1172
|
+
return transact(document, path, "set-column-format", (model) => setColumnFormat(model, column, format), kindOf(options));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// --- chart mapping (RR-54) -----------------------------------------------------------------------
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* The columns a chart plots and how they got that role: `{ path, xy, columns: [{ name, format?, role }], category, x?, series,
|
|
1179
|
+
* authored? }`. `category`, `x` and `series` are column names (the mapping's, or core's positional default); `xy` is true for a chart
|
|
1180
|
+
* with an X axis (scatter); `authored` is the chart's own `mapping`, when it has one. Roles are "category", "label", "x", "series"
|
|
1181
|
+
* and "other" (a column nothing plots).
|
|
1182
|
+
*/
|
|
1183
|
+
export function describeChartMapping(document, path) {
|
|
1184
|
+
const found = locateGridData(document, path);
|
|
1185
|
+
if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
|
|
1186
|
+
const model = toModel(found);
|
|
1187
|
+
padLines(model);
|
|
1188
|
+
const names = namesOfModel(model);
|
|
1189
|
+
const xy = isXYType(found.chartType);
|
|
1190
|
+
const { category, x, series } = mappingColumns(names.length, xy, model.mapping, names);
|
|
1191
|
+
const roles = chartColumnRoles(names.length, found.chartType, { ...(model.mapping ?? {}) }, names);
|
|
1192
|
+
return {
|
|
1193
|
+
path: found.parts.join("."),
|
|
1194
|
+
xy,
|
|
1195
|
+
columns: names.map((name, index) => ({ name, ...(formatOf(model.lines[0][index]) ? { format: formatOf(model.lines[0][index]) } : {}), role: roles[index] })),
|
|
1196
|
+
category: names[category],
|
|
1197
|
+
...(x === undefined ? {} : { x: names[x] }),
|
|
1198
|
+
series: series.map((index) => names[index]),
|
|
1199
|
+
...(model.mapping ? { authored: clone(model.mapping) } : {}),
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Set a chart's category, X and series columns by name. `wanted` is `{ category?, x?, series? }`; what it leaves out keeps its
|
|
1205
|
+
* current value. A field that equals the default (the first column is the category, the second the X column, every other column a
|
|
1206
|
+
* series, in order) is not written, and `chart.mapping` is removed when nothing is left. One patch.
|
|
1207
|
+
*/
|
|
1208
|
+
export function prepareChartMapping(document, path, wanted = {}) {
|
|
1209
|
+
const found = locateGridData(document, path);
|
|
1210
|
+
if (found.kind !== "chart") throw fail("grid-wrong-kind", "Only a chart has a series mapping.", { path });
|
|
1211
|
+
const model = toModel(found);
|
|
1212
|
+
padLines(model);
|
|
1213
|
+
const names = namesOfModel(model);
|
|
1214
|
+
const xy = isXYType(found.chartType);
|
|
1215
|
+
const current = mappingColumns(names.length, xy, model.mapping, names);
|
|
1216
|
+
const pick = (name, what) => {
|
|
1217
|
+
const index = typeof name === "string" ? names.indexOf(name) : -1;
|
|
1218
|
+
if (index < 0) throw fail("chart-mapping-unknown-column", `The ${what} column ${JSON.stringify(name)} is not a column of this chart's data. Choose ${names.map((entry) => JSON.stringify(entry)).join(", ")}.`, { column: name });
|
|
1219
|
+
return index;
|
|
1220
|
+
};
|
|
1221
|
+
if (wanted.x !== undefined && !xy) throw fail("chart-mapping-x-unsupported", `A '${found.chartType ?? "chart"}' chart has no X axis, so it has no X column. Only a scatter chart does.`, { x: wanted.x });
|
|
1222
|
+
const category = wanted.category !== undefined ? pick(wanted.category, "category") : current.category;
|
|
1223
|
+
let x;
|
|
1224
|
+
if (xy) x = wanted.x !== undefined ? pick(wanted.x, "X") : current.x === category || current.x === undefined ? defaultX(category, names.length) : current.x;
|
|
1225
|
+
if (xy && x === category) throw fail("chart-mapping-conflict", "The X column and the category column must be different columns.", { category: names[category] });
|
|
1226
|
+
if (xy && x !== undefined && x >= names.length) throw fail("chart-mapping-conflict", "An XY chart needs a column for X besides the category.", {});
|
|
1227
|
+
const defaultSeries = names.map((_, index) => index).filter((index) => index !== category && index !== x);
|
|
1228
|
+
// Without its own `series`, a chart plots every other column: that follows a new category or X column.
|
|
1229
|
+
let series = wanted.series !== undefined ? wanted.series.map((name) => pick(name, "series")) : Array.isArray(model.mapping?.series) ? current.series : defaultSeries;
|
|
1230
|
+
series = series.filter((index, position) => index !== category && index !== x && series.indexOf(index) === position);
|
|
1231
|
+
if (!series.length) throw fail("chart-mapping-no-series", "Choose at least one series column to plot.", {});
|
|
1232
|
+
const next = {};
|
|
1233
|
+
if (category !== 0) next.category = names[category];
|
|
1234
|
+
if (xy && x !== undefined && x !== defaultX(category, names.length)) next.x = names[x];
|
|
1235
|
+
if (series.length !== defaultSeries.length || series.some((index, position) => index !== defaultSeries[position])) next.series = series.map((index) => names[index]);
|
|
1236
|
+
const after = Object.keys(next).length ? next : undefined;
|
|
1237
|
+
const before = validateOpfDocument(document);
|
|
1238
|
+
const patches = valuePatches([...found.parts, "mapping"], found.owner.mapping, after);
|
|
1239
|
+
const changed = patches.length > 0;
|
|
1240
|
+
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
1241
|
+
return { action: "set-mapping", kind: "chart", path: found.parts.join("."), mapping: after ? clone(after) : null, document: clone(result), patches, changed };
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// --- shared datasets (RR-54) -----------------------------------------------------------------------
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Replace a chart's or table's dataset reference with its own inline copy of the data (the columns `fields` selects, formats and
|
|
1248
|
+
* the dataset's `source` kept). The dataset stays in the document for the other items. One patch; after it the item's edits no longer
|
|
1249
|
+
* reach the shared dataset.
|
|
1250
|
+
*/
|
|
1251
|
+
export function prepareDetachDataset(document, path) {
|
|
1252
|
+
const found = locateGridData(document, path);
|
|
1253
|
+
if (!found.dataset) throw fail("grid-not-dataset", "This chart or table does not use a shared dataset.", { path });
|
|
1254
|
+
if (!supportsChartTableData) throw fail("grid-core-too-old", "This version of OPF cannot copy a shared dataset; update @openpresentation/opf.", { path });
|
|
1255
|
+
const inline = found.kind === "chart" ? core.inlineChartData(found.owner, document).data : core.inlineTableData(found.owner, document);
|
|
1256
|
+
const pointer = opfPathToJsonPointer(found.dataset.refParts);
|
|
1257
|
+
const patches = [{ op: "test", path: pointer, value: clone(found.dataset.ref) }, { op: "replace", path: pointer, value: clone(inline) }];
|
|
1258
|
+
const result = checkedDocument(document, patches, validateOpfDocument(document));
|
|
1259
|
+
return { action: "detach-dataset", kind: found.kind, path: found.parts.join("."), dataset: found.dataset.id, document: clone(result), patches, changed: true };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export { datasetUsage };
|
|
1263
|
+
|
|
784
1264
|
// --- copy --------------------------------------------------------------------------------------
|
|
785
1265
|
|
|
786
1266
|
/**
|
|
@@ -834,3 +1314,9 @@ export const moveGridColumns = (editor, path, from, to, count = 1, options = {})
|
|
|
834
1314
|
export const sortGridRows = (editor, path, column, options = {}) => run(editor, (document) => prepareSortRows(document, path, column, options), options);
|
|
835
1315
|
export const setGridHeader = (editor, path, enabled, options = {}) => run(editor, (document) => prepareSetHeader(document, path, enabled, options), options);
|
|
836
1316
|
export const transposeGridData = (editor, path, options = {}) => run(editor, (document) => prepareTranspose(document, path), options);
|
|
1317
|
+
/** Set (or clear, with `null` or "") the number format of a column's header as one undoable transaction. Throws `number-format-invalid` with the reason. See {@link columnFormatError}. */
|
|
1318
|
+
export const setGridColumnFormat = (editor, path, column, format, options = {}) => run(editor, (document) => prepareGridColumnFormat(document, path, column, format, options), options);
|
|
1319
|
+
/** Set a chart's category, X and series columns as one undoable transaction. See {@link prepareChartMapping}. */
|
|
1320
|
+
export const setChartMapping = (editor, path, wanted, options = {}) => run(editor, (document) => prepareChartMapping(document, path, wanted), options);
|
|
1321
|
+
/** Give a chart or table its own copy of a shared dataset's data, as one undoable transaction. See {@link prepareDetachDataset}. */
|
|
1322
|
+
export const detachGridDataset = (editor, path, options = {}) => run(editor, (document) => prepareDetachDataset(document, path), options);
|