| 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 |