@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.
- package/dist/chrome.cjs +3339 -244
- package/dist/chrome.cjs.map +1 -1
- package/dist/chrome.js +3298 -203
- package/dist/chrome.js.map +1 -1
- package/dist/embed/embed-runtime.js +203 -157
- package/dist/index.cjs +5971 -1572
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5961 -1548
- package/dist/index.js.map +1 -1
- package/dist/sheets.cjs +4937 -538
- package/dist/sheets.cjs.map +1 -1
- package/dist/sheets.js +4945 -532
- package/dist/sheets.js.map +1 -1
- package/package.json +8 -7
- package/src/charts/ChartContextMenu.tsx +264 -0
- package/src/charts/ChartLayer.tsx +333 -0
- package/src/charts/ChartOverlay.tsx +293 -0
- package/src/charts/ChartsPanel.tsx +211 -0
- package/src/charts/FormatChartDialog.tsx +419 -0
- package/src/charts/InsertChartDialog.tsx +478 -0
- package/src/charts/build-option.ts +476 -0
- package/src/charts/charts-context.tsx +205 -0
- package/src/charts/echarts-init.ts +58 -0
- package/src/charts/hit-test.ts +130 -0
- package/src/charts/insert-chart.ts +106 -0
- package/src/charts/naming.ts +38 -0
- package/src/charts/render-to-png.ts +117 -0
- package/src/charts/resources.ts +108 -0
- package/src/charts/types.ts +239 -0
- package/src/charts/univer-dom.ts +102 -0
- package/src/chrome/CommentsPanel.tsx +427 -0
- package/src/chrome/ConditionalFormattingDialog.tsx +534 -0
- package/src/chrome/CustomSortDialog.tsx +357 -0
- package/src/chrome/DataValidationDialog.tsx +536 -0
- package/src/chrome/DeleteCellsDialog.tsx +183 -0
- package/src/chrome/GoalSeekDialog.tsx +370 -0
- package/src/chrome/HistoryPanel.tsx +319 -0
- package/src/chrome/InsertCellsDialog.tsx +185 -0
- package/src/chrome/InsertChartDialog.tsx +490 -0
- package/src/chrome/InsertFunctionDialog.tsx +493 -0
- package/src/chrome/InsertPivotDialog.tsx +488 -0
- package/src/chrome/InsertSparklineDialog.tsx +344 -0
- package/src/chrome/NameManagerDialog.tsx +378 -0
- package/src/chrome/PanelHost.tsx +55 -0
- package/src/chrome/PanelRail.tsx +90 -0
- package/src/chrome/PasteSpecialDialog.tsx +286 -0
- package/src/chrome/PivotFieldsPanel.tsx +1052 -0
- package/src/chrome/TablesPanel.tsx +301 -0
- package/src/chrome/dialog-context.tsx +24 -0
- package/src/chrome/panel-context.tsx +55 -0
- package/src/chrome/panel-registry.ts +48 -0
- package/src/sheets/CasualSheets.tsx +60 -34
|
@@ -0,0 +1,476 @@
|
|
|
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 {
|
|
19
|
+
PALETTES,
|
|
20
|
+
mergeFormat,
|
|
21
|
+
type ChartModel,
|
|
22
|
+
type ChartType,
|
|
23
|
+
type ResolvedChartFormat,
|
|
24
|
+
} from './types';
|
|
25
|
+
import type { EChartsOption } from './echarts-init';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read cells from the chart's source range and turn them into an
|
|
29
|
+
* ECharts option. Convention (mirrors Excel's default chart-from-
|
|
30
|
+
* selection):
|
|
31
|
+
*
|
|
32
|
+
* - Row 0 of the source range = header row → series names.
|
|
33
|
+
* - Column 0 = category axis labels (x-axis for column / area / line,
|
|
34
|
+
* y-axis for horizontal bar, dimension for pie).
|
|
35
|
+
* - Remaining cells = numeric values, one series per column.
|
|
36
|
+
*
|
|
37
|
+
* Non-numeric cells coerce to `null` so the chart shows a gap instead
|
|
38
|
+
* of NaN. If the source range collapses to one row / one column we
|
|
39
|
+
* fall back to a "No data" placeholder so the overlay still paints
|
|
40
|
+
* rather than crashing.
|
|
41
|
+
*
|
|
42
|
+
* Formatting (title visibility, legend position, axis titles,
|
|
43
|
+
* gridlines, data labels, colour palette) is applied from
|
|
44
|
+
* `mergeFormat(model)` — defaults match Excel's first-render.
|
|
45
|
+
*/
|
|
46
|
+
export function buildEChartsOption(api: FUniver, model: ChartModel): EChartsOption | null {
|
|
47
|
+
const wb = api.getActiveWorkbook();
|
|
48
|
+
if (!wb) return null;
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
50
|
+
const sheets = wb.getSheets() as any[];
|
|
51
|
+
const ws = sheets.find((s) => s.getSheetId?.() === model.sheetId);
|
|
52
|
+
if (!ws) return null;
|
|
53
|
+
|
|
54
|
+
const { startRow, endRow, startColumn, endColumn } = model.source;
|
|
55
|
+
const rows = endRow - startRow + 1;
|
|
56
|
+
const cols = endColumn - startColumn + 1;
|
|
57
|
+
if (rows < 2 || cols < 2) {
|
|
58
|
+
return { title: { text: 'No data', left: 'center', top: 'center' } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const headers: string[] = [];
|
|
62
|
+
for (let c = 1; c < cols; c++) {
|
|
63
|
+
const v = ws.getRange(startRow, startColumn + c).getValue();
|
|
64
|
+
headers.push(v == null ? `Series ${c}` : String(v));
|
|
65
|
+
}
|
|
66
|
+
const categories: string[] = [];
|
|
67
|
+
for (let r = 1; r < rows; r++) {
|
|
68
|
+
const v = ws.getRange(startRow + r, startColumn).getValue();
|
|
69
|
+
categories.push(v == null ? '' : String(v));
|
|
70
|
+
}
|
|
71
|
+
const seriesData: Array<Array<number | null>> = headers.map((_, sIdx) => {
|
|
72
|
+
const data: Array<number | null> = [];
|
|
73
|
+
for (let r = 1; r < rows; r++) {
|
|
74
|
+
const v = ws.getRange(startRow + r, startColumn + 1 + sIdx).getValue();
|
|
75
|
+
const n = typeof v === 'number' ? v : Number(v);
|
|
76
|
+
data.push(Number.isFinite(n) ? n : null);
|
|
77
|
+
}
|
|
78
|
+
return data;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const format = mergeFormat(model);
|
|
82
|
+
// If every category parses as a date, build the axis as a time axis
|
|
83
|
+
// — ECharts gets nicer auto-formatted tick labels (Jan / Feb / Mar /
|
|
84
|
+
// 2024) than the literal category strings. Pie/scatter/doughnut
|
|
85
|
+
// ignore this (no category axis); buildOptionForType branches on it.
|
|
86
|
+
const dates = detectDateCategories(categories);
|
|
87
|
+
return buildOptionForType(
|
|
88
|
+
model.type,
|
|
89
|
+
headers,
|
|
90
|
+
categories,
|
|
91
|
+
seriesData,
|
|
92
|
+
model.title,
|
|
93
|
+
format,
|
|
94
|
+
dates,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns a parallel array of ms timestamps if every category cell
|
|
100
|
+
* looks like a date, otherwise null. Heuristic: parses with
|
|
101
|
+
* `Date.parse` and accepts only results in the 1900–2100 range so we
|
|
102
|
+
* don't false-positive on raw numbers like `2024` (which parses as
|
|
103
|
+
* "year 2024-01-01") for what's actually a numeric category.
|
|
104
|
+
*/
|
|
105
|
+
function detectDateCategories(categories: string[]): number[] | null {
|
|
106
|
+
if (categories.length === 0) return null;
|
|
107
|
+
const out: number[] = [];
|
|
108
|
+
const min = Date.UTC(1900, 0, 1);
|
|
109
|
+
const max = Date.UTC(2100, 0, 1);
|
|
110
|
+
// Require at least one slash, dash, or letter so a column of bare
|
|
111
|
+
// integers (e.g. `2024`, `2025`) doesn't get interpreted as years.
|
|
112
|
+
const dateLike = /[-/]|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b/i;
|
|
113
|
+
for (const c of categories) {
|
|
114
|
+
if (!c) return null;
|
|
115
|
+
if (!dateLike.test(c)) return null;
|
|
116
|
+
const t = Date.parse(c);
|
|
117
|
+
if (!Number.isFinite(t) || t < min || t > max) return null;
|
|
118
|
+
out.push(t);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildOptionForType(
|
|
124
|
+
type: ChartType,
|
|
125
|
+
headers: string[],
|
|
126
|
+
categories: string[],
|
|
127
|
+
rawSeries: Array<Array<number | null>>,
|
|
128
|
+
title: string | undefined,
|
|
129
|
+
format: ResolvedChartFormat,
|
|
130
|
+
dateCategories: number[] | null = null,
|
|
131
|
+
): EChartsOption {
|
|
132
|
+
const titleNode =
|
|
133
|
+
title && format.showTitle ? { text: title, left: 'center' as const } : undefined;
|
|
134
|
+
const colors = PALETTES[format.palette];
|
|
135
|
+
const legendNode = legendOption(format);
|
|
136
|
+
const showAxes = format.legend !== 'none';
|
|
137
|
+
void showAxes;
|
|
138
|
+
|
|
139
|
+
if (type === 'pie' || type === 'doughnut') {
|
|
140
|
+
const pieData = categories.map((label, i) => ({
|
|
141
|
+
name: label,
|
|
142
|
+
value: rawSeries[0]?.[i] ?? 0,
|
|
143
|
+
}));
|
|
144
|
+
return {
|
|
145
|
+
color: colors,
|
|
146
|
+
title: titleNode,
|
|
147
|
+
tooltip: { trigger: 'item' },
|
|
148
|
+
legend: legendNode,
|
|
149
|
+
series: [
|
|
150
|
+
{
|
|
151
|
+
type: 'pie',
|
|
152
|
+
radius: type === 'doughnut' ? ['40%', '70%'] : '60%',
|
|
153
|
+
center: ['50%', titleNode ? '52%' : '48%'],
|
|
154
|
+
data: pieData,
|
|
155
|
+
label: format.dataLabels ? { formatter: '{b}: {c}' } : { formatter: '{b}' },
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
+
} as any;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (type === 'scatter') {
|
|
163
|
+
const xs = rawSeries[0] ?? [];
|
|
164
|
+
const series = rawSeries.slice(1).map((ys, i) => ({
|
|
165
|
+
name: headers[i + 1] ?? headers[0],
|
|
166
|
+
type: 'scatter' as const,
|
|
167
|
+
data: xs.map((x, idx) => [x, ys[idx]]).filter(([a, b]) => a != null && b != null),
|
|
168
|
+
label: dataLabelConfig(format),
|
|
169
|
+
}));
|
|
170
|
+
return {
|
|
171
|
+
color: colors,
|
|
172
|
+
title: titleNode,
|
|
173
|
+
tooltip: { trigger: 'item' },
|
|
174
|
+
legend: legendNode,
|
|
175
|
+
grid: chartGrid(format, titleNode != null),
|
|
176
|
+
xAxis: {
|
|
177
|
+
type: 'value',
|
|
178
|
+
name: format.xAxisTitle ?? headers[0] ?? '',
|
|
179
|
+
nameLocation: 'middle',
|
|
180
|
+
nameGap: 24,
|
|
181
|
+
splitLine: { show: format.gridlines },
|
|
182
|
+
},
|
|
183
|
+
yAxis: {
|
|
184
|
+
type: 'value',
|
|
185
|
+
name: format.yAxisTitle ?? '',
|
|
186
|
+
nameLocation: 'middle',
|
|
187
|
+
nameGap: 36,
|
|
188
|
+
splitLine: { show: format.gridlines },
|
|
189
|
+
},
|
|
190
|
+
series:
|
|
191
|
+
series.length > 0
|
|
192
|
+
? series
|
|
193
|
+
: [
|
|
194
|
+
{
|
|
195
|
+
name: headers[0] ?? 'Series',
|
|
196
|
+
type: 'scatter' as const,
|
|
197
|
+
data: xs.map((x, idx) => [idx, x]).filter(([, b]) => b != null),
|
|
198
|
+
label: dataLabelConfig(format),
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
202
|
+
} as any;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isHorizontalBar = type === 'bar' || type === 'bar-stacked' || type === 'bar-stacked-100';
|
|
206
|
+
const is100 = type === 'column-stacked-100' || type === 'bar-stacked-100';
|
|
207
|
+
// Combo + dual-axis only make sense on a plain (non-100%, non-
|
|
208
|
+
// horizontal) value-vs-category chart: column / line / area. A 100%-
|
|
209
|
+
// stacked chart already pins both series to a shared 0–100% scale,
|
|
210
|
+
// and horizontal bars swap the axes so a "secondary value axis on
|
|
211
|
+
// the right" no longer reads as Excel does it. Gate the features
|
|
212
|
+
// here so the rest of the branch can assume vertical, absolute axes.
|
|
213
|
+
const allowComboAndDualAxis = !is100 && !isHorizontalBar;
|
|
214
|
+
// Per-series secondary-axis flags, restricted to series that actually
|
|
215
|
+
// exist in this chart. Empty unless the feature applies.
|
|
216
|
+
const secondarySeries = allowComboAndDualAxis
|
|
217
|
+
? headers.filter((name) => format.secondaryAxis?.[name])
|
|
218
|
+
: [];
|
|
219
|
+
const hasSecondaryAxis = secondarySeries.length > 0;
|
|
220
|
+
const isStacked =
|
|
221
|
+
type === 'column-stacked' ||
|
|
222
|
+
type === 'column-stacked-100' ||
|
|
223
|
+
type === 'bar-stacked' ||
|
|
224
|
+
type === 'bar-stacked-100' ||
|
|
225
|
+
type === 'line-stacked' ||
|
|
226
|
+
type === 'area-stacked';
|
|
227
|
+
const isLine = type === 'line' || type === 'line-stacked';
|
|
228
|
+
const isArea = type === 'area' || type === 'area-stacked';
|
|
229
|
+
const echartsType: 'bar' | 'line' = isLine || isArea ? 'line' : 'bar';
|
|
230
|
+
|
|
231
|
+
const sumPerCat = is100
|
|
232
|
+
? categories.map((_, i) => {
|
|
233
|
+
let s = 0;
|
|
234
|
+
for (const ys of rawSeries) {
|
|
235
|
+
const v = ys[i];
|
|
236
|
+
if (typeof v === 'number') s += v;
|
|
237
|
+
}
|
|
238
|
+
return s === 0 ? 1 : s;
|
|
239
|
+
})
|
|
240
|
+
: null;
|
|
241
|
+
|
|
242
|
+
// Time axis only kicks in for horizontal-time charts (column / line /
|
|
243
|
+
// area). Horizontal bars keep the categorical axis to avoid weird
|
|
244
|
+
// sideways time scrolling.
|
|
245
|
+
const useTimeAxis = dateCategories != null && !isHorizontalBar;
|
|
246
|
+
|
|
247
|
+
const series = headers.map((name, sIdx) => {
|
|
248
|
+
const raw = rawSeries[sIdx] ?? [];
|
|
249
|
+
const dataRaw =
|
|
250
|
+
is100 && sumPerCat
|
|
251
|
+
? raw.map((v, i) => (typeof v === 'number' ? (v / sumPerCat[i]) * 100 : null))
|
|
252
|
+
: raw;
|
|
253
|
+
// ECharts' time axis wants `[timestamp, value]` pairs. The
|
|
254
|
+
// category axis takes plain values aligned with the axis labels.
|
|
255
|
+
const data =
|
|
256
|
+
useTimeAxis && dateCategories
|
|
257
|
+
? (dataRaw as Array<number | null>).map((v, i) => [dateCategories[i], v])
|
|
258
|
+
: dataRaw;
|
|
259
|
+
const trendlineMark = format.trendline
|
|
260
|
+
? buildTrendlineMark(dataRaw as Array<number | null>)
|
|
261
|
+
: undefined;
|
|
262
|
+
// Per-series colour override: if the user picked a specific
|
|
263
|
+
// colour for this series in the Format Chart dialog, it wins over
|
|
264
|
+
// the palette's default. Stored on `format.seriesColors[name]`.
|
|
265
|
+
const overrideColor = format.seriesColors?.[name];
|
|
266
|
+
// Combo: a per-series render-kind override turns this single series
|
|
267
|
+
// into a bar or line regardless of the chart's base type. Only
|
|
268
|
+
// honoured on the column / line / area families (see
|
|
269
|
+
// `allowComboAndDualAxis`). Area's fill is preserved only when the
|
|
270
|
+
// series stays a line; an explicit `bar` override drops the fill.
|
|
271
|
+
const seriesKind = allowComboAndDualAxis ? format.seriesTypes?.[name] : undefined;
|
|
272
|
+
const resolvedType: 'bar' | 'line' = seriesKind ?? echartsType;
|
|
273
|
+
const seriesIsLine = resolvedType === 'line';
|
|
274
|
+
const seriesIsArea = isArea && seriesIsLine && !seriesKind;
|
|
275
|
+
// Dual axis: route this series to yAxisIndex 1 (the secondary,
|
|
276
|
+
// right-hand value axis) when flagged. `yAxis` becomes a two-entry
|
|
277
|
+
// array below; primary series keep the default index 0.
|
|
278
|
+
const onSecondary = allowComboAndDualAxis && Boolean(format.secondaryAxis?.[name]);
|
|
279
|
+
return {
|
|
280
|
+
name,
|
|
281
|
+
type: resolvedType,
|
|
282
|
+
data,
|
|
283
|
+
...(hasSecondaryAxis ? { yAxisIndex: onSecondary ? 1 : 0 } : {}),
|
|
284
|
+
...(isStacked && !seriesKind ? { stack: 'all' as const } : {}),
|
|
285
|
+
...(seriesIsArea ? { areaStyle: {} } : {}),
|
|
286
|
+
...(seriesIsLine ? { smooth: false, symbol: 'circle' as const, symbolSize: 4 } : {}),
|
|
287
|
+
...(trendlineMark ? { markLine: trendlineMark } : {}),
|
|
288
|
+
...(overrideColor
|
|
289
|
+
? { itemStyle: { color: overrideColor }, lineStyle: { color: overrideColor } }
|
|
290
|
+
: {}),
|
|
291
|
+
label: dataLabelConfig(format, isHorizontalBar, seriesIsLine),
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const categoryAxis = useTimeAxis
|
|
296
|
+
? {
|
|
297
|
+
type: 'time' as const,
|
|
298
|
+
name: isHorizontalBar ? (format.yAxisTitle ?? '') : (format.xAxisTitle ?? ''),
|
|
299
|
+
nameLocation: 'middle' as const,
|
|
300
|
+
nameGap: 24,
|
|
301
|
+
}
|
|
302
|
+
: {
|
|
303
|
+
type: 'category' as const,
|
|
304
|
+
data: categories,
|
|
305
|
+
name: isHorizontalBar ? (format.yAxisTitle ?? '') : (format.xAxisTitle ?? ''),
|
|
306
|
+
nameLocation: 'middle' as const,
|
|
307
|
+
nameGap: 24,
|
|
308
|
+
};
|
|
309
|
+
const valueAxis: Record<string, unknown> = {
|
|
310
|
+
type: 'value' as const,
|
|
311
|
+
name: isHorizontalBar ? (format.xAxisTitle ?? '') : (format.yAxisTitle ?? ''),
|
|
312
|
+
nameLocation: 'middle',
|
|
313
|
+
nameGap: 36,
|
|
314
|
+
splitLine: { show: format.gridlines },
|
|
315
|
+
};
|
|
316
|
+
if (is100) {
|
|
317
|
+
valueAxis.max = 100;
|
|
318
|
+
valueAxis.axisLabel = { formatter: '{value}%' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Dual axis: build a second value axis aligned to the right. Its name
|
|
322
|
+
// defaults to the secondary series' name(s) so the reader can tell
|
|
323
|
+
// which line/bars it scales. `gridlines` is left off the secondary
|
|
324
|
+
// axis to avoid two overlapping splitLine grids fighting each other.
|
|
325
|
+
const secondaryValueAxis: Record<string, unknown> | null = hasSecondaryAxis
|
|
326
|
+
? {
|
|
327
|
+
type: 'value' as const,
|
|
328
|
+
name: secondarySeries.join(' / '),
|
|
329
|
+
nameLocation: 'middle',
|
|
330
|
+
nameGap: 40,
|
|
331
|
+
position: 'right',
|
|
332
|
+
splitLine: { show: false },
|
|
333
|
+
}
|
|
334
|
+
: null;
|
|
335
|
+
|
|
336
|
+
// The value axis lives on Y for vertical charts. When a secondary
|
|
337
|
+
// axis is requested we emit `yAxis: [primary, secondary]` and the
|
|
338
|
+
// series above carry `yAxisIndex`. Horizontal bars + 100%-stacked
|
|
339
|
+
// never reach here with `hasSecondaryAxis` (gated by
|
|
340
|
+
// `allowComboAndDualAxis`), so the single-axis path stays unchanged
|
|
341
|
+
// for them.
|
|
342
|
+
const yAxisNode =
|
|
343
|
+
!isHorizontalBar && secondaryValueAxis
|
|
344
|
+
? [valueAxis, secondaryValueAxis]
|
|
345
|
+
: isHorizontalBar
|
|
346
|
+
? categoryAxis
|
|
347
|
+
: valueAxis;
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
color: colors,
|
|
351
|
+
title: titleNode,
|
|
352
|
+
tooltip: {
|
|
353
|
+
trigger: 'axis',
|
|
354
|
+
...(is100 ? { valueFormatter: (v: unknown) => `${Math.round(Number(v))}%` } : {}),
|
|
355
|
+
},
|
|
356
|
+
legend: legendNode,
|
|
357
|
+
grid: chartGrid(format, titleNode != null, hasSecondaryAxis),
|
|
358
|
+
xAxis: isHorizontalBar ? valueAxis : categoryAxis,
|
|
359
|
+
yAxis: yAxisNode,
|
|
360
|
+
series,
|
|
361
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
362
|
+
} as any;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function legendOption(format: ResolvedChartFormat): Record<string, unknown> | undefined {
|
|
366
|
+
if (format.legend === 'none') return undefined;
|
|
367
|
+
const pos: Record<string, unknown> = { type: 'scroll' };
|
|
368
|
+
switch (format.legend) {
|
|
369
|
+
case 'top':
|
|
370
|
+
pos.top = 0;
|
|
371
|
+
break;
|
|
372
|
+
case 'bottom':
|
|
373
|
+
pos.bottom = 0;
|
|
374
|
+
break;
|
|
375
|
+
case 'left':
|
|
376
|
+
pos.left = 0;
|
|
377
|
+
pos.orient = 'vertical';
|
|
378
|
+
break;
|
|
379
|
+
case 'right':
|
|
380
|
+
pos.right = 0;
|
|
381
|
+
pos.orient = 'vertical';
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
return pos;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function chartGrid(
|
|
388
|
+
format: ResolvedChartFormat,
|
|
389
|
+
hasTitle: boolean,
|
|
390
|
+
hasSecondaryAxis = false,
|
|
391
|
+
): Record<string, unknown> {
|
|
392
|
+
// Make room for legend / title / axis-name labels by padding the
|
|
393
|
+
// plot area. Without this the value axis name gets clipped by the
|
|
394
|
+
// legend at the bottom.
|
|
395
|
+
const grid: Record<string, unknown> = {
|
|
396
|
+
left: 56,
|
|
397
|
+
right: hasSecondaryAxis ? 56 : 24,
|
|
398
|
+
top: hasTitle ? 40 : 16,
|
|
399
|
+
bottom: 56,
|
|
400
|
+
containLabel: true,
|
|
401
|
+
};
|
|
402
|
+
switch (format.legend) {
|
|
403
|
+
case 'top':
|
|
404
|
+
grid.top = hasTitle ? 60 : 32;
|
|
405
|
+
break;
|
|
406
|
+
case 'left':
|
|
407
|
+
grid.left = 96;
|
|
408
|
+
break;
|
|
409
|
+
case 'right':
|
|
410
|
+
// A right-side legend AND a secondary axis both compete for the
|
|
411
|
+
// right margin; widen further so neither clips the other.
|
|
412
|
+
grid.right = hasSecondaryAxis ? 128 : 96;
|
|
413
|
+
break;
|
|
414
|
+
case 'none':
|
|
415
|
+
grid.bottom = 32;
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
return grid;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function dataLabelConfig(
|
|
422
|
+
format: ResolvedChartFormat,
|
|
423
|
+
isHorizontalBar?: boolean,
|
|
424
|
+
isLineOrArea?: boolean,
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
if (!format.dataLabels) return { show: false };
|
|
427
|
+
if (isLineOrArea) return { show: true, position: 'top' };
|
|
428
|
+
if (isHorizontalBar) return { show: true, position: 'right' };
|
|
429
|
+
return { show: true, position: 'top' };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Linear regression trendline. Computes the best-fit line via simple
|
|
434
|
+
* ordinary-least-squares on the series data points, then encodes it
|
|
435
|
+
* as an ECharts `markLine` from (x_min, y_pred(x_min)) to (x_max,
|
|
436
|
+
* y_pred(x_max)). Returns `undefined` if fewer than two valid
|
|
437
|
+
* numeric points exist (a single point has no slope).
|
|
438
|
+
*/
|
|
439
|
+
function buildTrendlineMark(data: Array<number | null>): Record<string, unknown> | undefined {
|
|
440
|
+
const points: Array<{ x: number; y: number }> = [];
|
|
441
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
442
|
+
const v = data[i];
|
|
443
|
+
if (typeof v === 'number' && !Number.isNaN(v)) points.push({ x: i, y: v });
|
|
444
|
+
}
|
|
445
|
+
if (points.length < 2) return undefined;
|
|
446
|
+
const n = points.length;
|
|
447
|
+
let sumX = 0;
|
|
448
|
+
let sumY = 0;
|
|
449
|
+
let sumXY = 0;
|
|
450
|
+
let sumXX = 0;
|
|
451
|
+
for (const p of points) {
|
|
452
|
+
sumX += p.x;
|
|
453
|
+
sumY += p.y;
|
|
454
|
+
sumXY += p.x * p.y;
|
|
455
|
+
sumXX += p.x * p.x;
|
|
456
|
+
}
|
|
457
|
+
const denom = n * sumXX - sumX * sumX;
|
|
458
|
+
if (denom === 0) return undefined;
|
|
459
|
+
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
460
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
461
|
+
const xMin = points[0].x;
|
|
462
|
+
const xMax = points[points.length - 1].x;
|
|
463
|
+
const yAtMin = slope * xMin + intercept;
|
|
464
|
+
const yAtMax = slope * xMax + intercept;
|
|
465
|
+
return {
|
|
466
|
+
silent: true,
|
|
467
|
+
symbol: 'none',
|
|
468
|
+
lineStyle: { type: 'dashed', width: 2, opacity: 0.75 },
|
|
469
|
+
data: [
|
|
470
|
+
[
|
|
471
|
+
{ xAxis: xMin, yAxis: yAtMin },
|
|
472
|
+
{ xAxis: xMax, yAxis: yAtMax },
|
|
473
|
+
],
|
|
474
|
+
],
|
|
475
|
+
};
|
|
476
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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 {
|
|
18
|
+
createContext,
|
|
19
|
+
useCallback,
|
|
20
|
+
useContext,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react';
|
|
26
|
+
import type { ReactNode } from 'react';
|
|
27
|
+
import type { CasualSheetsAPI } from '../sheets/api';
|
|
28
|
+
import { readChartsFromSnapshot, writeChartsIntoSnapshot } from './resources';
|
|
29
|
+
import type { ChartModel } from './types';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Chart store mirrored into `IWorkbookData.resources['__casual_sheets_charts__']`
|
|
33
|
+
* at save time and re-hydrated when the active workbook changes. The store
|
|
34
|
+
* keeps charts keyed by id (`ChartModel.id`); removal by id, updates by
|
|
35
|
+
* replace — every change produces a new model object so React effect deps
|
|
36
|
+
* work for downstream consumers (ChartLayer, ChartOverlay).
|
|
37
|
+
*
|
|
38
|
+
* SDK port: the app read/wrote the store through the hidden xlsx pre-pass +
|
|
39
|
+
* `useWorkbook()` revision tracking. Here the store is loaded on mount and
|
|
40
|
+
* persisted on every local change through the SDK's `api.getContent()` /
|
|
41
|
+
* `api.setContent()` (which serialize/replace the active `IWorkbookData`).
|
|
42
|
+
* `api` is also re-exported via `useCharts()` so downstream chart components
|
|
43
|
+
* reach the FUniver facade through `api.univer` instead of a React hook.
|
|
44
|
+
*/
|
|
45
|
+
type ChartsCtxValue = {
|
|
46
|
+
/** The SDK editor handle — chart components reach the FUniver facade via
|
|
47
|
+
* `api.univer` (never a React `useUniverAPI` hook, which doesn't exist in
|
|
48
|
+
* the SDK). */
|
|
49
|
+
api: CasualSheetsAPI;
|
|
50
|
+
charts: ChartModel[];
|
|
51
|
+
/** Id of the chart with the selection frame + handles drawn. At most
|
|
52
|
+
* one chart is selected at a time (Excel single-select; multi-select
|
|
53
|
+
* via Ctrl+click is not implemented yet). */
|
|
54
|
+
selectedId: string | null;
|
|
55
|
+
insert: (chart: ChartModel) => void;
|
|
56
|
+
remove: (id: string) => void;
|
|
57
|
+
update: (id: string, patch: Partial<ChartModel>) => void;
|
|
58
|
+
select: (id: string | null) => void;
|
|
59
|
+
/** Replace the entire chart list. Used by CollabDriver to apply
|
|
60
|
+
* remote chart-state updates from the Yjs sync map. App code should
|
|
61
|
+
* prefer `insert / remove / update` — `__replaceAll` bypasses the
|
|
62
|
+
* collab broadcast tag, so consecutive calls from outside the
|
|
63
|
+
* collab driver can clobber each other. */
|
|
64
|
+
__replaceAll: (next: ChartModel[], opts?: { fromCollab?: boolean }) => void;
|
|
65
|
+
/** Subscribe to LOCAL chart-list changes (insert / remove / update,
|
|
66
|
+
* excluding remote echoes from `__replaceAll({ fromCollab: true })`).
|
|
67
|
+
* CollabDriver uses this to push our edits into the Yjs map without
|
|
68
|
+
* echoing them back. Returns an unsubscribe. */
|
|
69
|
+
__subscribeLocal: (cb: (charts: ChartModel[]) => void) => () => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const ChartsContext = createContext<ChartsCtxValue | null>(null);
|
|
73
|
+
|
|
74
|
+
export function useCharts(): ChartsCtxValue {
|
|
75
|
+
const ctx = useContext(ChartsContext);
|
|
76
|
+
if (!ctx) throw new Error('useCharts must be used inside <ChartsProvider>');
|
|
77
|
+
return ctx;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ChartsProvider({ api, children }: { api: CasualSheetsAPI; children: ReactNode }) {
|
|
81
|
+
const [charts, setCharts] = useState<ChartModel[]>(() =>
|
|
82
|
+
readChartsFromSnapshot(api.getContent() ?? undefined),
|
|
83
|
+
);
|
|
84
|
+
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
85
|
+
// Local change subscribers (used by CollabDriver to push to Yjs).
|
|
86
|
+
// Stored on a ref so the subscribe API is stable across renders.
|
|
87
|
+
const subsRef = useRef<Set<(c: ChartModel[]) => void>>(new Set());
|
|
88
|
+
const notifyLocal = useCallback((next: ChartModel[]) => {
|
|
89
|
+
for (const cb of subsRef.current) cb(next);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
// Persist the chart list into the active workbook snapshot on every local
|
|
93
|
+
// change. Mirrors the app's write-into-resources pre-pass, but driven
|
|
94
|
+
// through the SDK content accessors. Guarded by `persistingRef` so the
|
|
95
|
+
// `setContent` round-trip we trigger here doesn't bounce back through the
|
|
96
|
+
// `change` re-hydrate below as a phantom remote update.
|
|
97
|
+
const persistingRef = useRef(false);
|
|
98
|
+
const persist = useCallback(
|
|
99
|
+
(next: ChartModel[]) => {
|
|
100
|
+
const snap = api.getContent();
|
|
101
|
+
if (!snap) return;
|
|
102
|
+
writeChartsIntoSnapshot(snap, next);
|
|
103
|
+
persistingRef.current = true;
|
|
104
|
+
try {
|
|
105
|
+
api.setContent(snap);
|
|
106
|
+
} finally {
|
|
107
|
+
persistingRef.current = false;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[api],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Re-hydrate on external content swaps (Open / New / collab remote-snapshot).
|
|
114
|
+
// The app keyed this off `useWorkbook().meta.revision`; the SDK surfaces the
|
|
115
|
+
// same signal as the `change` event carrying a fresh `IWorkbookData`. Skip
|
|
116
|
+
// the echo from our own `persist()` setContent so a local edit doesn't wipe
|
|
117
|
+
// the selection / re-read what we just wrote.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const off = api.on('change', (snapshot) => {
|
|
120
|
+
if (persistingRef.current) return;
|
|
121
|
+
setCharts(readChartsFromSnapshot(snapshot));
|
|
122
|
+
setSelectedId(null);
|
|
123
|
+
});
|
|
124
|
+
return off;
|
|
125
|
+
}, [api]);
|
|
126
|
+
|
|
127
|
+
const insert = useCallback(
|
|
128
|
+
(chart: ChartModel) => {
|
|
129
|
+
setCharts((prev) => {
|
|
130
|
+
const next = [...prev, chart];
|
|
131
|
+
notifyLocal(next);
|
|
132
|
+
persist(next);
|
|
133
|
+
return next;
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
[notifyLocal, persist],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const remove = useCallback(
|
|
140
|
+
(id: string) => {
|
|
141
|
+
setCharts((prev) => {
|
|
142
|
+
const next = prev.filter((c) => c.id !== id);
|
|
143
|
+
notifyLocal(next);
|
|
144
|
+
persist(next);
|
|
145
|
+
return next;
|
|
146
|
+
});
|
|
147
|
+
setSelectedId((cur) => (cur === id ? null : cur));
|
|
148
|
+
},
|
|
149
|
+
[notifyLocal, persist],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const update = useCallback(
|
|
153
|
+
(id: string, patch: Partial<ChartModel>) => {
|
|
154
|
+
setCharts((prev) => {
|
|
155
|
+
const next = prev.map((c) => (c.id === id ? { ...c, ...patch } : c));
|
|
156
|
+
notifyLocal(next);
|
|
157
|
+
persist(next);
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
[notifyLocal, persist],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const select = useCallback((id: string | null) => {
|
|
165
|
+
setSelectedId(id);
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const __replaceAll = useCallback(
|
|
169
|
+
(next: ChartModel[], opts?: { fromCollab?: boolean }) => {
|
|
170
|
+
setCharts(next);
|
|
171
|
+
// Drop the selection if its chart no longer exists in the new list
|
|
172
|
+
// (a peer deleted it).
|
|
173
|
+
setSelectedId((cur) => (cur && next.some((c) => c.id === cur) ? cur : null));
|
|
174
|
+
if (!opts?.fromCollab) {
|
|
175
|
+
notifyLocal(next);
|
|
176
|
+
persist(next);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
[notifyLocal, persist],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const __subscribeLocal = useCallback((cb: (charts: ChartModel[]) => void) => {
|
|
183
|
+
subsRef.current.add(cb);
|
|
184
|
+
return () => {
|
|
185
|
+
subsRef.current.delete(cb);
|
|
186
|
+
};
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const value = useMemo<ChartsCtxValue>(
|
|
190
|
+
() => ({
|
|
191
|
+
api,
|
|
192
|
+
charts,
|
|
193
|
+
selectedId,
|
|
194
|
+
insert,
|
|
195
|
+
remove,
|
|
196
|
+
update,
|
|
197
|
+
select,
|
|
198
|
+
__replaceAll,
|
|
199
|
+
__subscribeLocal,
|
|
200
|
+
}),
|
|
201
|
+
[api, charts, selectedId, insert, remove, update, select, __replaceAll, __subscribeLocal],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return <ChartsContext.Provider value={value}>{children}</ChartsContext.Provider>;
|
|
205
|
+
}
|