返回 presentation-ai
1 import { getLabelKey } from "../chart-utils";
2 import {
3 type ChartDataMode,
4 type ChartDataType,
5 type MultiSeriesData,
6 type SeriesChartType,
7 } from "./types";
8
9 type ChartDataFieldType = "number" | "text";
10
11 export type ChartDataField = {
12 key: string;
13 label: string;
14 type: ChartDataFieldType;
15 placeholder?: string;
16 editableHeader?: boolean;
17 removable?: boolean;
18 colorIndex?: number;
19 };
20
21 export type ChartDataRow = Record<string, string | number>;
22
23 export type ChartEditorSchema = {
24 mode: ChartDataMode;
25 description: string;
26 fixedFields: ChartDataField[];
27 supportsSeries: boolean;
28 supportsZColumn: boolean;
29 defaultRows: ChartDataRow[];
30 defaultSeriesNames: string[];
31 };
32
33 const DEFAULT_CHART_TYPES: SeriesChartType[] = ["bar", "line", "area"];
34
35 const categoricalRows = [
36 { label: "Q1 Revenue", value: 45200 },
37 { label: "Q2 Revenue", value: 52800 },
38 { label: "Q3 Revenue", value: 48100 },
39 { label: "Q4 Revenue", value: 61400 },
40 ];
41
42 const multiSeriesRows = [
43 { label: "Jan", revenue: 4500, expenses: 3200, profit: 1300 },
44 { label: "Feb", revenue: 5200, expenses: 3400, profit: 1800 },
45 { label: "Mar", revenue: 4800, expenses: 3100, profit: 1700 },
46 { label: "Apr", revenue: 6100, expenses: 3800, profit: 2300 },
47 ];
48
49 const CHART_EDITOR_SCHEMAS: Record<ChartDataMode, ChartEditorSchema> = {
50 categorical: {
51 mode: "categorical",
52 description: "Use one label column and one or more numeric series.",
53 fixedFields: [{ key: "label", label: "Label", type: "text" }],
54 supportsSeries: true,
55 supportsZColumn: false,
56 defaultRows: categoricalRows,
57 defaultSeriesNames: ["value"],
58 },
59 "label-value": {
60 mode: "label-value",
61 description: "Use one label and one value for each slice or segment.",
62 fixedFields: [{ key: "label", label: "Label", type: "text" }],
63 supportsSeries: true,
64 supportsZColumn: false,
65 defaultRows: categoricalRows,
66 defaultSeriesNames: ["value"],
67 },
68 "multi-series": {
69 mode: "multi-series",
70 description: "Use one category column with multiple numeric series.",
71 fixedFields: [{ key: "label", label: "Category", type: "text" }],
72 supportsSeries: true,
73 supportsZColumn: false,
74 defaultRows: multiSeriesRows,
75 defaultSeriesNames: ["revenue", "expenses", "profit"],
76 },
77 xy: {
78 mode: "xy",
79 description: "Use numeric X and Y coordinates for each point.",
80 fixedFields: [
81 { key: "x", label: "X", type: "number" },
82 { key: "y", label: "Y", type: "number" },
83 ],
84 supportsSeries: false,
85 supportsZColumn: false,
86 defaultRows: [
87 { x: 10, y: 30 },
88 { x: 25, y: 45 },
89 { x: 40, y: 28 },
90 { x: 55, y: 62 },
91 ],
92 defaultSeriesNames: [],
93 },
94 xyz: {
95 mode: "xyz",
96 description: "Use X/Y coordinates and Z for bubble size.",
97 fixedFields: [
98 { key: "x", label: "X", type: "number" },
99 { key: "y", label: "Y", type: "number" },
100 { key: "z", label: "Z (Size)", type: "number" },
101 ],
102 supportsSeries: false,
103 supportsZColumn: true,
104 defaultRows: [
105 { x: 10, y: 30, z: 15 },
106 { x: 25, y: 45, z: 22 },
107 { x: 40, y: 28, z: 18 },
108 { x: 55, y: 62, z: 30 },
109 ],
110 defaultSeriesNames: [],
111 },
112 range: {
113 mode: "range",
114 description: "Use a category with low and high values.",
115 fixedFields: [
116 { key: "category", label: "Category", type: "text" },
117 { key: "low", label: "Low", type: "number" },
118 { key: "high", label: "High", type: "number" },
119 ],
120 supportsSeries: false,
121 supportsZColumn: false,
122 defaultRows: [
123 { category: "Planning", low: 12, high: 28 },
124 { category: "Design", low: 18, high: 36 },
125 { category: "Build", low: 30, high: 62 },
126 ],
127 defaultSeriesNames: [],
128 },
129 waterfall: {
130 mode: "waterfall",
131 description: "Use positive and negative amounts to build a running total.",
132 fixedFields: [
133 { key: "category", label: "Category", type: "text" },
134 { key: "amount", label: "Amount", type: "number" },
135 ],
136 supportsSeries: false,
137 supportsZColumn: false,
138 defaultRows: [
139 { category: "Starting", amount: 42000 },
140 { category: "New Sales", amount: 18000 },
141 { category: "Churn", amount: -7000 },
142 { category: "Expansion", amount: 11000 },
143 ],
144 defaultSeriesNames: [],
145 },
146 ohlc: {
147 mode: "ohlc",
148 description: "Use date, open, high, low, and close price values.",
149 fixedFields: [
150 { key: "date", label: "Date", type: "text" },
151 { key: "open", label: "Open", type: "number" },
152 { key: "high", label: "High", type: "number" },
153 { key: "low", label: "Low", type: "number" },
154 { key: "close", label: "Close", type: "number" },
155 ],
156 supportsSeries: false,
157 supportsZColumn: false,
158 defaultRows: [
159 { date: "2024-01-02", open: 100, high: 105, low: 98, close: 103 },
160 { date: "2024-01-03", open: 103, high: 108, low: 101, close: 107 },
161 { date: "2024-01-04", open: 107, high: 112, low: 104, close: 106 },
162 ],
163 defaultSeriesNames: [],
164 },
165 "box-plot": {
166 mode: "box-plot",
167 description: "Use min, quartiles, median, and max for each category.",
168 fixedFields: [
169 { key: "category", label: "Category", type: "text" },
170 { key: "min", label: "Min", type: "number" },
171 { key: "q1", label: "Q1", type: "number" },
172 { key: "median", label: "Median", type: "number" },
173 { key: "q3", label: "Q3", type: "number" },
174 { key: "max", label: "Max", type: "number" },
175 ],
176 supportsSeries: false,
177 supportsZColumn: false,
178 defaultRows: [
179 { category: "Q1 Sales", min: 10, q1: 25, median: 50, q3: 75, max: 95 },
180 { category: "Q2 Sales", min: 15, q1: 30, median: 55, q3: 80, max: 100 },
181 { category: "Q3 Sales", min: 20, q1: 35, median: 60, q3: 85, max: 105 },
182 ],
183 defaultSeriesNames: [],
184 },
185 hierarchical: {
186 mode: "hierarchical",
187 description: "Use an optional parent name to nest rows inside a hierarchy.",
188 fixedFields: [
189 { key: "name", label: "Name", type: "text" },
190 { key: "parent", label: "Parent", type: "text", placeholder: "Optional" },
191 { key: "value", label: "Value", type: "number" },
192 ],
193 supportsSeries: false,
194 supportsZColumn: false,
195 defaultRows: [
196 { name: "Digital Experience", parent: "", value: 186 },
197 { name: "Acquisition", parent: "Digital Experience", value: 78 },
198 { name: "Organic Search", parent: "Acquisition", value: 34 },
199 { name: "Paid Campaigns", parent: "Acquisition", value: 27 },
200 { name: "Referral", parent: "Acquisition", value: 17 },
201 { name: "Engagement", parent: "Digital Experience", value: 64 },
202 { name: "Product Tours", parent: "Engagement", value: 26 },
203 { name: "Templates", parent: "Engagement", value: 22 },
204 { name: "Workspace Sharing", parent: "Engagement", value: 16 },
205 { name: "Retention", parent: "Digital Experience", value: 44 },
206 { name: "Weekly Active Teams", parent: "Retention", value: 25 },
207 { name: "Automations", parent: "Retention", value: 19 },
208 ],
209 defaultSeriesNames: [],
210 },
211 flow: {
212 mode: "flow",
213 description: "Use source, target, and size for each connection.",
214 fixedFields: [
215 { key: "from", label: "From", type: "text" },
216 { key: "to", label: "To", type: "text" },
217 { key: "size", label: "Size", type: "number" },
218 ],
219 supportsSeries: false,
220 supportsZColumn: false,
221 defaultRows: [
222 { from: "Visitors", to: "Leads", size: 80 },
223 { from: "Leads", to: "Trials", size: 42 },
224 { from: "Trials", to: "Customers", size: 18 },
225 ],
226 defaultSeriesNames: [],
227 },
228 funnel: {
229 mode: "funnel",
230 description: "Use a funnel stage label and numeric value.",
231 fixedFields: [
232 { key: "label", label: "Stage", type: "text" },
233 { key: "value", label: "Value", type: "number" },
234 ],
235 supportsSeries: false,
236 supportsZColumn: false,
237 defaultRows: [
238 { label: "Visitors", value: 12000 },
239 { label: "Signups", value: 4200 },
240 { label: "Trials", value: 1800 },
241 { label: "Customers", value: 640 },
242 ],
243 defaultSeriesNames: [],
244 },
245 heatmap: {
246 mode: "heatmap",
247 description: "Use X category, Y category, and value for each cell.",
248 fixedFields: [
249 { key: "x", label: "X", type: "text" },
250 { key: "y", label: "Y", type: "text" },
251 { key: "value", label: "Value", type: "number" },
252 ],
253 supportsSeries: false,
254 supportsZColumn: false,
255 defaultRows: [
256 { x: "Mon", y: "Morning", value: 12 },
257 { x: "Mon", y: "Afternoon", value: 18 },
258 { x: "Tue", y: "Morning", value: 15 },
259 { x: "Tue", y: "Afternoon", value: 24 },
260 ],
261 defaultSeriesNames: [],
262 },
263 histogram: {
264 mode: "histogram",
265 description:
266 "Use bin labels and frequencies. Raw values are still rendered if supplied.",
267 fixedFields: [
268 { key: "label", label: "Bin", type: "text" },
269 { key: "value", label: "Frequency", type: "number" },
270 ],
271 supportsSeries: false,
272 supportsZColumn: false,
273 defaultRows: [
274 { label: "15-25", value: 3 },
275 { label: "25-35", value: 7 },
276 { label: "35-45", value: 15 },
277 { label: "45-55", value: 25 },
278 ],
279 defaultSeriesNames: [],
280 },
281 gauge: {
282 mode: "gauge",
283 description: "Use one value between 0 and 100.",
284 fixedFields: [{ key: "value", label: "Value", type: "number" }],
285 supportsSeries: false,
286 supportsZColumn: false,
287 defaultRows: [{ value: 64 }],
288 defaultSeriesNames: [],
289 },
290 };
291
292 function normalizeChartDataMode(mode: ChartDataMode): ChartDataMode {
293 return mode in CHART_EDITOR_SCHEMAS ? mode : "categorical";
294 }
295
296 export function getSchemaForMode(mode: ChartDataMode): ChartEditorSchema {
297 return CHART_EDITOR_SCHEMAS[normalizeChartDataMode(mode)];
298 }
299
300 function isRecord(value: unknown): value is Record<string, unknown> {
301 return typeof value === "object" && value !== null && !Array.isArray(value);
302 }
303
304 function toCellValue(
305 value: unknown,
306 type: ChartDataFieldType,
307 ): string | number {
308 if (type === "number") {
309 return typeof value === "number" && Number.isFinite(value) ? value : 0;
310 }
311 if (typeof value === "string") return value;
312 if (typeof value === "number") return String(value);
313 return "";
314 }
315
316 function flattenHierarchyRows(
317 rows: readonly Record<string, unknown>[],
318 parent = "",
319 ): ChartDataRow[] {
320 return rows.flatMap((row, index) => {
321 const name =
322 typeof row.name === "string" && row.name.trim().length > 0
323 ? row.name
324 : `Item ${index + 1}`;
325 const value = typeof row.value === "number" ? row.value : 0;
326 const children = Array.isArray(row.children)
327 ? flattenHierarchyRows(row.children.filter(isRecord), name)
328 : [];
329
330 return [{ name, parent, value }, ...children];
331 });
332 }
333
334 function normalizeRows(
335 data: ChartDataType,
336 schema: ChartEditorSchema,
337 ): ChartDataRow[] {
338 if (!Array.isArray(data) || data.length === 0) {
339 return schema.defaultRows.map((row) => ({ ...row }));
340 }
341
342 if (schema.mode === "hierarchical") {
343 return flattenHierarchyRows(data.filter(isRecord));
344 }
345
346 return data.filter(isRecord).map((row) => {
347 const normalizedRow: ChartDataRow = {};
348 for (const field of schema.fixedFields) {
349 normalizedRow[field.key] = toCellValue(row[field.key], field.type);
350 }
351 for (const [key, value] of Object.entries(row)) {
352 if (!(key in normalizedRow)) {
353 normalizedRow[key] =
354 typeof value === "number" && Number.isFinite(value)
355 ? value
356 : typeof value === "string"
357 ? value
358 : "";
359 }
360 }
361 return normalizedRow;
362 });
363 }
364
365 function getNumericKeys(rows: ChartDataRow[], labelKey: string): string[] {
366 const sample = rows[0];
367 if (!sample) return [];
368
369 return Object.keys(sample).filter(
370 (key) => key !== labelKey && typeof sample[key] === "number",
371 );
372 }
373
374 export function getInitialEditorState(
375 data: ChartDataType,
376 mode: ChartDataMode,
377 ): {
378 rows: ChartDataRow[];
379 schema: ChartEditorSchema;
380 labelKey: string;
381 seriesNames: string[];
382 } {
383 const schema = getSchemaForMode(mode);
384 const rows = normalizeRows(data, schema);
385 const labelKey =
386 schema.supportsSeries && rows.length > 0 ? getLabelKey(rows) : "label";
387 const seriesNames = schema.supportsSeries
388 ? getNumericKeys(rows, labelKey)
389 : [];
390
391 return {
392 rows,
393 schema,
394 labelKey,
395 seriesNames:
396 seriesNames.length > 0
397 ? seriesNames
398 : schema.defaultSeriesNames.length > 0
399 ? schema.defaultSeriesNames
400 : [],
401 };
402 }
403
404 export function buildFields(
405 schema: ChartEditorSchema,
406 labelKey: string,
407 seriesNames: string[],
408 hasZColumn: boolean,
409 ): ChartDataField[] {
410 if (schema.mode === "xy" && hasZColumn) {
411 return [
412 ...schema.fixedFields,
413 { key: "z", label: "Z (Size)", type: "number" },
414 ];
415 }
416
417 if (!schema.supportsSeries) {
418 return schema.fixedFields;
419 }
420
421 const labelField = {
422 key: labelKey,
423 label: labelKey === "label" ? "Label" : labelKey,
424 type: "text" as const,
425 };
426
427 return [
428 labelField,
429 ...seriesNames.map((name, index) => ({
430 key: name,
431 label: name,
432 type: "number" as const,
433 editableHeader: true,
434 removable: seriesNames.length > 1,
435 colorIndex: index,
436 })),
437 ];
438 }
439
440 export function createEmptyRow(
441 fields: readonly ChartDataField[],
442 ): ChartDataRow {
443 return fields.reduce<ChartDataRow>((row, field) => {
444 row[field.key] = field.type === "number" ? 0 : "";
445 return row;
446 }, {});
447 }
448
449 export function getDefaultSeriesChartType(index: number): SeriesChartType {
450 return DEFAULT_CHART_TYPES[index % DEFAULT_CHART_TYPES.length] ?? "bar";
451 }
452
453 export function rowsToChartData(rows: ChartDataRow[]): ChartDataType {
454 return rows.map((row) => ({ ...row })) as MultiSeriesData[];
455 }
456
456 lines TYPESCRIPT