返回 presentation-ai
1 "use client";
2
3 import { type InfographicOptions } from "@antv/infographic";
4
5 import {
6 buildInfographicDataFromParsed,
7 parseDataBlock,
8 syncInfographicSyntaxWithData,
9 type DataFieldType,
10 type DataItem,
11 type InfographicRelationEdge,
12 type ParsedDataBlock,
13 } from "@/components/notebook/presentation/editor/utils/infographic-utils";
14 import {
15 type EditableInfographicData,
16 type EditableInfographicItem,
17 type InfographicDataMode,
18 } from "./types";
19
20 const DEFAULT_ITEMS: DataItem[] = [
21 { label: "Discovery", desc: "Understand the need", value: 28 },
22 { label: "Design", desc: "Shape the solution", value: 44 },
23 { label: "Delivery", desc: "Ship and measure", value: 72 },
24 ];
25
26 type DataRecord = Record<string, unknown>;
27
28 let generatedId = 0;
29
30 export function createEditorId(): string {
31 generatedId += 1;
32 return `infographic-item-${Date.now()}-${generatedId}`;
33 }
34
35 function withEditorIds(items: DataItem[]): EditableInfographicItem[] {
36 return items.map((item) => ({
37 ...item,
38 editorId: createEditorId(),
39 children: item.children ? withEditorIds(item.children) : undefined,
40 }));
41 }
42
43 function getStableItemKey(item: DataItem, index: number): string {
44 if (item.id) return `id:${item.id}`;
45 if (item.label) return `label:${item.label}`;
46 return `index:${index}`;
47 }
48
49 function reuseEditorIds(
50 nextItems: DataItem[],
51 previousItems: EditableInfographicItem[],
52 ): EditableInfographicItem[] {
53 const previousByKey = new Map<string, EditableInfographicItem>();
54
55 previousItems.forEach((item, index) => {
56 previousByKey.set(getStableItemKey(item, index), item);
57 });
58
59 return nextItems.map((item, index) => {
60 const previous = previousByKey.get(getStableItemKey(item, index));
61
62 return {
63 ...item,
64 editorId: previous?.editorId ?? createEditorId(),
65 children: item.children
66 ? reuseEditorIds(item.children, previous?.children ?? [])
67 : undefined,
68 };
69 });
70 }
71
72 function withoutEditorIds(items: EditableInfographicItem[]): DataItem[] {
73 return items.map(({ editorId: _editorId, children, ...item }) => ({
74 ...item,
75 children: children ? withoutEditorIds(children) : undefined,
76 }));
77 }
78
79 function getFallbackMode(
80 options?: Partial<InfographicOptions>,
81 ): InfographicDataMode {
82 const data = options?.data;
83 if (!data) return "items";
84 if ("root" in data && data.root) return "root";
85 if ("nodes" in data && Array.isArray(data.nodes)) return "nodes";
86 if ("compares" in data && Array.isArray(data.compares)) return "compares";
87 if ("lists" in data && Array.isArray(data.lists)) return "lists";
88 if ("sequences" in data && Array.isArray(data.sequences)) return "sequences";
89 if ("values" in data && Array.isArray(data.values)) return "values";
90 return "items";
91 }
92
93 function isDataRecord(value: unknown): value is DataRecord {
94 return Boolean(value && typeof value === "object" && !Array.isArray(value));
95 }
96
97 function cloneDataRecord(value: unknown): Record<string, unknown> | undefined {
98 if (!isDataRecord(value)) return undefined;
99
100 return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
101 }
102
103 function cloneDataItem(value: unknown): DataItem | null {
104 if (!isDataRecord(value)) return null;
105
106 const item = JSON.parse(JSON.stringify(value)) as DataItem;
107 return item;
108 }
109
110 function getArrayDataItems(data: DataRecord, field: DataFieldType): DataItem[] {
111 const value = data[field];
112 if (!Array.isArray(value)) return [];
113
114 return value
115 .map(cloneDataItem)
116 .filter((item): item is DataItem => Boolean(item));
117 }
118
119 function getItemsFromOptionsData(
120 data: DataRecord,
121 field: DataFieldType,
122 ): DataItem[] {
123 if (field === "root") {
124 const root = cloneDataItem(data.root);
125 if (root) return [root];
126
127 const items = getArrayDataItems(data, "items");
128 return items.length > 0 ? [items[0]!] : [];
129 }
130
131 const items = getArrayDataItems(data, field);
132 if (items.length > 0) return items;
133
134 return getArrayDataItems(data, "items");
135 }
136
137 function relationEdgeToSyntax(edge: InfographicRelationEdge): string | null {
138 const from = edge.from?.trim();
139 const to = edge.to?.trim();
140 if (!from || !to) return null;
141
142 let connector = "->";
143 if (edge.direction === "both") {
144 connector = "<->";
145 } else if (edge.direction === "none") {
146 connector = "--";
147 }
148
149 if (edge.label?.trim()) {
150 return `${from} - ${edge.label.trim()} ${connector} ${to}`;
151 }
152
153 return `${from} ${connector} ${to}`;
154 }
155
156 function getRelationsFromOptionsData(data: DataRecord): string[] {
157 const relations = data.relations;
158 if (!Array.isArray(relations)) return [];
159
160 return relations
161 .map((relation) => {
162 if (typeof relation === "string") return relation;
163 if (!isDataRecord(relation)) return null;
164
165 return relationEdgeToSyntax(relation as InfographicRelationEdge);
166 })
167 .filter((relation): relation is string => Boolean(relation));
168 }
169
170 function createParsedDataFromOptions(
171 options?: Partial<InfographicOptions>,
172 ): ParsedDataBlock | null {
173 if (!isDataRecord(options?.data)) return null;
174
175 const data = options.data;
176 const sourceField = getFallbackMode(options);
177 const title = typeof data.title === "string" ? data.title : undefined;
178 const desc = typeof data.desc === "string" ? data.desc : undefined;
179 const order = typeof data.order === "string" ? data.order : undefined;
180 const attributes = cloneDataRecord(data.attributes);
181 const items = getItemsFromOptionsData(data, sourceField);
182 const relations = getRelationsFromOptionsData(data);
183
184 return {
185 ...(title ? { title } : {}),
186 ...(desc ? { desc } : {}),
187 ...(order ? { order } : {}),
188 ...(attributes ? { attributes } : {}),
189 items,
190 relations,
191 sourceField,
192 };
193 }
194
195 function getLiveTextMetadata(options?: Partial<InfographicOptions>): {
196 desc?: string;
197 title?: string;
198 } {
199 const data = options?.data;
200 if (!data || typeof data !== "object") return {};
201
202 return {
203 desc:
204 "desc" in data && typeof data.desc === "string" ? data.desc : undefined,
205 title:
206 "title" in data && typeof data.title === "string"
207 ? data.title
208 : undefined,
209 };
210 }
211
212 export function createEditableInfographicData({
213 options,
214 previousData,
215 syntax,
216 }: {
217 options?: Partial<InfographicOptions>;
218 previousData?: EditableInfographicData;
219 syntax: string;
220 }): EditableInfographicData {
221 const parsedFromOptions = createParsedDataFromOptions(options);
222 const syntaxWithLiveData = options?.data
223 ? syncInfographicSyntaxWithData(syntax, options)
224 : syntax;
225 const parsed = parsedFromOptions ?? parseDataBlock(syntaxWithLiveData);
226 const fallbackMode = getFallbackMode(options);
227 const liveTextMetadata = getLiveTextMetadata(options);
228
229 if (!parsed) {
230 const items = previousData
231 ? reuseEditorIds(DEFAULT_ITEMS, previousData.items)
232 : withEditorIds(DEFAULT_ITEMS);
233
234 return {
235 ...liveTextMetadata,
236 items,
237 relations: [],
238 sourceField: fallbackMode,
239 };
240 }
241
242 const items = parsed.items.length > 0 ? parsed.items : DEFAULT_ITEMS;
243
244 return {
245 ...parsed,
246 ...liveTextMetadata,
247 items: previousData
248 ? reuseEditorIds(items, previousData.items)
249 : withEditorIds(items),
250 };
251 }
252
253 export function createBlankInfographicItem(
254 mode: InfographicDataMode,
255 ): EditableInfographicItem {
256 const isRelation = mode === "nodes";
257
258 return {
259 editorId: createEditorId(),
260 label: isRelation ? "New node" : "New item",
261 value: mode === "values" ? 10 : undefined,
262 id: isRelation ? `node-${generatedId + 1}` : undefined,
263 desc: mode === "values" ? undefined : "",
264 };
265 }
266
267 export function toParsedInfographicData(
268 data: EditableInfographicData,
269 ): ParsedDataBlock {
270 return {
271 ...data,
272 items: withoutEditorIds(data.items),
273 };
274 }
275
276 export function toInfographicOptionsData(data: EditableInfographicData) {
277 return buildInfographicDataFromParsed(toParsedInfographicData(data));
278 }
279
280 export function countNestedItems(items: EditableInfographicItem[]): number {
281 let count = 0;
282 for (const item of items) {
283 count += 1;
284 if (item.children) {
285 count += countNestedItems(item.children);
286 }
287 }
288 return count;
289 }
290
291 export function normalizeEditableValue(
292 value: string,
293 ): string | number | undefined {
294 if (!value.trim()) return undefined;
295
296 const numeric = Number(value);
297 return Number.isNaN(numeric) ? value : numeric;
298 }
299
299 lines TYPESCRIPT