返回 presentation-ai
infographic-utils.ts
根目录 / src / components / notebook / presentation / editor / utils / infographic-utils.ts
1 /**
2 * Infographic syntax utilities for template conversion
3 */
4
5 import { type Data, type InfographicOptions } from "@antv/infographic";
6
7 import {
8 INFOGRAPHIC_CATEGORIES,
9 type InfographicDataField,
10 } from "@/constants/antv-templates";
11 import { type ThemeColors } from "@/lib/presentation/themes";
12
13 /**
14 * Parse the current template name from infographic syntax
15 * Format: "infographic <template-id>\n..."
16 */
17 export function parseInfographicTemplate(syntax: string): string | null {
18 if (!syntax?.trim()) return null;
19
20 const lines = syntax.trim().split("\n");
21 for (const line of lines) {
22 const trimmed = line.trim();
23 if (trimmed.startsWith("infographic ")) {
24 return trimmed.slice("infographic ".length).trim();
25 }
26 }
27
28 return null;
29 }
30
31 /**
32 * Replace the template in infographic syntax while preserving everything else
33 * @param syntax The full infographic DSL syntax
34 * @param newTemplate The new template ID to use
35 * @returns Updated syntax with new template
36 */
37 export function changeInfographicTemplate(
38 syntax: string,
39 newTemplate: string,
40 ): string {
41 if (!syntax?.trim()) return syntax;
42
43 const lines = syntax.split("\n");
44 let replaced = false;
45
46 for (let i = 0; i < lines.length; i++) {
47 const line = lines[i] ?? "";
48 const trimmed = line.trim();
49 if (trimmed.startsWith("infographic ")) {
50 const match = line.match(/^\s*/);
51 const prefix = match ? match[0] : "";
52 lines[i] = `${prefix}infographic ${newTemplate}`;
53 replaced = true;
54 break;
55 }
56 }
57
58 if (!replaced) {
59 // If no infographic line, prepend it
60 return `infographic ${newTemplate}\n${syntax}`;
61 }
62
63 return lines.join("\n");
64 }
65
66 /**
67 * Get main category from template ID
68 * e.g., "sequence-steps-simple" -> "sequence"
69 */
70 function getTemplateMainCategory(templateId: string): string {
71 return templateId.split("-")[0] ?? "other";
72 }
73
74 type ThemeBlockRange = {
75 start: number;
76 end: number;
77 };
78
79 type ThemeUpdateOptions = {
80 stylize?: string | null;
81 palette?: string | string[] | null;
82 colorBg?: string;
83 colorPrimary?: string | null;
84 baseTextFill?: string;
85 itemLabelFill?: string;
86 };
87
88 const TOP_LEVEL_KEYWORDS = ["data", "design", "relations"];
89
90 function getIndentSize(line: string): number {
91 const match = line.match(/^\s*/);
92 return match ? match[0].length : 0;
93 }
94
95 function parseScalarValue(value: string): unknown {
96 const trimmed = value.trim();
97 if (/^true$/i.test(trimmed)) return true;
98 if (/^false$/i.test(trimmed)) return false;
99 if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
100 return trimmed;
101 }
102
103 function parseUnknownObjectBlock(
104 lines: string[],
105 startIndex: number,
106 baseIndent: number,
107 ): { endIndex: number; value: Record<string, unknown> } {
108 const value: Record<string, unknown> = {};
109 let i = startIndex;
110
111 while (i < lines.length) {
112 const line = lines[i];
113 if (!line || !line.trim()) {
114 i++;
115 continue;
116 }
117
118 const indent = getIndentSize(line);
119 if (indent <= baseIndent) break;
120
121 const trimmed = line.trim();
122 const [key, ...rest] = trimmed.split(/\s+/);
123 if (!key) {
124 i++;
125 continue;
126 }
127
128 if (rest.length === 0) {
129 const parsed = parseUnknownObjectBlock(lines, i + 1, indent);
130 value[key] = parsed.value;
131 i = parsed.endIndex;
132 continue;
133 }
134
135 value[key] = parseScalarValue(rest.join(" "));
136 i++;
137 }
138
139 return { endIndex: i, value };
140 }
141
142 function serializeUnknownObject(
143 value: Record<string, unknown>,
144 indent: number,
145 ): string[] {
146 const lines: string[] = [];
147 const pad = " ".repeat(indent);
148
149 for (const [key, entry] of Object.entries(value)) {
150 if (entry === undefined || entry === null) continue;
151 if (
152 typeof entry === "object" &&
153 !Array.isArray(entry) &&
154 Object.keys(entry as Record<string, unknown>).length > 0
155 ) {
156 lines.push(`${pad}${key}`);
157 lines.push(
158 ...serializeUnknownObject(entry as Record<string, unknown>, indent + 2),
159 );
160 continue;
161 }
162
163 lines.push(`${pad}${key} ${String(entry)}`);
164 }
165
166 return lines;
167 }
168
169 function isTopLevelBlock(line: string): boolean {
170 const trimmed = line.trim();
171 if (!trimmed) return false;
172 if (line.startsWith(" ") || line.startsWith("\t")) return false;
173 return TOP_LEVEL_KEYWORDS.some((keyword) => trimmed.startsWith(keyword));
174 }
175
176 function findThemeBlockRange(lines: string[]): ThemeBlockRange | null {
177 const start = lines.findIndex((line) => line.trim().startsWith("theme"));
178 if (start === -1) return null;
179
180 let end = lines.length;
181 for (let i = start + 1; i < lines.length; i++) {
182 if (isTopLevelBlock(lines[i]!)) {
183 end = i;
184 break;
185 }
186 }
187
188 return { start, end };
189 }
190
191 function ensureThemeBlock(lines: string[]): ThemeBlockRange {
192 const existing = findThemeBlockRange(lines);
193 if (existing) return existing;
194
195 const infographicIndex = lines.findIndex((line) =>
196 line.trim().startsWith("infographic "),
197 );
198 const insertIndex = infographicIndex >= 0 ? infographicIndex + 1 : 0;
199 lines.splice(insertIndex, 0, "theme", " colorBg transparent");
200 return { start: insertIndex, end: insertIndex + 2 };
201 }
202
203 function updateStylizeLine(
204 lines: string[],
205 themeRange: ThemeBlockRange,
206 stylize: string | null | undefined,
207 ): void {
208 if (stylize === undefined) return;
209
210 const keyPattern = /^\s{2}stylize\b/;
211 const existingIndex = lines
212 .slice(themeRange.start + 1, themeRange.end)
213 .findIndex((line) => keyPattern.test(line));
214
215 if (stylize === null) {
216 // Remove stylize line if it exists
217 if (existingIndex >= 0) {
218 const absoluteIndex = themeRange.start + 1 + existingIndex;
219 lines.splice(absoluteIndex, 1);
220 }
221 return;
222 }
223
224 // Add or update stylize line
225 if (existingIndex >= 0) {
226 const absoluteIndex = themeRange.start + 1 + existingIndex;
227 lines[absoluteIndex] = ` stylize ${stylize}`;
228 return;
229 }
230
231 // Insert after theme line
232 const insertIndex = themeRange.start + 1;
233 lines.splice(insertIndex, 0, ` stylize ${stylize}`);
234 }
235
236 function upsertTopLevelThemeLine(
237 lines: string[],
238 themeRange: ThemeBlockRange,
239 key: string,
240 value: string,
241 ): void {
242 const keyPattern = new RegExp(`^\\s{2}${key}\\b`);
243 const existingIndex = lines
244 .slice(themeRange.start + 1, themeRange.end)
245 .findIndex((line) => keyPattern.test(line));
246
247 if (existingIndex >= 0) {
248 const absoluteIndex = themeRange.start + 1 + existingIndex;
249 lines[absoluteIndex] = ` ${key} ${value}`;
250 return;
251 }
252
253 const insertIndex = themeRange.start + 1;
254 lines.splice(insertIndex, 0, ` ${key} ${value}`);
255 }
256
257 function removeTopLevelThemeLine(
258 lines: string[],
259 themeRange: ThemeBlockRange,
260 key: string,
261 ): void {
262 const keyPattern = new RegExp(`^\\s{2}${key}\\b`);
263 const existingIndex = lines
264 .slice(themeRange.start + 1, themeRange.end)
265 .findIndex((line) => keyPattern.test(line));
266
267 if (existingIndex >= 0) {
268 lines.splice(themeRange.start + 1 + existingIndex, 1);
269 }
270 }
271
272 function removeIndentedBlock(
273 lines: string[],
274 startIndex: number,
275 blockEnd: number,
276 ): void {
277 const baseIndent = getIndentSize(lines[startIndex]!);
278 let endIndex = startIndex + 1;
279 for (; endIndex < blockEnd; endIndex++) {
280 const line = lines[endIndex]!;
281 if (!line.trim()) continue;
282 const indent = getIndentSize(line);
283 if (indent <= baseIndent) break;
284 }
285 lines.splice(startIndex, endIndex - startIndex);
286 }
287
288 function upsertPaletteBlock(
289 lines: string[],
290 themeRange: ThemeBlockRange,
291 palette: string | string[] | null | undefined,
292 ): void {
293 if (palette === undefined) return;
294
295 const keyPattern = /^\s{2}palette\b/;
296 const paletteIndex = lines
297 .slice(themeRange.start + 1, themeRange.end)
298 .findIndex((line) => keyPattern.test(line));
299
300 if (paletteIndex >= 0) {
301 const absoluteIndex = themeRange.start + 1 + paletteIndex;
302 removeIndentedBlock(lines, absoluteIndex, themeRange.end);
303 }
304
305 if (palette === null) return;
306
307 const currentThemeRange = findThemeBlockRange(lines) ?? themeRange;
308 const colorBgIndex = lines
309 .slice(currentThemeRange.start + 1, currentThemeRange.end)
310 .findIndex((line) => /^\s{2}colorBg\b/.test(line));
311
312 const insertIndex =
313 colorBgIndex >= 0
314 ? currentThemeRange.start + 2 + colorBgIndex
315 : currentThemeRange.start + 1;
316
317 if (Array.isArray(palette)) {
318 const paletteLines = [" palette", ...palette.map((c) => ` - ${c}`)];
319 lines.splice(insertIndex, 0, ...paletteLines);
320 return;
321 }
322
323 lines.splice(insertIndex, 0, ` palette ${palette}`);
324 }
325
326 function findBlockEnd(
327 lines: string[],
328 blockStart: number,
329 blockEnd: number,
330 ): number {
331 const baseIndent = getIndentSize(lines[blockStart]!);
332 for (let i = blockStart + 1; i < blockEnd; i++) {
333 const line = lines[i]!;
334 if (!line.trim()) continue;
335 if (getIndentSize(line) <= baseIndent) return i;
336 }
337 return blockEnd;
338 }
339
340 function findChildBlockIndex(
341 lines: string[],
342 blockStart: number,
343 blockEnd: number,
344 childKey: string,
345 ): number {
346 const childIndent = getIndentSize(lines[blockStart]!) + 2;
347 for (let i = blockStart + 1; i < blockEnd; i++) {
348 const line = lines[i]!;
349 if (!line.trim()) continue;
350 if (getIndentSize(line) < childIndent) break;
351 if (
352 getIndentSize(line) === childIndent &&
353 line.trim().startsWith(childKey)
354 ) {
355 return i;
356 }
357 }
358 return -1;
359 }
360
361 function upsertNestedThemeValue(
362 lines: string[],
363 themeRange: ThemeBlockRange,
364 path: string[],
365 key: string,
366 value: string,
367 ): void {
368 let currentStart = themeRange.start;
369 let currentEnd = themeRange.end;
370
371 for (const segment of path) {
372 const childIndex = findChildBlockIndex(
373 lines,
374 currentStart,
375 currentEnd,
376 segment,
377 );
378 if (childIndex === -1) {
379 const insertAt = findBlockEnd(lines, currentStart, currentEnd);
380 lines.splice(
381 insertAt,
382 0,
383 `${" ".repeat(getIndentSize(lines[currentStart]!) + 2)}${segment}`,
384 );
385 currentStart = insertAt;
386 currentEnd = findThemeBlockRange(lines)?.end ?? lines.length;
387 } else {
388 currentStart = childIndex;
389 }
390
391 currentEnd = findBlockEnd(lines, currentStart, currentEnd);
392 }
393
394 const valueIndent = getIndentSize(lines[currentStart]!) + 2;
395 const keyPattern = new RegExp(`^\\s{${valueIndent}}${key}\\b`);
396 let existingIndex = -1;
397
398 for (let i = currentStart + 1; i < currentEnd; i++) {
399 const line = lines[i]!;
400 if (!line.trim()) continue;
401 const indent = getIndentSize(line);
402 if (indent < valueIndent) break;
403 if (indent === valueIndent && keyPattern.test(line)) {
404 existingIndex = i;
405 break;
406 }
407 }
408
409 if (existingIndex >= 0) {
410 lines[existingIndex] = `${" ".repeat(valueIndent)}${key} ${value}`;
411 return;
412 }
413
414 lines.splice(currentEnd, 0, `${" ".repeat(valueIndent)}${key} ${value}`);
415 }
416
417 export function updateInfographicTheme(
418 syntax: string,
419 options: ThemeUpdateOptions,
420 ): string {
421 if (!syntax?.trim()) return syntax;
422
423 const lines = syntax.split("\n");
424 let themeRange = ensureThemeBlock(lines);
425
426 // Ensure theme line is just "theme" (no theme name)
427 lines[themeRange.start] = "theme";
428
429 updateStylizeLine(lines, themeRange, options.stylize);
430 themeRange = findThemeBlockRange(lines) ?? themeRange;
431
432 if (options.colorBg) {
433 upsertTopLevelThemeLine(lines, themeRange, "colorBg", options.colorBg);
434 themeRange = findThemeBlockRange(lines) ?? themeRange;
435 }
436
437 if (options.colorPrimary !== undefined) {
438 if (options.colorPrimary === null) {
439 removeTopLevelThemeLine(lines, themeRange, "colorPrimary");
440 themeRange = findThemeBlockRange(lines) ?? themeRange;
441 } else {
442 upsertTopLevelThemeLine(
443 lines,
444 themeRange,
445 "colorPrimary",
446 options.colorPrimary,
447 );
448 themeRange = findThemeBlockRange(lines) ?? themeRange;
449 }
450 }
451
452 upsertPaletteBlock(lines, themeRange, options.palette);
453 themeRange = findThemeBlockRange(lines) ?? themeRange;
454
455 if (options.baseTextFill) {
456 upsertNestedThemeValue(
457 lines,
458 themeRange,
459 ["base", "text"],
460 "fill",
461 options.baseTextFill,
462 );
463 themeRange = findThemeBlockRange(lines) ?? themeRange;
464 }
465
466 if (options.itemLabelFill) {
467 upsertNestedThemeValue(
468 lines,
469 themeRange,
470 ["item", "label"],
471 "fill",
472 options.itemLabelFill,
473 );
474 }
475
476 return lines.join("\n");
477 }
478
479 export function parseInfographicStylize(syntax: string): string | null {
480 if (!syntax?.trim()) return null;
481 const lines = syntax.split("\n");
482 const themeRange = findThemeBlockRange(lines);
483 if (!themeRange) return null;
484
485 // Search for stylize line in theme block (new format)
486 for (let i = themeRange.start + 1; i < themeRange.end; i++) {
487 const line = lines[i];
488 if (!line || !line.trim()) continue;
489 if (/^\s{2}stylize\b/.test(line)) {
490 const parts = line.trim().split(/\s+/);
491 return parts.length > 1 ? parts.slice(1).join(" ") : null;
492 }
493 }
494
495 // Also check for legacy "theme hand-drawn" format for backwards compatibility
496 const themeLine = lines[themeRange.start]?.trim() ?? "";
497 if (themeLine === "theme hand-drawn") {
498 return "rough";
499 }
500
501 return null;
502 }
503
504 export function parseInfographicPalette(
505 syntax: string,
506 ): string | string[] | null {
507 if (!syntax?.trim()) return null;
508 const lines = syntax.split("\n");
509 const themeRange = findThemeBlockRange(lines);
510 if (!themeRange) return null;
511
512 for (let i = themeRange.start + 1; i < themeRange.end; i++) {
513 const line = lines[i]!;
514 if (!line.trim()) continue;
515 if (/^\s{2}palette\b/.test(line)) {
516 const parts = line.trim().split(/\s+/);
517 if (parts.length > 1) {
518 return parts.slice(1).join(" ");
519 }
520
521 const paletteItems: string[] = [];
522 const baseIndent = getIndentSize(line);
523 for (let j = i + 1; j < themeRange.end; j++) {
524 const nextLine = lines[j]!;
525 if (!nextLine.trim()) continue;
526 const indent = getIndentSize(nextLine);
527 if (indent <= baseIndent) break;
528 const trimmed = nextLine.trim();
529 if (trimmed.startsWith("-")) {
530 paletteItems.push(trimmed.replace(/^-\s*/, "").trim());
531 }
532 }
533
534 return paletteItems.length > 0 ? paletteItems : null;
535 }
536 }
537
538 return null;
539 }
540
541 /**
542 * Force-replaces or inserts theme block right after the infographic line
543 * Expected format:
544 * infographic <template>
545 * theme [dark]
546 * colorBg transparent
547 * base
548 * text
549 * color <css-var>
550 * item
551 * label
552 * fill <css-var>
553 * data
554 * ...
555 */
556 export type InfographicPaletteThemeColors = Pick<
557 ThemeColors,
558 "primary" | "accent" | "smartLayout" | "text" | "heading" | "cardBackground"
559 >;
560
561 type RgbColor = {
562 r: number;
563 g: number;
564 b: number;
565 };
566
567 function normalizeHexColor(color: string | undefined): string | null {
568 if (!color) return null;
569
570 const trimmed = color.trim();
571 const shorthand = trimmed.match(/^#([0-9a-fA-F]{3})$/);
572 if (shorthand) {
573 const [r, g, b] = shorthand[1]!.split("");
574 return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
575 }
576
577 if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
578 return trimmed.toUpperCase();
579 }
580
581 return null;
582 }
583
584 function hexToRgb(color: string): RgbColor {
585 return {
586 r: Number.parseInt(color.slice(1, 3), 16),
587 g: Number.parseInt(color.slice(3, 5), 16),
588 b: Number.parseInt(color.slice(5, 7), 16),
589 };
590 }
591
592 function rgbToHex({ r, g, b }: RgbColor): string {
593 const toHex = (value: number) =>
594 Math.round(Math.min(255, Math.max(0, value)))
595 .toString(16)
596 .padStart(2, "0");
597
598 return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
599 }
600
601 function mixHexColors(from: string, to: string, ratio: number): string {
602 const start = hexToRgb(from);
603 const end = hexToRgb(to);
604
605 return rgbToHex({
606 r: start.r + (end.r - start.r) * ratio,
607 g: start.g + (end.g - start.g) * ratio,
608 b: start.b + (end.b - start.b) * ratio,
609 });
610 }
611
612 function uniqueColors(colors: string[]): string[] {
613 return Array.from(new Set(colors));
614 }
615
616 function buildGradientPalette(
617 themeColors: InfographicPaletteThemeColors | null | undefined,
618 isDark: boolean,
619 ): string[] {
620 const primary =
621 normalizeHexColor(themeColors?.primary) ?? (isDark ? "#60A5FA" : "#2563EB");
622 const secondary = normalizeHexColor(themeColors?.accent) ?? primary;
623 const smartLayout = normalizeHexColor(themeColors?.smartLayout) ?? secondary;
624
625 return uniqueColors([
626 primary,
627 mixHexColors(primary, secondary, 0.5),
628 secondary,
629 mixHexColors(secondary, smartLayout, 0.5),
630 smartLayout,
631 ]);
632 }
633
634 function getExplicitPalettePrimary(palette: unknown): string | null {
635 if (Array.isArray(palette)) {
636 const firstColor = palette.find(
637 (color): color is string => typeof color === "string",
638 );
639 return normalizeHexColor(firstColor);
640 }
641
642 if (typeof palette === "string") {
643 return normalizeHexColor(palette);
644 }
645
646 return null;
647 }
648
649 export function applyThemeToSyntax(
650 syntax: string,
651 isDark: boolean,
652 themeColors?: InfographicPaletteThemeColors | null,
653 ): string {
654 if (!syntax?.trim()) return syntax;
655
656 const colors = getInfographicThemeColors(isDark, themeColors);
657 const explicitPalette = parseInfographicPalette(syntax);
658 const explicitPalettePrimary = getExplicitPalettePrimary(explicitPalette);
659
660 return updateInfographicTheme(syntax, {
661 colorBg: colors.colorBg,
662 colorPrimary:
663 explicitPalette === null ? colors.colorPrimary : explicitPalettePrimary,
664 palette: explicitPalette === null ? colors.palette : undefined,
665 baseTextFill: colors.baseTextFill,
666 itemLabelFill: colors.itemLabelFill,
667 });
668 }
669
670 export function applyColorModeToSyntax(
671 syntax: string,
672 isDark: boolean,
673 ): string {
674 if (!syntax?.trim()) return syntax;
675
676 const colors = getInfographicThemeColors(isDark, null);
677
678 return updateInfographicTheme(syntax, {
679 colorBg: colors.colorBg,
680 baseTextFill: colors.baseTextFill,
681 itemLabelFill: colors.itemLabelFill,
682 });
683 }
684
685 export function getInfographicThemeColors(
686 isDark: boolean,
687 themeColors?: InfographicPaletteThemeColors | null,
688 ) {
689 const colorPrimary =
690 normalizeHexColor(themeColors?.primary) ?? (isDark ? "#60A5FA" : "#2563EB");
691 const baseTextFill =
692 normalizeHexColor(themeColors?.text) ??
693 normalizeHexColor(themeColors?.heading) ??
694 (isDark ? "#FFFFFF" : "#000000");
695 const itemLabelFill =
696 normalizeHexColor(themeColors?.heading) ??
697 normalizeHexColor(themeColors?.text) ??
698 (isDark ? "#E5E5E5" : "#404040");
699
700 return {
701 colorBg: "transparent",
702 colorPrimary,
703 palette: buildGradientPalette(themeColors, isDark),
704 baseTextFill,
705 itemLabelFill,
706 };
707 }
708 export function applyThemeToData(
709 data: Partial<InfographicOptions>,
710 isDark: boolean,
711 themeColors?: InfographicPaletteThemeColors | null,
712 ): Partial<InfographicOptions> {
713 if (!data || typeof data !== "object") return data;
714
715 const colors = getInfographicThemeColors(isDark, themeColors);
716
717 // Create a deep clone to avoid mutating the original object
718 const newData = JSON.parse(JSON.stringify(data)) as InfographicOptions;
719
720 // Ensure themeConfig structure exists
721 if (!newData.themeConfig) {
722 newData.themeConfig = {};
723 }
724
725 const themeConfig = newData.themeConfig;
726
727 if (!themeConfig.base) {
728 themeConfig.base = {};
729 }
730 if (!themeConfig.base.text) {
731 themeConfig.base.text = {};
732 }
733 if (!themeConfig.item) {
734 themeConfig.item = {};
735 }
736 if (!themeConfig.item.label) {
737 themeConfig.item.label = {};
738 }
739
740 // Apply colors
741 const existingPalette = themeConfig.palette;
742 const explicitPalettePrimary = getExplicitPalettePrimary(existingPalette);
743 themeConfig.colorBg = colors.colorBg;
744 if (!existingPalette) {
745 themeConfig.colorPrimary = colors.colorPrimary;
746 themeConfig.palette = colors.palette;
747 } else if (!themeConfig.colorPrimary && explicitPalettePrimary) {
748 themeConfig.colorPrimary = explicitPalettePrimary;
749 }
750 themeConfig.base!.text!.fill = colors.baseTextFill;
751 themeConfig.item!.label!.fill = colors.itemLabelFill;
752
753 return newData as unknown as Partial<InfographicOptions>;
754 }
755
756 export function applyColorModeToData(
757 data: Partial<InfographicOptions>,
758 isDark: boolean,
759 ): Partial<InfographicOptions> {
760 if (!data || typeof data !== "object") return data;
761
762 const colors = getInfographicThemeColors(isDark, null);
763 const newData = JSON.parse(JSON.stringify(data)) as InfographicOptions;
764
765 if (!newData.themeConfig) {
766 newData.themeConfig = {};
767 }
768
769 const themeConfig = newData.themeConfig;
770
771 if (!themeConfig.base) {
772 themeConfig.base = {};
773 }
774 if (!themeConfig.base.text) {
775 themeConfig.base.text = {};
776 }
777 if (!themeConfig.item) {
778 themeConfig.item = {};
779 }
780 if (!themeConfig.item.label) {
781 themeConfig.item.label = {};
782 }
783
784 themeConfig.colorBg = colors.colorBg;
785 themeConfig.base!.text!.fill = colors.baseTextFill;
786 themeConfig.item!.label!.fill = colors.itemLabelFill;
787
788 return newData as unknown as Partial<InfographicOptions>;
789 }
790
791 // ============================================================================
792 // DATA CONVERSION UTILITIES
793 // ============================================================================
794
795 /**
796 * Data field types supported by infographic templates
797 */
798 export type DataFieldType = InfographicDataField;
799
800 /**
801 * Base data item structure (common fields across all types)
802 */
803 export interface DataItem {
804 label?: string;
805 desc?: string;
806 value?: number | string;
807 icon?: string;
808 id?: string;
809 group?: string;
810 category?: string;
811 children?: DataItem[];
812 attributes?: Record<string, unknown>;
813 }
814
815 /**
816 * Parsed data block structure
817 */
818 export interface ParsedDataBlock {
819 title?: string;
820 desc?: string;
821 order?: string;
822 items: DataItem[];
823 relations?: string[];
824 sourceField: DataFieldType;
825 attributes?: Record<string, unknown>;
826 }
827
828 /**
829 * Map template category to expected data field
830 */
831 function getExpectedDataField(categoryKey: string): DataFieldType {
832 return (
833 INFOGRAPHIC_CATEGORIES.find((category) => category.key === categoryKey)
834 ?.dataField ?? "items"
835 );
836 }
837
838 /**
839 * Detect which data field is used in the syntax
840 */
841 function detectDataField(dataBlockContent: string): DataFieldType {
842 const fieldPatterns: Array<[DataFieldType, RegExp]> = [
843 ["lists", /^\s{2}lists\s*$/m],
844 ["sequences", /^\s{2}sequences\s*$/m],
845 ["values", /^\s{2}values\s*$/m],
846 ["compares", /^\s{2}compares\s*$/m],
847 ["root", /^\s{2}root\s*$/m],
848 ["nodes", /^\s{2}nodes\s*$/m],
849 ["items", /^\s{2}items\s*$/m],
850 ];
851
852 for (const [field, pattern] of fieldPatterns) {
853 if (pattern.test(dataBlockContent)) {
854 return field;
855 }
856 }
857
858 return "items";
859 }
860
861 /**
862 * Parse a data item from indented lines
863 */
864 function parseDataItem(
865 lines: string[],
866 startIndex: number,
867 ): { item: DataItem; endIndex: number } {
868 const item: DataItem = {};
869 let i = startIndex;
870 const baseIndent = lines[i]!.search(/\S/);
871
872 // First line should start with "- "
873 const firstLine = lines[i]!.trim();
874 if (firstLine.startsWith("- ")) {
875 const afterDash = firstLine.slice(2).trim();
876 // Check if it's "- label Value" format
877 if (afterDash.startsWith("label ")) {
878 item.label = afterDash.slice(6).trim();
879 } else if (afterDash.startsWith("id ")) {
880 item.id = afterDash.slice(3).trim();
881 } else if (afterDash) {
882 item.label = afterDash;
883 }
884 }
885 i++;
886
887 // Parse subsequent property lines
888 while (i < lines.length) {
889 const line = lines[i];
890 if (!line || line.trim() === "") {
891 i++;
892 continue;
893 }
894
895 const currentIndent = line.search(/\S/);
896
897 // If we hit a line with same or less indent that starts with "-", we're done with this item
898 if (currentIndent <= baseIndent && line.trim().startsWith("-")) {
899 break;
900 }
901
902 // If we hit a top-level keyword, we're done
903 if (currentIndent === 0 || currentIndent < baseIndent) {
904 break;
905 }
906
907 const trimmed = line.trim();
908
909 // Parse property lines
910 if (trimmed.startsWith("label ")) {
911 item.label = trimmed.slice(6).trim();
912 } else if (trimmed.startsWith("desc ")) {
913 item.desc = trimmed.slice(5).trim();
914 } else if (trimmed.startsWith("value ")) {
915 const val = trimmed.slice(6).trim();
916 item.value = Number.isNaN(Number(val)) ? val : Number(val);
917 } else if (trimmed.startsWith("icon ")) {
918 item.icon = trimmed.slice(5).trim();
919 } else if (trimmed.startsWith("id ")) {
920 item.id = trimmed.slice(3).trim();
921 } else if (trimmed.startsWith("group ")) {
922 item.group = trimmed.slice(6).trim();
923 } else if (trimmed.startsWith("category ")) {
924 item.category = trimmed.slice(9).trim();
925 } else if (trimmed === "attributes") {
926 const parsed = parseUnknownObjectBlock(lines, i + 1, currentIndent);
927 item.attributes = parsed.value;
928 i = parsed.endIndex;
929 continue;
930 } else if (trimmed === "children") {
931 // Parse children recursively
932 i++;
933 const children: DataItem[] = [];
934 while (i < lines.length) {
935 const childLine = lines[i];
936 if (!childLine || childLine.trim() === "") {
937 i++;
938 continue;
939 }
940 const childIndent = childLine.search(/\S/);
941 if (childIndent <= currentIndent) break;
942
943 if (childLine.trim().startsWith("-")) {
944 const { item: childItem, endIndex } = parseDataItem(lines, i);
945 children.push(childItem);
946 i = endIndex;
947 } else {
948 i++;
949 }
950 }
951 item.children = children;
952 continue;
953 }
954
955 i++;
956 }
957
958 return { item, endIndex: i };
959 }
960
961 /**
962 * Parse the root node for hierarchy data
963 */
964 function parseRootNode(
965 lines: string[],
966 startIndex: number,
967 ): { item: DataItem; endIndex: number } {
968 const item: DataItem = {};
969 let i = startIndex;
970
971 while (i < lines.length) {
972 const line = lines[i];
973 if (!line || line.trim() === "") {
974 i++;
975 continue;
976 }
977
978 const currentIndent = line.search(/\S/);
979
980 // If we hit a top-level keyword (indent 0), we're done
981 if (
982 currentIndent === 0 &&
983 !line.trim().startsWith("label") &&
984 !line.trim().startsWith("children")
985 ) {
986 break;
987 }
988
989 const trimmed = line.trim();
990
991 if (trimmed.startsWith("label ")) {
992 item.label = trimmed.slice(6).trim();
993 } else if (trimmed.startsWith("desc ")) {
994 item.desc = trimmed.slice(5).trim();
995 } else if (trimmed === "attributes") {
996 const parsed = parseUnknownObjectBlock(lines, i + 1, currentIndent);
997 item.attributes = parsed.value;
998 i = parsed.endIndex;
999 continue;
1000 } else if (trimmed === "children") {
1001 i++;
1002 const children: DataItem[] = [];
1003 while (i < lines.length) {
1004 const childLine = lines[i];
1005 if (!childLine || childLine.trim() === "") {
1006 i++;
1007 continue;
1008 }
1009 const childIndent = childLine.search(/\S/);
1010 // If indent is 0 or 2 (same as "children"), we're done with children
1011 if (childIndent <= 4 && !childLine.trim().startsWith("-")) break;
1012
1013 if (childLine.trim().startsWith("-")) {
1014 const { item: childItem, endIndex } = parseDataItem(lines, i);
1015 children.push(childItem);
1016 i = endIndex;
1017 } else {
1018 i++;
1019 }
1020 }
1021 item.children = children;
1022 continue;
1023 }
1024
1025 i++;
1026 }
1027
1028 return { item, endIndex: i };
1029 }
1030
1031 /**
1032 * Parse the data block from infographic syntax
1033 */
1034 export function parseDataBlock(syntax: string): ParsedDataBlock | null {
1035 if (!syntax?.trim()) return null;
1036
1037 // Find the data block
1038 const dataMatch = syntax.match(/^data\s*$/m);
1039 if (!dataMatch) return null;
1040
1041 const dataStartIndex = dataMatch.index!;
1042
1043 // Find the end of the data block (next top-level keyword or end of string)
1044 const afterData = syntax.slice(dataStartIndex);
1045 const nextBlockMatch = afterData.match(/\n(?=theme\s|design\s|relations\s)/);
1046 const dataBlockContent = nextBlockMatch
1047 ? afterData.slice(0, nextBlockMatch.index)
1048 : afterData;
1049
1050 const lines = dataBlockContent.split("\n");
1051 const result: ParsedDataBlock = {
1052 items: [],
1053 relations: [],
1054 sourceField: detectDataField(dataBlockContent),
1055 };
1056
1057 let i = 0;
1058 while (i < lines.length) {
1059 const line = lines[i];
1060 if (!line) {
1061 i++;
1062 continue;
1063 }
1064
1065 const trimmed = line.trim();
1066
1067 // Parse top-level properties
1068 if (trimmed.startsWith("title ")) {
1069 result.title = trimmed.slice(6).trim();
1070 } else if (trimmed.startsWith("desc ")) {
1071 result.desc = trimmed.slice(5).trim();
1072 } else if (trimmed.startsWith("order ")) {
1073 result.order = trimmed.slice(6).trim();
1074 } else if (trimmed === "attributes") {
1075 const parsed = parseUnknownObjectBlock(lines, i + 1, getIndentSize(line));
1076 result.attributes = parsed.value;
1077 i = parsed.endIndex;
1078 continue;
1079 } else if (
1080 trimmed === "lists" ||
1081 trimmed === "sequences" ||
1082 trimmed === "values" ||
1083 trimmed === "compares" ||
1084 trimmed === "nodes" ||
1085 trimmed === "items"
1086 ) {
1087 // Parse items
1088 i++;
1089 while (i < lines.length) {
1090 const itemLine = lines[i];
1091 if (!itemLine || itemLine.trim() === "") {
1092 i++;
1093 continue;
1094 }
1095
1096 // Check if we've hit a new top-level block
1097 const indent = itemLine.search(/\S/);
1098 if (indent === 0 || indent === 2) {
1099 // Could be a new field like "relations" or "order"
1100 if (!itemLine.trim().startsWith("-")) break;
1101 }
1102
1103 if (itemLine.trim().startsWith("-")) {
1104 const { item, endIndex } = parseDataItem(lines, i);
1105 if (item.label || item.value !== undefined || item.id) {
1106 result.items.push(item);
1107 }
1108 i = endIndex;
1109 } else {
1110 i++;
1111 }
1112 }
1113 continue;
1114 } else if (trimmed === "relations") {
1115 // Parse relations under data block
1116 i++;
1117 while (i < lines.length) {
1118 const relLine = lines[i];
1119 if (!relLine || relLine.trim() === "") {
1120 i++;
1121 continue;
1122 }
1123
1124 const indent = relLine.search(/\S/);
1125 if (indent === 0 || indent === 2) {
1126 if (!relLine.trim().startsWith("-")) break;
1127 }
1128
1129 if (relLine.trim().startsWith("-")) {
1130 const relation = relLine.trim().slice(1).trim();
1131 if (relation) {
1132 result.relations!.push(relation);
1133 }
1134 i++;
1135 } else {
1136 i++;
1137 }
1138 }
1139 continue;
1140 } else if (trimmed === "root") {
1141 // Parse hierarchy root
1142 i++;
1143 const { item, endIndex } = parseRootNode(lines, i);
1144 if (item.label || item.children) {
1145 result.items = [item];
1146 }
1147 i = endIndex;
1148 continue;
1149 }
1150
1151 i++;
1152 }
1153
1154 // Parse relations block separately if it exists
1155 const relationsMatch = syntax.match(/^relations\s*$/m);
1156 if (relationsMatch) {
1157 const relationsStartIndex = relationsMatch.index!;
1158 const afterRelations = syntax.slice(relationsStartIndex);
1159 const relationsLines = afterRelations.split("\n").slice(1);
1160
1161 for (const line of relationsLines) {
1162 if (!line.trim()) continue;
1163 if (line.search(/\S/) === 0) break; // Hit next top-level block
1164
1165 const trimmed = line.trim();
1166 // Capture relation lines like "A -> B" or "A - label -> B"
1167 if (
1168 trimmed.includes("->") ||
1169 trimmed.includes("<-") ||
1170 trimmed.includes("--")
1171 ) {
1172 result.relations!.push(trimmed);
1173 }
1174 }
1175 }
1176
1177 return result;
1178 }
1179
1180 /**
1181 * Flatten hierarchy items to a flat list (breadth-first, no path formatting)
1182 */
1183 function flattenHierarchy(items: DataItem[]): DataItem[] {
1184 const result: DataItem[] = [];
1185 const queue: DataItem[] = [...items];
1186
1187 while (queue.length > 0) {
1188 const node = queue.shift()!;
1189 const { children, ...rest } = node;
1190 if (rest.label || rest.value !== undefined || rest.id) {
1191 result.push(rest);
1192 }
1193 if (children?.length) {
1194 queue.push(...children);
1195 }
1196 }
1197
1198 return result;
1199 }
1200
1201 function hasNestedChildren(items: DataItem[]): boolean {
1202 for (const item of items) {
1203 if (item.children && item.children.length > 0) return true;
1204 if (item.children && hasNestedChildren(item.children)) return true;
1205 }
1206 return false;
1207 }
1208
1209 /**
1210 * Convert flat list items to compare-binary format
1211 * Splits items into 2 groups, each group becomes children of a parent item
1212 */
1213 function convertToCompareBinary(items: DataItem[]): DataItem[] {
1214 // Flatten any nested structure first
1215 const flat = flattenHierarchy(items);
1216 if (flat.length === 0) return [];
1217
1218 // Split into two halves
1219 const mid = Math.ceil(flat.length / 2);
1220 const firstHalf = flat.slice(0, mid);
1221 const secondHalf = flat.slice(mid);
1222
1223 // Create two parent items with children (no desc/icon on children, just label)
1224 const groupA: DataItem = {
1225 label: "Group A",
1226 value: firstHalf.length,
1227 children: firstHalf.map((item) => ({ label: item.label ?? "Item" })),
1228 };
1229
1230 const groupB: DataItem = {
1231 label: "Group B",
1232 value: secondHalf.length,
1233 children: secondHalf.map((item) => ({ label: item.label ?? "Item" })),
1234 };
1235
1236 return [groupA, groupB];
1237 }
1238
1239 /**
1240 * Flatten compare items (with children) back to a flat list
1241 * Used when converting FROM compare templates TO other templates
1242 * Only extracts the children, ignores parent group items
1243 */
1244 function flattenCompareItems(items: DataItem[]): DataItem[] {
1245 const result: DataItem[] = [];
1246
1247 for (const item of items) {
1248 // Only extract children, ignore the parent item (Group A, Group B)
1249 if (item.children && item.children.length > 0) {
1250 for (const child of item.children) {
1251 const { children: _, ...rest } = child;
1252 result.push(rest);
1253 }
1254 }
1255 // If item has no children, it's probably already a flat item from a different source
1256 // In that case, only include it if it looks like actual content (not a Group label)
1257 else if (item.label && !item.label.startsWith("Group ")) {
1258 const { children: _, ...rest } = item;
1259 result.push(rest);
1260 }
1261 }
1262
1263 return result;
1264 }
1265
1266 /**
1267 * Convert flat items to hierarchy structure (first item becomes root, rest become children)
1268 */
1269 function convertToHierarchy(items: DataItem[]): DataItem[] {
1270 if (items.length === 0) return [];
1271
1272 const [first, ...rest] = items;
1273 return [
1274 {
1275 ...first,
1276 children: rest.map(({ children: _, ...item }) => item),
1277 },
1278 ];
1279 }
1280
1281 /**
1282 * Serialize a single data item to syntax lines
1283 */
1284 function serializeDataItem(item: DataItem, indent: number = 4): string[] {
1285 const pad = " ".repeat(indent);
1286 const lines: string[] = [];
1287
1288 lines.push(`${pad}- label ${item.label ?? "Item"}`);
1289
1290 if (item.value !== undefined) {
1291 lines.push(`${pad} value ${item.value}`);
1292 }
1293 if (item.desc) {
1294 lines.push(`${pad} desc ${item.desc}`);
1295 }
1296 if (item.icon) {
1297 lines.push(`${pad} icon ${item.icon}`);
1298 }
1299 if (item.id) {
1300 lines.push(`${pad} id ${item.id}`);
1301 }
1302 if (item.group) {
1303 lines.push(`${pad} group ${item.group}`);
1304 }
1305 if (item.category) {
1306 lines.push(`${pad} category ${item.category}`);
1307 }
1308 if (item.attributes && Object.keys(item.attributes).length > 0) {
1309 lines.push(`${pad} attributes`);
1310 lines.push(...serializeUnknownObject(item.attributes, indent + 4));
1311 }
1312 if (item.children && item.children.length > 0) {
1313 lines.push(`${pad} children`);
1314 for (const child of item.children) {
1315 lines.push(...serializeDataItem(child, indent + 4));
1316 }
1317 }
1318
1319 return lines;
1320 }
1321
1322 /**
1323 * Serialize hierarchy root structure
1324 */
1325 function serializeRootNode(item: DataItem): string[] {
1326 const lines: string[] = [];
1327
1328 lines.push(" root");
1329 if (item.label) {
1330 lines.push(` label ${item.label}`);
1331 }
1332 if (item.desc) {
1333 lines.push(` desc ${item.desc}`);
1334 }
1335 if (item.attributes && Object.keys(item.attributes).length > 0) {
1336 lines.push(" attributes");
1337 lines.push(...serializeUnknownObject(item.attributes, 6));
1338 }
1339 if (item.children && item.children.length > 0) {
1340 lines.push(" children");
1341 for (const child of item.children) {
1342 lines.push(...serializeDataItem(child, 6));
1343 }
1344 }
1345
1346 return lines;
1347 }
1348
1349 /**
1350 * Build a new data block from parsed data and target field type
1351 */
1352 function buildDataBlock(
1353 parsed: ParsedDataBlock,
1354 targetField: DataFieldType,
1355 ): string {
1356 const lines: string[] = ["data"];
1357
1358 if (parsed.title) {
1359 lines.push(` title ${parsed.title}`);
1360 }
1361 if (parsed.desc) {
1362 lines.push(` desc ${parsed.desc}`);
1363 }
1364 if (parsed.attributes && Object.keys(parsed.attributes).length > 0) {
1365 lines.push(" attributes");
1366 lines.push(...serializeUnknownObject(parsed.attributes, 4));
1367 }
1368
1369 let items = parsed.items;
1370
1371 // Convert items based on target field
1372 if (targetField === "root") {
1373 // Convert to hierarchy
1374 if (parsed.sourceField !== "root") {
1375 items = convertToHierarchy(items);
1376 }
1377 if (items.length > 0) {
1378 lines.push(...serializeRootNode(items[0]!));
1379 }
1380 } else {
1381 // For all other types, flatten if coming from hierarchy or nested children exist
1382 // But skip flattening for compares field to preserve children structure
1383 if (
1384 targetField !== "compares" &&
1385 (parsed.sourceField === "root" || hasNestedChildren(items))
1386 ) {
1387 items = flattenHierarchy(items);
1388 }
1389
1390 // Add the field name
1391 lines.push(` ${targetField}`);
1392
1393 // Serialize items
1394 for (const item of items) {
1395 lines.push(...serializeDataItem(item));
1396 }
1397 }
1398
1399 // For sequences, preserve order if present
1400 if (targetField === "sequences" && parsed.order) {
1401 lines.push(` order ${parsed.order}`);
1402 }
1403
1404 return lines.join("\n");
1405 }
1406
1407 function slugifyId(input: string): string {
1408 return input
1409 .trim()
1410 .toLowerCase()
1411 .replace(/[^a-z0-9]+/g, "-")
1412 .replace(/^-+|-+$/g, "");
1413 }
1414
1415 function normalizeNodeIds(items: DataItem[]): DataItem[] {
1416 const used = new Set<string>();
1417
1418 return items.map((item, idx) => {
1419 const raw = String(item.id ?? item.label ?? `node-${idx + 1}`);
1420 let id = slugifyId(raw) || `node-${idx + 1}`;
1421
1422 const base = id;
1423 let n = 2;
1424 while (used.has(id)) id = `${base}-${n++}`;
1425 used.add(id);
1426
1427 // keep label as-is, but ensure a safe id
1428 return { ...item, id, children: undefined };
1429 });
1430 }
1431
1432 /** Leaves-only flatten (prevents internal/group nodes from becoming extra nodes) */
1433 function flattenHierarchyLeaves(items: DataItem[]): DataItem[] {
1434 const out: DataItem[] = [];
1435
1436 const walk = (node: DataItem) => {
1437 if (!node.children || node.children.length === 0) {
1438 const { children: _, ...rest } = node;
1439 out.push(rest);
1440 return;
1441 }
1442 for (const ch of node.children) walk(ch);
1443 };
1444
1445 for (const n of items) walk(n);
1446 return out;
1447 }
1448
1449 /**
1450 * Build relation data block with nodes + relations inside data.
1451 * Nodes use safe ids; relations are unlabeled simple connections.
1452 */
1453 function buildRelationDataBlock(
1454 parsed: ParsedDataBlock,
1455 relations: string[],
1456 ): string {
1457 const lines: string[] = ["data"];
1458
1459 if (parsed.title) lines.push(` title ${parsed.title}`);
1460 if (parsed.desc) lines.push(` desc ${parsed.desc}`);
1461 if (parsed.attributes && Object.keys(parsed.attributes).length > 0) {
1462 lines.push(" attributes");
1463 lines.push(...serializeUnknownObject(parsed.attributes, 4));
1464 }
1465
1466 // get source items
1467 let items = parsed.items;
1468
1469 // IMPORTANT: hierarchy → only leaves (avoid “extra” internal/group nodes)
1470 if (parsed.sourceField === "root") {
1471 items = flattenHierarchyLeaves(items);
1472 } else if (hasNestedChildren(items)) {
1473 // if anything nested sneaks in, also leaves-only (keeps it stable)
1474 items = flattenHierarchyLeaves(items);
1475 }
1476
1477 // normalize ids
1478 const nodes = normalizeNodeIds(items);
1479
1480 lines.push(" nodes");
1481 for (const node of nodes) {
1482 // write "- id ..." so the relation engine uses stable IDs
1483 const pad = " ".repeat(4);
1484 lines.push(`${pad}- id ${node.id}`);
1485 lines.push(`${pad} label ${node.label ?? node.id}`);
1486
1487 if (node.value !== undefined) lines.push(`${pad} value ${node.value}`);
1488 if (node.desc) lines.push(`${pad} desc ${node.desc}`);
1489 if (node.icon) lines.push(`${pad} icon ${node.icon}`);
1490 if (node.group) lines.push(`${pad} group ${node.group}`);
1491 if (node.category) lines.push(`${pad} category ${node.category}`);
1492 if (node.attributes && Object.keys(node.attributes).length > 0) {
1493 lines.push(`${pad} attributes`);
1494 lines.push(...serializeUnknownObject(node.attributes, 6));
1495 }
1496 }
1497
1498 if (relations.length > 0) {
1499 lines.push(" relations");
1500 for (const r of relations) lines.push(` - ${r}`);
1501 }
1502
1503 return lines.join("\n");
1504 }
1505
1506 /**
1507 * Build hierarchy data using items with a single root that contains children.
1508 */
1509 function buildHierarchyItemsDataBlock(parsed: ParsedDataBlock): string {
1510 const lines: string[] = ["data"];
1511
1512 if (parsed.title) {
1513 lines.push(` title ${parsed.title}`);
1514 }
1515 if (parsed.desc) {
1516 lines.push(` desc ${parsed.desc}`);
1517 }
1518 if (parsed.attributes && Object.keys(parsed.attributes).length > 0) {
1519 lines.push(" attributes");
1520 lines.push(...serializeUnknownObject(parsed.attributes, 4));
1521 }
1522
1523 let items = parsed.items;
1524 if (parsed.sourceField !== "root") {
1525 items = convertToHierarchy(items);
1526 }
1527
1528 lines.push(" items");
1529 if (items.length > 0) {
1530 lines.push(...serializeDataItem(items[0]!));
1531 }
1532
1533 return lines.join("\n");
1534 }
1535
1536 /**
1537 * Convert infographic syntax data from one template category to another
1538 */
1539 export function convertInfographicData(
1540 syntax: string,
1541 fromTemplate: string,
1542 toTemplate: string,
1543 ): string {
1544 if (!syntax?.trim()) return syntax;
1545
1546 const fromCategory = getTemplateMainCategory(fromTemplate);
1547 const toCategory = getTemplateMainCategory(toTemplate);
1548
1549 // If same category, just change the template name
1550 if (fromCategory === toCategory) {
1551 return changeInfographicTemplate(syntax, toTemplate);
1552 }
1553
1554 // Parse the data block
1555 const parsed = parseDataBlock(syntax);
1556 if (!parsed || parsed.items.length === 0) {
1557 // No data to convert, just change template
1558 return changeInfographicTemplate(syntax, toTemplate);
1559 }
1560
1561 // Get target data field
1562 const targetField = getExpectedDataField(toCategory);
1563
1564 // Special handling for relation templates (items + relations inside data)
1565 if (toCategory === "relation") {
1566 // Always force a simple chain; ignore existing relations to avoid surprises.
1567 let items = parsed.items;
1568
1569 if (parsed.sourceField === "root") items = flattenHierarchyLeaves(items);
1570 else if (hasNestedChildren(items)) items = flattenHierarchyLeaves(items);
1571
1572 const nodes = normalizeNodeIds(items);
1573
1574 // simple unlabeled line connections (no arrows): "--"
1575 const relations =
1576 nodes.length >= 2
1577 ? nodes.slice(0, -1).map((n, idx) => `${n.id} -- ${nodes[idx + 1]!.id}`)
1578 : [];
1579
1580 // overwrite parsed.items with normalized nodes so builder uses same ids
1581 parsed.items = nodes;
1582
1583 const relationDataBlock = buildRelationDataBlock(parsed, relations);
1584
1585 const themeMatch = syntax.match(
1586 /^theme(\s+\w+)?[\s\S]*?(?=^(?:data|design|relations)\s|$)/m,
1587 );
1588 const themeBlock = themeMatch ? themeMatch[0].trim() : "";
1589
1590 const designMatch = syntax.match(
1591 /^design[\s\S]*?(?=^(?:data|theme|relations)\s|$)/m,
1592 );
1593 const designBlock = designMatch ? designMatch[0].trim() : "";
1594
1595 const parts = [`infographic ${toTemplate}`];
1596 if (themeBlock) parts.push(themeBlock);
1597 if (designBlock) parts.push(designBlock);
1598 parts.push(relationDataBlock);
1599
1600 return parts.join("\n");
1601 }
1602
1603 // Special handling for compare-binary templates (need 2 items with children)
1604 const isCompareBinary = toTemplate.startsWith("compare-binary-");
1605 if (isCompareBinary) {
1606 // Flatten source items if coming from compare or hierarchy
1607 let items = parsed.items;
1608 if (parsed.sourceField === "compares" || fromCategory === "compare") {
1609 items = flattenCompareItems(items);
1610 } else if (parsed.sourceField === "root" || hasNestedChildren(items)) {
1611 items = flattenHierarchy(items);
1612 }
1613
1614 // Convert to compare-binary format
1615 const compareItems = convertToCompareBinary(items);
1616 parsed.items = compareItems;
1617
1618 // Build data block with compares field
1619 const newDataBlock = buildDataBlock(parsed, "compares");
1620
1621 const themeMatch = syntax.match(
1622 /^theme(\s+\w+)?[\s\S]*?(?=^(?:data|design|relations)\s|$)/m,
1623 );
1624 const themeBlock = themeMatch ? themeMatch[0].trim() : "";
1625
1626 const designMatch = syntax.match(
1627 /^design[\s\S]*?(?=^(?:data|theme|relations)\s|$)/m,
1628 );
1629 const designBlock = designMatch ? designMatch[0].trim() : "";
1630
1631 const parts = [`infographic ${toTemplate}`];
1632 if (themeBlock) parts.push(themeBlock);
1633 if (designBlock) parts.push(designBlock);
1634 parts.push(newDataBlock);
1635
1636 return parts.join("\n");
1637 }
1638
1639 // When converting FROM compare to non-compare, flatten compare items first
1640 if (fromCategory === "compare" && toCategory !== "compare") {
1641 if (parsed.sourceField === "compares" || hasNestedChildren(parsed.items)) {
1642 parsed.items = flattenCompareItems(parsed.items);
1643 }
1644 }
1645
1646 // Build new data block
1647 let newDataBlock = "";
1648 if (toCategory === "hierarchy") {
1649 newDataBlock = buildHierarchyItemsDataBlock(parsed);
1650 } else {
1651 newDataBlock = buildDataBlock(parsed, targetField);
1652 }
1653
1654 // Extract theme block if present
1655 const themeMatch = syntax.match(
1656 /^theme(\s+\w+)?[\s\S]*?(?=^(?:data|design|relations)\s|$)/m,
1657 );
1658 const themeBlock = themeMatch ? themeMatch[0].trim() : "";
1659
1660 // Extract design block if present
1661 const designMatch = syntax.match(
1662 /^design[\s\S]*?(?=^(?:data|theme|relations)\s|$)/m,
1663 );
1664 const designBlock = designMatch ? designMatch[0].trim() : "";
1665
1666 // Rebuild complete syntax
1667 const parts = [`infographic ${toTemplate}`];
1668
1669 if (themeBlock) {
1670 parts.push(themeBlock);
1671 }
1672 if (designBlock) {
1673 parts.push(designBlock);
1674 }
1675
1676 parts.push(newDataBlock);
1677
1678 return parts.join("\n");
1679 }
1680
1681 type RelationEdge = {
1682 from?: string;
1683 to?: string;
1684 label?: string;
1685 direction?: "forward" | "both" | "none";
1686 };
1687
1688 export type InfographicRelationEdge = RelationEdge;
1689
1690 function resolveDataFieldFromOptions(data: Data): DataFieldType {
1691 if ("root" in data && data.root) return "root";
1692 if ("nodes" in data && Array.isArray(data.nodes) && data.nodes.length > 0)
1693 return "nodes";
1694 if (
1695 "relations" in data &&
1696 Array.isArray(data.relations) &&
1697 data.relations.length > 0
1698 )
1699 return "nodes";
1700 if (
1701 "compares" in data &&
1702 Array.isArray(data.compares) &&
1703 data.compares.length > 0
1704 )
1705 return "compares";
1706 if ("lists" in data && Array.isArray(data.lists) && data.lists.length > 0)
1707 return "lists";
1708 if (
1709 "sequences" in data &&
1710 Array.isArray(data.sequences) &&
1711 data.sequences.length > 0
1712 )
1713 return "sequences";
1714 if ("values" in data && Array.isArray(data.values) && data.values.length > 0)
1715 return "values";
1716 if ("items" in data && Array.isArray(data.items) && data.items.length > 0)
1717 return "items";
1718 return "items";
1719 }
1720
1721 function toDataItem(item: unknown): DataItem {
1722 if (!item || typeof item !== "object") {
1723 return {};
1724 }
1725
1726 const candidate = item as DataItem;
1727 const normalized: DataItem = {
1728 label: candidate.label,
1729 desc: candidate.desc,
1730 value: candidate.value,
1731 icon: candidate.icon,
1732 id: candidate.id,
1733 group: candidate.group,
1734 category: candidate.category,
1735 };
1736
1737 if (candidate.attributes && typeof candidate.attributes === "object") {
1738 normalized.attributes = JSON.parse(JSON.stringify(candidate.attributes));
1739 }
1740
1741 if (Array.isArray(candidate.children) && candidate.children.length > 0) {
1742 normalized.children = candidate.children.map(toDataItem);
1743 }
1744
1745 return normalized;
1746 }
1747
1748 function getItemsFromOptions(data: Data, field: DataFieldType): DataItem[] {
1749 if (field === "root") {
1750 const root =
1751 ("root" in data && data.root) ||
1752 ("items" in data && Array.isArray(data.items)
1753 ? data.items[0]
1754 : undefined);
1755 return root ? [toDataItem(root)] : [];
1756 }
1757
1758 if (field === "nodes") {
1759 const nodes =
1760 ("nodes" in data && Array.isArray(data.nodes) && data.nodes.length > 0
1761 ? data.nodes
1762 : undefined) ??
1763 ("items" in data && Array.isArray(data.items) ? data.items : []);
1764 return nodes.map(toDataItem);
1765 }
1766
1767 if (field === "compares") {
1768 const compares =
1769 ("compares" in data &&
1770 Array.isArray(data.compares) &&
1771 data.compares.length > 0
1772 ? data.compares
1773 : undefined) ??
1774 ("items" in data && Array.isArray(data.items) ? data.items : []);
1775 return compares.map(toDataItem);
1776 }
1777
1778 if (field === "lists") {
1779 const lists =
1780 ("lists" in data && Array.isArray(data.lists) && data.lists.length > 0
1781 ? data.lists
1782 : undefined) ??
1783 ("items" in data && Array.isArray(data.items) ? data.items : []);
1784 return lists.map(toDataItem);
1785 }
1786
1787 if (field === "sequences") {
1788 const sequences =
1789 ("sequences" in data &&
1790 Array.isArray(data.sequences) &&
1791 data.sequences.length > 0
1792 ? data.sequences
1793 : undefined) ??
1794 ("items" in data && Array.isArray(data.items) ? data.items : []);
1795 return sequences.map(toDataItem);
1796 }
1797
1798 if (field === "values") {
1799 const values =
1800 ("values" in data && Array.isArray(data.values) && data.values.length > 0
1801 ? data.values
1802 : undefined) ??
1803 ("items" in data && Array.isArray(data.items) ? data.items : []);
1804 return values.map(toDataItem);
1805 }
1806
1807 const items = "items" in data && Array.isArray(data.items) ? data.items : [];
1808 return items.map(toDataItem);
1809 }
1810
1811 function relationEdgeToSyntax(edge: RelationEdge): string | null {
1812 const from = edge.from?.trim();
1813 const to = edge.to?.trim();
1814 if (!from || !to) return null;
1815
1816 let connector = "->";
1817 if (edge.direction === "none") connector = "--";
1818 if (edge.direction === "both") connector = "<->";
1819
1820 if (edge.label?.trim()) {
1821 return `${from} - ${edge.label.trim()} ${connector} ${to}`;
1822 }
1823
1824 return `${from} ${connector} ${to}`;
1825 }
1826
1827 function parseInfographicRelation(
1828 relation: string,
1829 ): InfographicRelationEdge | null {
1830 const trimmed = relation.trim();
1831 if (!trimmed) return null;
1832
1833 let direction: InfographicRelationEdge["direction"] = "forward";
1834 let connector = "->";
1835
1836 if (trimmed.includes("<->")) {
1837 direction = "both";
1838 connector = "<->";
1839 } else if (trimmed.includes("--")) {
1840 direction = "none";
1841 connector = "--";
1842 }
1843
1844 const [left, right] = trimmed.split(connector);
1845 const to = right?.trim();
1846 if (!left || !to) return null;
1847
1848 const labeledMatch = left.match(/^(.*?)\s+-\s+(.*?)\s*$/);
1849 const from = labeledMatch?.[1]?.trim() ?? left.trim();
1850 const label = labeledMatch?.[2]?.trim();
1851 if (!from) return null;
1852
1853 return {
1854 from,
1855 to,
1856 ...(label ? { label } : {}),
1857 direction,
1858 };
1859 }
1860
1861 export function buildInfographicDataFromParsed(parsed: ParsedDataBlock): Data {
1862 const data: Record<string, unknown> = {};
1863
1864 if (parsed.title) data.title = parsed.title;
1865 if (parsed.desc) data.desc = parsed.desc;
1866 if (parsed.order) data.order = parsed.order;
1867 if (parsed.attributes) data.attributes = parsed.attributes;
1868
1869 if (parsed.sourceField === "root") {
1870 const root = parsed.items[0];
1871 if (root) {
1872 data.root = root;
1873 data.items = [root];
1874 }
1875 return data as Data;
1876 }
1877
1878 data.items = parsed.items;
1879 data[parsed.sourceField] = parsed.items;
1880
1881 if (parsed.sourceField === "nodes" && parsed.relations) {
1882 data.relations = parsed.relations
1883 .map(parseInfographicRelation)
1884 .filter((edge): edge is InfographicRelationEdge => Boolean(edge));
1885 }
1886
1887 return data as Data;
1888 }
1889
1890 export function updateInfographicSyntaxWithParsedData(
1891 syntax: string,
1892 parsed: ParsedDataBlock,
1893 ): string {
1894 const template = parseInfographicTemplate(syntax);
1895 if (!template) return syntax;
1896
1897 const themeMatch = syntax.match(
1898 /^theme(\s+\w+)?[\s\S]*?(?=^(?:data|design|relations)\s|$)/m,
1899 );
1900 const themeBlock = themeMatch ? themeMatch[0].trim() : "";
1901
1902 const designMatch = syntax.match(
1903 /^design[\s\S]*?(?=^(?:data|theme|relations)\s|$)/m,
1904 );
1905 const designBlock = designMatch ? designMatch[0].trim() : "";
1906
1907 const dataBlock =
1908 parsed.sourceField === "nodes"
1909 ? buildRelationDataBlock(parsed, parsed.relations ?? [])
1910 : buildDataBlock(parsed, parsed.sourceField);
1911
1912 const parts = [`infographic ${template}`];
1913 if (themeBlock) parts.push(themeBlock);
1914 if (designBlock) parts.push(designBlock);
1915 parts.push(dataBlock);
1916
1917 return parts.join("\n");
1918 }
1919
1920 function buildDataBlockFromOptions(data: Data): string {
1921 const field = resolveDataFieldFromOptions(data);
1922 const items = getItemsFromOptions(data, field);
1923 const relations =
1924 "relations" in data && Array.isArray(data.relations)
1925 ? (data.relations as RelationEdge[])
1926 .map(relationEdgeToSyntax)
1927 .filter((line): line is string => Boolean(line))
1928 : [];
1929 const attributes =
1930 "attributes" in data &&
1931 data.attributes &&
1932 typeof data.attributes === "object"
1933 ? JSON.parse(JSON.stringify(data.attributes))
1934 : undefined;
1935
1936 const parsed: ParsedDataBlock = {
1937 title: "title" in data ? (data.title as string | undefined) : undefined,
1938 desc: "desc" in data ? (data.desc as string | undefined) : undefined,
1939 order: "order" in data ? (data.order as string | undefined) : undefined,
1940 items,
1941 relations,
1942 sourceField: field,
1943 attributes,
1944 };
1945
1946 if (field === "nodes") {
1947 return buildRelationDataBlock(parsed, relations);
1948 }
1949
1950 return buildDataBlock(parsed, field);
1951 }
1952
1953 export function syncInfographicSyntaxWithData(
1954 syntax: string,
1955 options: Partial<InfographicOptions> | null | undefined,
1956 ): string {
1957 if (!options || !options.data) return syntax;
1958
1959 const template = parseInfographicTemplate(syntax) ?? options.template ?? null;
1960 if (!template) return syntax;
1961
1962 const dataBlock = buildDataBlockFromOptions(options.data);
1963
1964 const themeMatch = syntax.match(
1965 /^theme(\s+\w+)?[\s\S]*?(?=^(?:data|design|relations)\s|$)/m,
1966 );
1967 const themeBlock = themeMatch ? themeMatch[0].trim() : "";
1968
1969 const designMatch = syntax.match(
1970 /^design[\s\S]*?(?=^(?:data|theme|relations)\s|$)/m,
1971 );
1972 const designBlock = designMatch ? designMatch[0].trim() : "";
1973
1974 const parts = [`infographic ${template}`];
1975 if (themeBlock) parts.push(themeBlock);
1976 if (designBlock) parts.push(designBlock);
1977 parts.push(dataBlock);
1978
1979 return parts.join("\n");
1980 }
1981
1981 lines TYPESCRIPT