@casualoffice/sheets 0.17.0 → 0.19.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.
Files changed (52) hide show
  1. package/dist/chrome.cjs +3339 -244
  2. package/dist/chrome.cjs.map +1 -1
  3. package/dist/chrome.js +3298 -203
  4. package/dist/chrome.js.map +1 -1
  5. package/dist/embed/embed-runtime.js +203 -157
  6. package/dist/index.cjs +5971 -1572
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.js +5961 -1548
  9. package/dist/index.js.map +1 -1
  10. package/dist/sheets.cjs +4937 -538
  11. package/dist/sheets.cjs.map +1 -1
  12. package/dist/sheets.js +4945 -532
  13. package/dist/sheets.js.map +1 -1
  14. package/package.json +8 -7
  15. package/src/charts/ChartContextMenu.tsx +264 -0
  16. package/src/charts/ChartLayer.tsx +333 -0
  17. package/src/charts/ChartOverlay.tsx +293 -0
  18. package/src/charts/ChartsPanel.tsx +211 -0
  19. package/src/charts/FormatChartDialog.tsx +419 -0
  20. package/src/charts/InsertChartDialog.tsx +478 -0
  21. package/src/charts/build-option.ts +476 -0
  22. package/src/charts/charts-context.tsx +205 -0
  23. package/src/charts/echarts-init.ts +58 -0
  24. package/src/charts/hit-test.ts +130 -0
  25. package/src/charts/insert-chart.ts +106 -0
  26. package/src/charts/naming.ts +38 -0
  27. package/src/charts/render-to-png.ts +117 -0
  28. package/src/charts/resources.ts +108 -0
  29. package/src/charts/types.ts +239 -0
  30. package/src/charts/univer-dom.ts +102 -0
  31. package/src/chrome/CommentsPanel.tsx +427 -0
  32. package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
  33. package/src/chrome/CustomSortDialog.tsx +357 -0
  34. package/src/chrome/DataValidationDialog.tsx +536 -0
  35. package/src/chrome/DeleteCellsDialog.tsx +183 -0
  36. package/src/chrome/GoalSeekDialog.tsx +370 -0
  37. package/src/chrome/HistoryPanel.tsx +319 -0
  38. package/src/chrome/InsertCellsDialog.tsx +185 -0
  39. package/src/chrome/InsertChartDialog.tsx +490 -0
  40. package/src/chrome/InsertFunctionDialog.tsx +493 -0
  41. package/src/chrome/InsertPivotDialog.tsx +488 -0
  42. package/src/chrome/InsertSparklineDialog.tsx +344 -0
  43. package/src/chrome/NameManagerDialog.tsx +378 -0
  44. package/src/chrome/PanelHost.tsx +55 -0
  45. package/src/chrome/PanelRail.tsx +90 -0
  46. package/src/chrome/PasteSpecialDialog.tsx +286 -0
  47. package/src/chrome/PivotFieldsPanel.tsx +1052 -0
  48. package/src/chrome/TablesPanel.tsx +301 -0
  49. package/src/chrome/dialog-context.tsx +24 -0
  50. package/src/chrome/panel-context.tsx +55 -0
  51. package/src/chrome/panel-registry.ts +48 -0
  52. package/src/sheets/CasualSheets.tsx +60 -34
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { use } from 'echarts/core';
18
+ import { CanvasRenderer } from 'echarts/renderers';
19
+ import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts';
20
+ import {
21
+ GridComponent,
22
+ LegendComponent,
23
+ TitleComponent,
24
+ TooltipComponent,
25
+ DatasetComponent,
26
+ } from 'echarts/components';
27
+
28
+ /**
29
+ * Tree-shaken ECharts wiring. We pay only for the chart types + canvas
30
+ * renderer we actually use. Pipeline Stage 4 already ships each lazy
31
+ * group as its own chunk; this module is the chart-group entry point —
32
+ * importing anything from `'echarts'` directly elsewhere would pull
33
+ * the full library back in and undo the bundle savings.
34
+ *
35
+ * P0 ships Bar (the demo type). P3 layers Line / Pie / Scatter /
36
+ * Area / Combo / Stacked variants on top — those use the same
37
+ * Chart/Component registrations registered here, so future types
38
+ * just import their chart constructor and add to `use([…])`.
39
+ */
40
+ // `use` here is ECharts' renderer/component registry, not a React hook —
41
+ // silence rules-of-hooks for the module-level call.
42
+ // eslint-disable-next-line react-hooks/rules-of-hooks
43
+ use([
44
+ CanvasRenderer,
45
+ BarChart,
46
+ LineChart,
47
+ PieChart,
48
+ ScatterChart,
49
+ GridComponent,
50
+ LegendComponent,
51
+ TitleComponent,
52
+ TooltipComponent,
53
+ DatasetComponent,
54
+ ]);
55
+
56
+ export { init } from 'echarts/core';
57
+ export type { EChartsType } from 'echarts/core';
58
+ export type { EChartsOption } from 'echarts';
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Pixel ↔ cell conversion for chart drag / resize. The Univer facade
19
+ * doesn't ship a `pixelToCell` for our coordinate space, so we walk
20
+ * row heights / column widths via `getCellRect` until the cumulative
21
+ * extent contains the target coordinate.
22
+ *
23
+ * Inputs are in canvas-local PRE-scroll coordinates — the same frame
24
+ * `getCellRect` itself returns. Callers must add the viewport scroll
25
+ * before invoking these.
26
+ */
27
+
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ type Sheet = any;
30
+
31
+ const MAX_SCAN = 2048;
32
+
33
+ /** Find the row whose [top, bottom) range contains `y`. Clamped to MAX_SCAN. */
34
+ export function rowAtY(sheet: Sheet, y: number, hintRow = 0): number {
35
+ if (y < 0) return 0;
36
+ // Walk forward from the hint. Charts only move a few cells per drag
37
+ // frame, so the hint puts us within a handful of iterations.
38
+ let r = Math.max(0, hintRow);
39
+ for (let i = 0; i < MAX_SCAN; i++) {
40
+ let rect: { top: number; bottom: number } | null = null;
41
+ try {
42
+ rect = sheet.getRange(r, 0).getCellRect();
43
+ } catch {
44
+ return Math.max(0, r - 1);
45
+ }
46
+ if (!rect) return Math.max(0, r - 1);
47
+ if (rect.top > y) {
48
+ // Overshot — walk back row by row.
49
+ while (r > 0) {
50
+ r--;
51
+ try {
52
+ const back = sheet.getRange(r, 0).getCellRect();
53
+ if (back && back.top <= y && back.bottom > y) return r;
54
+ if (back && back.top <= y) return r;
55
+ } catch {
56
+ return r;
57
+ }
58
+ }
59
+ return 0;
60
+ }
61
+ if (rect.bottom > y) return r;
62
+ r++;
63
+ }
64
+ return r;
65
+ }
66
+
67
+ /** Find the column whose [left, right) range contains `x`. Clamped to MAX_SCAN. */
68
+ export function colAtX(sheet: Sheet, x: number, hintCol = 0): number {
69
+ if (x < 0) return 0;
70
+ let c = Math.max(0, hintCol);
71
+ for (let i = 0; i < MAX_SCAN; i++) {
72
+ let rect: { left: number; right: number } | null = null;
73
+ try {
74
+ rect = sheet.getRange(0, c).getCellRect();
75
+ } catch {
76
+ return Math.max(0, c - 1);
77
+ }
78
+ if (!rect) return Math.max(0, c - 1);
79
+ if (rect.left > x) {
80
+ while (c > 0) {
81
+ c--;
82
+ try {
83
+ const back = sheet.getRange(0, c).getCellRect();
84
+ if (back && back.left <= x && back.right > x) return c;
85
+ if (back && back.left <= x) return c;
86
+ } catch {
87
+ return c;
88
+ }
89
+ }
90
+ return 0;
91
+ }
92
+ if (rect.right > x) return c;
93
+ c++;
94
+ }
95
+ return c;
96
+ }
97
+
98
+ /**
99
+ * Convert a chart's screen-local pixel rect to cell-coordinate position.
100
+ * `screenRect` is the chart's host-local box (left/top/width/height).
101
+ * `host`-local pixels are translated to canvas-local pre-scroll coords
102
+ * using `canvasOffset` (canvas vs. host offset) and `scroll` (the
103
+ * worksheet's current scroll offset). We then snap the resulting
104
+ * top-left and bottom-right cells.
105
+ */
106
+ export function rectToCellPos(
107
+ sheet: Sheet,
108
+ screenRect: { left: number; top: number; width: number; height: number },
109
+ canvasOffset: { x: number; y: number },
110
+ scroll: { x: number; y: number },
111
+ hint?: { startRow: number; startColumn: number; endRow: number; endColumn: number },
112
+ ): { startRow: number; endRow: number; startColumn: number; endColumn: number } | null {
113
+ const preLeft = screenRect.left - canvasOffset.x + scroll.x;
114
+ const preTop = screenRect.top - canvasOffset.y + scroll.y;
115
+ const preRight = preLeft + screenRect.width;
116
+ const preBottom = preTop + screenRect.height;
117
+
118
+ const startRow = rowAtY(sheet, preTop, hint?.startRow);
119
+ const startColumn = colAtX(sheet, preLeft, hint?.startColumn);
120
+ // For the bottom-right, snap to the cell BEFORE the bottom-right
121
+ // pixel (so the chart includes that cell, matching Excel's "anchor
122
+ // to bottom-right cell" semantics). Subtract 1 from preRight/Bottom
123
+ // to land in the inclusive last cell.
124
+ const endRow = Math.max(startRow, rowAtY(sheet, preBottom - 1, hint?.endRow ?? startRow));
125
+ const endColumn = Math.max(
126
+ startColumn,
127
+ colAtX(sheet, preRight - 1, hint?.endColumn ?? startColumn),
128
+ );
129
+ return { startRow, endRow, startColumn, endColumn };
130
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { FUniver } from '@univerjs/core/facade';
18
+ import type { ChartType, ChartModel } from './types';
19
+ import { newChartId } from './types';
20
+
21
+ type Range = { startRow: number; endRow: number; startColumn: number; endColumn: number };
22
+
23
+ /**
24
+ * Build a ChartModel for `source` on the active sheet. The chart anchors
25
+ * to a 10-row × 8-col block placed two rows below the source so it doesn't
26
+ * cover its own data. Mirrors Excel's Insert &gt; Chart defaults — source =
27
+ * the dialog's selection, output = roughly the right size, dropped below.
28
+ *
29
+ * Returns null when there's no active workbook/sheet, or the range has
30
+ * fewer than 2 rows × 2 cols (no header + data row, or no label + value
31
+ * column).
32
+ */
33
+ export function buildChartModelForRange(
34
+ api: FUniver,
35
+ source: Range,
36
+ type: ChartType,
37
+ ): ChartModel | null {
38
+ const wb = api.getActiveWorkbook();
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const ws = wb?.getActiveSheet() as any;
41
+ if (!wb || !ws) return null;
42
+
43
+ const rows = source.endRow - source.startRow + 1;
44
+ const cols = source.endColumn - source.startColumn + 1;
45
+ if (rows < 2 || cols < 2) return null;
46
+
47
+ const chartTop = source.endRow + 2;
48
+ const chartLeft = source.startColumn;
49
+ return {
50
+ id: newChartId(),
51
+ sheetId: ws.getSheetId(),
52
+ source,
53
+ pos: {
54
+ startRow: chartTop,
55
+ endRow: chartTop + 9,
56
+ startColumn: chartLeft,
57
+ endColumn: chartLeft + 7,
58
+ },
59
+ type,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Read the active selection on the active sheet. Returns null if no
65
+ * workbook / sheet / selection is available — the menu item is enabled
66
+ * unconditionally, so the caller still has to handle the empty case.
67
+ */
68
+ export function getActiveSelectionRange(api: FUniver): Range | null {
69
+ const wb = api.getActiveWorkbook();
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const ws = wb?.getActiveSheet() as any;
72
+ const range = ws?.getActiveRange();
73
+ if (!wb || !ws || !range) return null;
74
+ const r = range.getRange();
75
+ return {
76
+ startRow: r.startRow,
77
+ endRow: r.endRow,
78
+ startColumn: r.startColumn,
79
+ endColumn: r.endColumn,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Format a cell range as an A1-style reference (e.g. `A1:C4`). Used by
85
+ * the insert dialog to pre-fill its source-range input from the current
86
+ * selection so the common case is two clicks.
87
+ */
88
+ export function rangeToA1(range: Range): string {
89
+ const tl = `${colIndexToA1(range.startColumn)}${range.startRow + 1}`;
90
+ if (range.startRow === range.endRow && range.startColumn === range.endColumn) {
91
+ return tl;
92
+ }
93
+ const br = `${colIndexToA1(range.endColumn)}${range.endRow + 1}`;
94
+ return `${tl}:${br}`;
95
+ }
96
+
97
+ function colIndexToA1(c: number): string {
98
+ let n = c + 1;
99
+ let s = '';
100
+ while (n > 0) {
101
+ const rem = (n - 1) % 26;
102
+ s = String.fromCharCode(65 + rem) + s;
103
+ n = Math.floor((n - 1) / 26);
104
+ }
105
+ return s;
106
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { ChartModel } from './types';
18
+
19
+ /**
20
+ * Excel auto-names charts as "Chart 1", "Chart 2", ..., picking the
21
+ * lowest unused suffix even if intermediate charts have been deleted
22
+ * (so deleting Chart 2 then inserting again gives you Chart 2, not
23
+ * Chart 4). Match that.
24
+ *
25
+ * Operates on `ChartModel.title` — see the Chart Selection Pane for
26
+ * inline rename.
27
+ */
28
+ export function nextChartName(existing: ChartModel[]): string {
29
+ const used = new Set<number>();
30
+ for (const c of existing) {
31
+ if (!c.title) continue;
32
+ const m = /^Chart (\d+)$/.exec(c.title);
33
+ if (m) used.add(Number(m[1]));
34
+ }
35
+ let n = 1;
36
+ while (used.has(n)) n++;
37
+ return `Chart ${n}`;
38
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { FUniver } from '@univerjs/core/facade';
18
+ import { init } from './echarts-init';
19
+ import { buildEChartsOption } from './build-option';
20
+ import type { ChartModel } from './types';
21
+
22
+ /**
23
+ * Render a chart model to a PNG ArrayBuffer using a detached ECharts
24
+ * instance. Used by the xlsx exporter (Charts P5b) so the chart shows
25
+ * up as a real image when the file is opened in Excel — the live
26
+ * interactive ECharts canvas can't ship; an embedded image can.
27
+ *
28
+ * Constraints:
29
+ * - Must run on the main thread (ECharts needs a DOM container).
30
+ * - The container is created inside `document.body` with
31
+ * `visibility: hidden` + `pointer-events: none` so it never
32
+ * interferes with the actual UI even momentarily.
33
+ * - We choose pixel dimensions based on the chart's cell-anchored
34
+ * position so the embedded PNG roughly matches what the user
35
+ * sees in the app.
36
+ *
37
+ * Returns null when there's no source data (empty range etc.) so the
38
+ * exporter can skip the chart cleanly rather than embed a 1×1 blank.
39
+ */
40
+ export async function renderChartToPng(
41
+ api: FUniver,
42
+ model: ChartModel,
43
+ pxWidth: number,
44
+ pxHeight: number,
45
+ ): Promise<ArrayBuffer | null> {
46
+ const opt = buildEChartsOption(api, model);
47
+ if (!opt) return null;
48
+
49
+ const host = document.createElement('div');
50
+ host.style.position = 'absolute';
51
+ host.style.left = '-99999px';
52
+ host.style.top = '0';
53
+ host.style.width = `${pxWidth}px`;
54
+ host.style.height = `${pxHeight}px`;
55
+ host.style.visibility = 'hidden';
56
+ host.style.pointerEvents = 'none';
57
+ document.body.appendChild(host);
58
+
59
+ try {
60
+ const inst = init(host, undefined, { renderer: 'canvas' });
61
+ // Apply the same option ChartOverlay uses so the snapshot matches
62
+ // what the user sees in-app. notMerge: true to fully override any
63
+ // ECharts defaults left over from the previous setOption.
64
+ inst.setOption(opt, true);
65
+ // ECharts renders on the next animation frame; force a synchronous
66
+ // resize so the canvas is filled before we read it.
67
+ inst.resize();
68
+ const dataUrl = inst.getDataURL({
69
+ type: 'png',
70
+ pixelRatio: 2,
71
+ backgroundColor: '#ffffff',
72
+ });
73
+ inst.dispose();
74
+ return dataUrlToArrayBuffer(dataUrl);
75
+ } finally {
76
+ host.remove();
77
+ }
78
+ }
79
+
80
+ function dataUrlToArrayBuffer(dataUrl: string): ArrayBuffer {
81
+ const comma = dataUrl.indexOf(',');
82
+ if (comma < 0) return new ArrayBuffer(0);
83
+ const b64 = dataUrl.slice(comma + 1);
84
+ const binary = atob(b64);
85
+ const bytes = new Uint8Array(binary.length);
86
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
87
+ return bytes.buffer;
88
+ }
89
+
90
+ /**
91
+ * Resolve the chart's pixel size from its cell-anchored position by
92
+ * walking the sheet's column widths / row heights. Falls back to a
93
+ * sensible default (480×320) when the snapshot doesn't have width/height
94
+ * data for the spanned cells.
95
+ */
96
+ export function pixelsForChart(api: FUniver, model: ChartModel): { width: number; height: number } {
97
+ const wb = api.getActiveWorkbook();
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ const sheets = wb?.getSheets() as any[] | undefined;
100
+ const ws = sheets?.find((s) => s.getSheetId?.() === model.sheetId);
101
+ if (!ws) return { width: 480, height: 320 };
102
+
103
+ let width = 0;
104
+ for (let c = model.pos.startColumn; c <= model.pos.endColumn; c++) {
105
+ const w = Number(ws.getColumnWidth?.(c) ?? 0);
106
+ width += w > 0 ? w : 88;
107
+ }
108
+ let height = 0;
109
+ for (let r = model.pos.startRow; r <= model.pos.endRow; r++) {
110
+ const h = Number(ws.getRowHeight?.(r) ?? 0);
111
+ height += h > 0 ? h : 24;
112
+ }
113
+ return {
114
+ width: Math.max(120, Math.round(width)),
115
+ height: Math.max(80, Math.round(height)),
116
+ };
117
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Copyright 2026 Casual Office
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { IWorkbookData } from '@univerjs/core';
18
+ import {
19
+ CHARTS_RESOURCE_NAME,
20
+ type ChartModel,
21
+ type ChartsResourceV1,
22
+ type ChartType,
23
+ } from './types';
24
+
25
+ /**
26
+ * Round-trip helpers for the charts plugin resource. The resource lives on
27
+ * `IWorkbookData.resources` and is carried through xlsx via the hidden
28
+ * `__casual_sheets_resources__` sheet we already use for outline groups,
29
+ * and through collab via Univer's snapshot-load path.
30
+ */
31
+
32
+ const VALID_TYPES: ChartType[] = [
33
+ 'column',
34
+ 'column-stacked',
35
+ 'column-stacked-100',
36
+ 'bar',
37
+ 'bar-stacked',
38
+ 'bar-stacked-100',
39
+ 'line',
40
+ 'line-stacked',
41
+ 'area',
42
+ 'area-stacked',
43
+ 'pie',
44
+ 'doughnut',
45
+ 'scatter',
46
+ ];
47
+
48
+ /**
49
+ * The pre-P3 store used `'bar'` for what we now (correctly) call
50
+ * `'column'` — vertical bars. Migrate on read so existing workbooks
51
+ * keep rendering after the rename.
52
+ */
53
+ function migrateType(raw: unknown): ChartType | null {
54
+ if (typeof raw !== 'string') return null;
55
+ // Existing 'bar' string is ambiguous between old "vertical column"
56
+ // and new "horizontal bar". P0/P1/P2 saved 'bar' meaning column;
57
+ // we have no horizontal-bar charts in the wild yet, so 'bar' from
58
+ // before P3 means column.
59
+ if (raw === 'bar') return 'column';
60
+ return VALID_TYPES.includes(raw as ChartType) ? (raw as ChartType) : null;
61
+ }
62
+
63
+ function isValidChart(c: unknown): c is ChartModel {
64
+ if (!c || typeof c !== 'object') return false;
65
+ const r = c as Record<string, unknown>;
66
+ if (typeof r.id !== 'string' || typeof r.sheetId !== 'string') return false;
67
+ const migrated = migrateType(r.type);
68
+ if (!migrated) return false;
69
+ r.type = migrated; // mutate so the rest of the app sees the new value
70
+ const src = r.source as Record<string, unknown> | undefined;
71
+ const pos = r.pos as Record<string, unknown> | undefined;
72
+ if (!src || !pos) return false;
73
+ for (const k of ['startRow', 'endRow', 'startColumn', 'endColumn'] as const) {
74
+ if (typeof src[k] !== 'number' || typeof pos[k] !== 'number') return false;
75
+ }
76
+ // `format` is optional and freely shaped — defer validation to
77
+ // `mergeFormat`, which fills missing fields with defaults. Anything
78
+ // we don't recognise is ignored at render time.
79
+ if (r.format != null && typeof r.format !== 'object') return false;
80
+ return true;
81
+ }
82
+
83
+ /** Read chart models out of a snapshot. Tolerant of older / missing payloads. */
84
+ export function readChartsFromSnapshot(data: IWorkbookData | undefined): ChartModel[] {
85
+ if (!data?.resources?.length) return [];
86
+ const entry = data.resources.find((r) => r.name === CHARTS_RESOURCE_NAME);
87
+ if (!entry?.data) return [];
88
+ try {
89
+ const parsed = JSON.parse(entry.data) as Partial<ChartsResourceV1>;
90
+ if (parsed?.v !== 1 || !Array.isArray(parsed.charts)) return [];
91
+ return parsed.charts.filter(isValidChart);
92
+ } catch {
93
+ /* corrupt payload — drop silently, the workbook still opens fine */
94
+ return [];
95
+ }
96
+ }
97
+
98
+ /** Merge chart models INTO `data.resources` for export. Mutates in place. */
99
+ export function writeChartsIntoSnapshot(data: IWorkbookData, charts: ChartModel[]): void {
100
+ const existing = data.resources ?? [];
101
+ const filtered = existing.filter((r) => r.name !== CHARTS_RESOURCE_NAME);
102
+ if (charts.length === 0) {
103
+ data.resources = filtered;
104
+ return;
105
+ }
106
+ const payload: ChartsResourceV1 = { v: 1, charts };
107
+ data.resources = [...filtered, { name: CHARTS_RESOURCE_NAME, data: JSON.stringify(payload) }];
108
+ }