@bendyline/squisq 1.5.2 → 2.0.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.
Files changed (263) hide show
  1. package/README.md +63 -10
  2. package/dist/{Doc-BBVGq1_9.d.ts → Doc-ylqWKDc_.d.ts} +248 -37
  3. package/dist/{ImageEditDoc-FuyGtt6o.d.ts → ImageEditDoc-D828IXJ1.d.ts} +1 -1
  4. package/dist/annotationCoercion-Dr_DY9Hx.d.ts +86 -0
  5. package/dist/{chunk-VWUFZ6ZG.js → chunk-3E5F2XMR.js} +2 -29
  6. package/dist/chunk-3E5F2XMR.js.map +1 -0
  7. package/dist/chunk-4X3JQXNM.js +35 -0
  8. package/dist/chunk-4X3JQXNM.js.map +1 -0
  9. package/dist/{chunk-P3K7NLHF.js → chunk-5DKBFXPY.js} +31 -121
  10. package/dist/chunk-5DKBFXPY.js.map +1 -0
  11. package/dist/{chunk-DLXZMS5K.js → chunk-5J4HKRJF.js} +51 -3
  12. package/dist/chunk-5J4HKRJF.js.map +1 -0
  13. package/dist/chunk-5UYI3RJB.js +2503 -0
  14. package/dist/chunk-5UYI3RJB.js.map +1 -0
  15. package/dist/{chunk-BEQODJWV.js → chunk-66QO7XFB.js} +11 -291
  16. package/dist/chunk-66QO7XFB.js.map +1 -0
  17. package/dist/{chunk-KKNUBQ6Y.js → chunk-6PJRB2CF.js} +156 -91
  18. package/dist/chunk-6PJRB2CF.js.map +1 -0
  19. package/dist/{chunk-HWVFJAAH.js → chunk-AMIR72JP.js} +28 -10
  20. package/dist/chunk-AMIR72JP.js.map +1 -0
  21. package/dist/chunk-FVJUUTEM.js +74 -0
  22. package/dist/chunk-FVJUUTEM.js.map +1 -0
  23. package/dist/{chunk-5TYS5V3G.js → chunk-GF3BVNLU.js} +4127 -3538
  24. package/dist/chunk-GF3BVNLU.js.map +1 -0
  25. package/dist/{chunk-Q3ROPT5H.js → chunk-GZY66SDN.js} +1 -1
  26. package/dist/{chunk-Q3ROPT5H.js.map → chunk-GZY66SDN.js.map} +1 -1
  27. package/dist/chunk-JOQYPRDZ.js +127 -0
  28. package/dist/chunk-JOQYPRDZ.js.map +1 -0
  29. package/dist/{chunk-A4O7GIWE.js → chunk-LYNJEOAC.js} +10 -73
  30. package/dist/chunk-LYNJEOAC.js.map +1 -0
  31. package/dist/{chunk-LDQ2HJIX.js → chunk-PXLVXTLW.js} +252 -54
  32. package/dist/chunk-PXLVXTLW.js.map +1 -0
  33. package/dist/{chunk-TQWLI6S2.js → chunk-Q7LROV2H.js} +4 -2
  34. package/dist/{chunk-TQWLI6S2.js.map → chunk-Q7LROV2H.js.map} +1 -1
  35. package/dist/{chunk-T5UK6YOB.js → chunk-QTSJOY54.js} +60 -17
  36. package/dist/chunk-QTSJOY54.js.map +1 -0
  37. package/dist/chunk-QVANWDPH.js +2358 -0
  38. package/dist/chunk-QVANWDPH.js.map +1 -0
  39. package/dist/{chunk-GPLTCSXO.js → chunk-R2GC6RLD.js} +7 -7
  40. package/dist/chunk-R2GC6RLD.js.map +1 -0
  41. package/dist/chunk-R2YZTCLO.js +292 -0
  42. package/dist/chunk-R2YZTCLO.js.map +1 -0
  43. package/dist/chunk-S7A5D7ZJ.js +174 -0
  44. package/dist/chunk-S7A5D7ZJ.js.map +1 -0
  45. package/dist/chunk-T3TI7GSZ.js +884 -0
  46. package/dist/chunk-T3TI7GSZ.js.map +1 -0
  47. package/dist/{chunk-LTN6I7QW.js → chunk-TUWMK62B.js} +55 -4
  48. package/dist/chunk-TUWMK62B.js.map +1 -0
  49. package/dist/{chunk-IMSCRLLV.js → chunk-VR7UPJ7C.js} +35 -25
  50. package/dist/chunk-VR7UPJ7C.js.map +1 -0
  51. package/dist/doc/index.d.ts +2334 -5
  52. package/dist/doc/index.js +127 -33
  53. package/dist/generate/index.d.ts +7 -2
  54. package/dist/generate/index.js +1 -1
  55. package/dist/imageEdit/index.d.ts +9 -6
  56. package/dist/imageEdit/index.js +3 -3
  57. package/dist/index.d.ts +12 -10
  58. package/dist/index.js +246 -80
  59. package/dist/jsonForm/index.d.ts +7 -3
  60. package/dist/jsonForm/index.js +9 -4
  61. package/dist/markdown/index.d.ts +6 -70
  62. package/dist/markdown/index.js +13 -9
  63. package/dist/narration/index.d.ts +618 -0
  64. package/dist/narration/index.js +82 -0
  65. package/dist/recommend/index.d.ts +15 -1
  66. package/dist/recommend/index.js +2 -2
  67. package/dist/schemas/index.d.ts +28 -6
  68. package/dist/schemas/index.js +15 -13
  69. package/dist/spatial/index.d.ts +0 -1
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.js +1 -1
  72. package/dist/{themeLibrary-CehcJhzz.d.ts → themeLibrary-RjVgLpUi.d.ts} +10 -11
  73. package/dist/timing/index.js +5 -3
  74. package/dist/transform/index.d.ts +35 -24
  75. package/dist/transform/index.js +9 -10
  76. package/dist/{types-DlAZ7MW4.d.ts → types-BwMPl2CB.d.ts} +1 -1
  77. package/dist/{types-D98li1gV.d.ts → types-C4kiRn_k.d.ts} +3 -1
  78. package/dist/versions/index.d.ts +2 -1
  79. package/dist/versions/index.js +2 -2
  80. package/package.json +6 -6
  81. package/src/__tests__/applyNarrationTiming.test.ts +190 -0
  82. package/src/__tests__/applyRenderStyle.test.ts +3 -3
  83. package/src/__tests__/asciiDiagramBattle.test.ts +246 -0
  84. package/src/__tests__/asciiDiagramDetect.test.ts +162 -0
  85. package/src/__tests__/asciiDiagramMapping.test.ts +38 -0
  86. package/src/__tests__/asciiDiagramParse.test.ts +233 -0
  87. package/src/__tests__/asciiDiagramPipeline.test.ts +273 -0
  88. package/src/__tests__/asciiDiagramRender.test.ts +162 -0
  89. package/src/__tests__/asciiDiagramRepair.test.ts +84 -0
  90. package/src/__tests__/asciiTimelineDetect.test.ts +157 -0
  91. package/src/__tests__/asciiTimelineParse.test.ts +205 -0
  92. package/src/__tests__/asciiTimelinePipeline.test.ts +349 -0
  93. package/src/__tests__/asciiTimelineRender.test.ts +376 -0
  94. package/src/__tests__/attrTokens.test.ts +5 -0
  95. package/src/__tests__/contentContainer.test.ts +43 -0
  96. package/src/__tests__/customTemplatesFrontmatter.test.ts +0 -15
  97. package/src/__tests__/dataTable.test.ts +25 -15
  98. package/src/__tests__/diagramBlock.test.ts +3 -3
  99. package/src/__tests__/diagramText.test.ts +26 -0
  100. package/src/__tests__/docToMarkdownMedia.test.ts +83 -0
  101. package/src/__tests__/drawingBlock.test.ts +3 -3
  102. package/src/__tests__/fixtures/asciiDiagramBattle.ts +512 -0
  103. package/src/__tests__/fixtures/asciiDiagrams.ts +287 -0
  104. package/src/__tests__/fixtures/asciiTimelines.ts +35 -0
  105. package/src/__tests__/fixtures/treeviewFixtures.ts +243 -0
  106. package/src/__tests__/getLayers.test.ts +32 -18
  107. package/src/__tests__/getLayersFallback.test.ts +8 -8
  108. package/src/__tests__/imageEditVersions.test.ts +2 -0
  109. package/src/__tests__/jsonForm.pointer.test.ts +47 -1
  110. package/src/__tests__/layoutBlock.test.ts +3 -3
  111. package/src/__tests__/majorApiTypes.test.ts +18 -0
  112. package/src/__tests__/markdown.test.ts +41 -0
  113. package/src/__tests__/matchFontFamily.test.ts +114 -0
  114. package/src/__tests__/materializeBlockLayers.test.ts +135 -0
  115. package/src/__tests__/materializeTestUtils.ts +17 -0
  116. package/src/__tests__/narrationAlign.test.ts +150 -0
  117. package/src/__tests__/narrationFeatures.test.ts +71 -0
  118. package/src/__tests__/narrationNuclei.test.ts +45 -0
  119. package/src/__tests__/narrationPacing.test.ts +189 -0
  120. package/src/__tests__/narrationScript.test.ts +149 -0
  121. package/src/__tests__/narrationSessionRepro.test.ts +62 -0
  122. package/src/__tests__/narrationSidecar.test.ts +88 -0
  123. package/src/__tests__/narrationSyllables.test.ts +65 -0
  124. package/src/__tests__/narrationTestSignals.ts +167 -0
  125. package/src/__tests__/narrationVad.test.ts +66 -0
  126. package/src/__tests__/resolveDocTheme.test.ts +16 -2
  127. package/src/__tests__/rootExports.test.ts +18 -0
  128. package/src/__tests__/scopedContentContainer.test.ts +20 -0
  129. package/src/__tests__/shapeGeometry.test.ts +12 -0
  130. package/src/__tests__/templateAnnotationParse.test.ts +10 -0
  131. package/src/__tests__/templateInputs.test.ts +69 -0
  132. package/src/__tests__/templateMetadata.test.ts +2 -1
  133. package/src/__tests__/templates.test.ts +143 -17
  134. package/src/__tests__/themeCompile.test.ts +49 -22
  135. package/src/__tests__/transformProvenance.test.ts +160 -0
  136. package/src/__tests__/transformV2.test.ts +40 -14
  137. package/src/__tests__/treeviewBattle.test.ts +76 -0
  138. package/src/__tests__/treeviewDetect.test.ts +168 -0
  139. package/src/__tests__/treeviewParse.test.ts +118 -0
  140. package/src/__tests__/treeviewPipeline.test.ts +183 -0
  141. package/src/__tests__/treeviewRender.test.ts +91 -0
  142. package/src/__tests__/versions.test.ts +44 -0
  143. package/src/doc/applyNarrationTiming.ts +244 -0
  144. package/src/doc/asciiDiagram/chars.ts +210 -0
  145. package/src/doc/asciiDiagram/detect.ts +175 -0
  146. package/src/doc/asciiDiagram/index.ts +38 -0
  147. package/src/doc/asciiDiagram/mapping.ts +225 -0
  148. package/src/doc/asciiDiagram/parse.ts +1095 -0
  149. package/src/doc/asciiDiagram/render.ts +963 -0
  150. package/src/doc/asciiDiagram/repair.ts +400 -0
  151. package/src/doc/asciiDiagram/types.ts +65 -0
  152. package/src/doc/asciiTimeline/detect.ts +138 -0
  153. package/src/doc/asciiTimeline/index.ts +26 -0
  154. package/src/doc/asciiTimeline/mapping.ts +43 -0
  155. package/src/doc/asciiTimeline/parse.ts +870 -0
  156. package/src/doc/asciiTimeline/render.ts +427 -0
  157. package/src/doc/asciiTimeline/types.ts +73 -0
  158. package/src/doc/asciiTimeline/wrappedFlow.ts +427 -0
  159. package/src/doc/audioMapping.ts +60 -46
  160. package/src/doc/customTemplatesFrontmatter.ts +0 -41
  161. package/src/doc/docToMarkdown.ts +70 -2
  162. package/src/doc/index.ts +76 -4
  163. package/src/doc/markdownToDoc.ts +135 -1
  164. package/src/doc/materializeBlockLayers.ts +304 -0
  165. package/src/doc/mediaAnnotations.ts +20 -0
  166. package/src/doc/resolveDocTheme.ts +8 -7
  167. package/src/doc/templateInputs.ts +185 -2
  168. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +22 -8
  169. package/src/doc/templates/diagramBlock.ts +237 -38
  170. package/src/doc/templates/fallbackBlock.ts +1 -1
  171. package/src/doc/templates/index.ts +134 -279
  172. package/src/doc/templates/inputDescriptors.ts +20 -1
  173. package/src/doc/templates/metadata.ts +10 -0
  174. package/src/doc/templates/persistentLayers.ts +12 -126
  175. package/src/doc/templates/registry.ts +88 -0
  176. package/src/doc/templates/templateNames.ts +22 -0
  177. package/src/doc/templates/timelineBlock.ts +613 -0
  178. package/src/doc/templates/treeBlock.ts +131 -0
  179. package/src/doc/treeview/chars.ts +72 -0
  180. package/src/doc/treeview/detect.ts +145 -0
  181. package/src/doc/treeview/index.ts +30 -0
  182. package/src/doc/treeview/mapping.ts +95 -0
  183. package/src/doc/treeview/parse.ts +201 -0
  184. package/src/doc/treeview/render.ts +41 -0
  185. package/src/doc/treeview/types.ts +43 -0
  186. package/src/doc/utils/applyRenderStyle.ts +2 -9
  187. package/src/doc/utils/diagramText.ts +78 -0
  188. package/src/doc/utils/shapeGeometry.ts +67 -5
  189. package/src/doc/validate.ts +1 -1
  190. package/src/generate/templateMapper.ts +7 -0
  191. package/src/imageEdit/export.ts +6 -5
  192. package/src/imageEdit/versionPaths.ts +13 -26
  193. package/src/imageEdit/versions.ts +21 -100
  194. package/src/index.ts +1 -0
  195. package/src/internal/immutable.ts +58 -0
  196. package/src/jsonForm/index.ts +9 -1
  197. package/src/jsonForm/pointer.ts +68 -23
  198. package/src/markdown/attrTokens.ts +7 -5
  199. package/src/markdown/index.ts +1 -0
  200. package/src/markdown/stringify.ts +4 -9
  201. package/src/markdown/utils.ts +36 -14
  202. package/src/narration/align.ts +514 -0
  203. package/src/narration/features.ts +231 -0
  204. package/src/narration/index.ts +86 -0
  205. package/src/narration/nuclei.ts +118 -0
  206. package/src/narration/pacing.ts +211 -0
  207. package/src/narration/script.ts +239 -0
  208. package/src/narration/session.ts +100 -0
  209. package/src/narration/sidecar.ts +187 -0
  210. package/src/narration/syllables.ts +85 -0
  211. package/src/narration/trace.ts +50 -0
  212. package/src/narration/types.ts +299 -0
  213. package/src/narration/vad.ts +131 -0
  214. package/src/recommend/numberHighlight.ts +25 -0
  215. package/src/recommend/templates.ts +108 -12
  216. package/src/schemas/BlockTemplates.ts +156 -7
  217. package/src/schemas/Doc.ts +72 -9
  218. package/src/schemas/Media.ts +12 -2
  219. package/src/schemas/Theme.ts +45 -25
  220. package/src/schemas/Viewport.ts +2 -2
  221. package/src/schemas/fontStacks.ts +79 -0
  222. package/src/schemas/themeCompile.ts +15 -0
  223. package/src/schemas/themeConstants.ts +2 -0
  224. package/src/schemas/themeLibrary.ts +25 -19
  225. package/src/schemas/themeValidator.ts +1 -1
  226. package/src/spatial/Geohash.ts +0 -3
  227. package/src/storage/ContentContainer.ts +17 -9
  228. package/src/storage/ScopedContentContainer.ts +45 -15
  229. package/src/transform/applyTransform.ts +5 -5
  230. package/src/transform/index.ts +3 -1
  231. package/src/transform/registry.ts +169 -49
  232. package/src/transform/templateSelector.ts +21 -2
  233. package/src/transform/timingAllocator.ts +152 -21
  234. package/src/transform/types.ts +14 -1
  235. package/src/versions/gitignore.ts +34 -0
  236. package/src/versions/index.ts +2 -0
  237. package/src/versions/operations.ts +18 -104
  238. package/src/versions/paths.ts +13 -28
  239. package/src/versions/snapshotStore.ts +202 -0
  240. package/src/versions/types.ts +3 -1
  241. package/dist/chunk-5TYS5V3G.js.map +0 -1
  242. package/dist/chunk-A4O7GIWE.js.map +0 -1
  243. package/dist/chunk-BEQODJWV.js.map +0 -1
  244. package/dist/chunk-DLXZMS5K.js.map +0 -1
  245. package/dist/chunk-GPLTCSXO.js.map +0 -1
  246. package/dist/chunk-HWVFJAAH.js.map +0 -1
  247. package/dist/chunk-IMSCRLLV.js.map +0 -1
  248. package/dist/chunk-JELIRVDP.js +0 -24
  249. package/dist/chunk-JELIRVDP.js.map +0 -1
  250. package/dist/chunk-KKNUBQ6Y.js.map +0 -1
  251. package/dist/chunk-LDQ2HJIX.js.map +0 -1
  252. package/dist/chunk-LTN6I7QW.js.map +0 -1
  253. package/dist/chunk-P3K7NLHF.js.map +0 -1
  254. package/dist/chunk-T5UK6YOB.js.map +0 -1
  255. package/dist/chunk-UY7KGQ5R.js +0 -192
  256. package/dist/chunk-UY7KGQ5R.js.map +0 -1
  257. package/dist/chunk-VLJJHDAC.js +0 -218
  258. package/dist/chunk-VLJJHDAC.js.map +0 -1
  259. package/dist/chunk-VWUFZ6ZG.js.map +0 -1
  260. package/dist/story/index.d.ts +0 -1801
  261. package/dist/story/index.js +0 -279
  262. package/src/doc/getLayers.ts +0 -195
  263. /package/dist/{story → narration}/index.js.map +0 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * ASCII diagram REPAIR — an opt-in, best-effort pass that reconstructs a
3
+ * clean, well-formed diagram from broken box-and-line art.
4
+ *
5
+ * The motivating case: hand-drawn architecture art whose labels overflow
6
+ * their boxes and shove every following column out of alignment row-to-row
7
+ * (e.g. a rename widened the labels). Conservative detection correctly
8
+ * declines such art (`garbled-labels`) so it renders as a faithful code
9
+ * block — but a user can then ask to "repair" it. This is that repair.
10
+ *
11
+ * It is deliberately AGGRESSIVE and lossy — never on the auto-detection path
12
+ * (a mistake there would hide real code). It is only invoked when the user
13
+ * explicitly opts in, and the result is a fully editable diagram, so an
14
+ * imperfect edge or two is fine (the canvas lets the user fix them).
15
+ *
16
+ * Pipeline:
17
+ * 1. Aggressive box tracing (walls may cross edge routing).
18
+ * 2. Row-band label recovery — the key trick: even when a box's columns are
19
+ * desynced from its borders, each label ROW is internally consistent, so
20
+ * its `│…│` segments map to the band's boxes by reading order.
21
+ * 3. Best-effort edge recovery (facing-border adjacency + arrowhead
22
+ * direction).
23
+ * 4. Re-render clean via `renderAsciiDiagram` — the output re-parses cleanly.
24
+ */
25
+
26
+ import { isHoriz, isVert, isJunction, arrowDirection } from './chars.js';
27
+ import { toGrid, traceBoxes, type TracedRect } from './parse.js';
28
+ import { renderAsciiDiagram } from './render.js';
29
+ import { detectAsciiDiagram } from './detect.js';
30
+ import type { AsciiDiagram, AsciiDiagramEdge, AsciiDiagramNode } from './types.js';
31
+
32
+ export interface RepairResult {
33
+ /** The reconstructed diagram model (grid-native coordinates). */
34
+ diagram: AsciiDiagram;
35
+ /** Clean, re-rendered box-and-line art — re-parses to `diagram`. */
36
+ art: string;
37
+ }
38
+
39
+ /** Minimum boxes for a repair to be worthwhile. */
40
+ const MIN_REPAIR_BOXES = 2;
41
+ /** Max vertical gap (rows) between two boxes to infer a connecting edge. */
42
+ const MAX_EDGE_ROW_GAP = 8;
43
+ /** Max horizontal gap (cols) between two boxes to infer a connecting edge. */
44
+ const MAX_EDGE_COL_GAP = 12;
45
+ /** Blank rows inserted between stacked bands so edges route without overlap. */
46
+ const BAND_ROW_GAP = 4;
47
+ /** Blank cols inserted between boxes in a band so edges route without overlap. */
48
+ const BAND_COL_GAP = 4;
49
+
50
+ /**
51
+ * True when a fence is a box diagram too broken for clean detection but worth
52
+ * offering to repair: it has box corners and aggressively traces ≥2 boxes,
53
+ * yet conservative detection declines it. Clean diagrams (already detected)
54
+ * and non-box art both return false, so the three states partition cleanly.
55
+ */
56
+ export function isRepairableDiagram(text: string): boolean {
57
+ // Already a clean diagram → not a repair candidate (the canvas handles it).
58
+ if (detectAsciiDiagram(text).isDiagram) return false;
59
+ const grid = toGrid(text);
60
+ const rects = traceBoxes(grid, true);
61
+ return dedupeRects(rects).length >= MIN_REPAIR_BOXES;
62
+ }
63
+
64
+ /**
65
+ * Reconstruct a clean diagram from broken art. Returns `null` when fewer than
66
+ * two boxes can be recovered (nothing worth repairing).
67
+ */
68
+ export function repairAsciiDiagram(text: string): RepairResult | null {
69
+ const grid = toGrid(text);
70
+ const rects = dedupeRects(traceBoxes(grid, true));
71
+ if (rects.length < MIN_REPAIR_BOXES) return null;
72
+
73
+ const nodes = buildNodes(grid, rects);
74
+ const edges = recoverEdges(grid, rects, nodes);
75
+ const style = detectGridStyle(grid);
76
+ const diagram: AsciiDiagram = { nodes, edges, style, warnings: ['repaired'] };
77
+ return { diagram, art: renderAsciiDiagram(diagram) };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Box de-duplication (aggressive tracing can emit the same rect twice, and
82
+ // nested duplicates where a container and a decomposed cell coincide).
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function dedupeRects(rects: TracedRect[]): TracedRect[] {
86
+ const seen = new Set<string>();
87
+ const out: TracedRect[] = [];
88
+ for (const r of rects) {
89
+ const key = `${r.r0},${r.c0},${r.r1},${r.c1}`;
90
+ if (seen.has(key)) continue;
91
+ seen.add(key);
92
+ out.push(r);
93
+ }
94
+ // Reading order (row, then col).
95
+ return out.sort((a, b) => a.r0 - b.r0 || a.c0 - b.c0);
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Label recovery (row-band segment matching)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function buildNodes(grid: string[][], rects: TracedRect[]): AsciiDiagramNode[] {
103
+ const W = grid[0]?.length ?? 0;
104
+ const labels = rects.map(() => [] as string[]);
105
+ const index = new Map(rects.map((r, i) => [r, i] as const));
106
+
107
+ // Group boxes into horizontal bands (row ranges overlap).
108
+ const bands: TracedRect[][] = [];
109
+ for (const r of rects) {
110
+ let band = bands.find((bd) => bd.some((x) => x.r0 <= r.r1 && r.r0 <= x.r1));
111
+ if (!band) {
112
+ band = [];
113
+ bands.push(band);
114
+ }
115
+ band.push(r);
116
+ }
117
+
118
+ for (const band of bands) {
119
+ band.sort((a, b) => a.c0 - b.c0);
120
+ const minR = Math.min(...band.map((b) => b.r0 + 1));
121
+ const maxR = Math.max(...band.map((b) => b.r1 - 1));
122
+ for (let r = minR; r <= maxR; r++) {
123
+ const inRow = band.filter((b) => r > b.r0 && r < b.r1);
124
+ if (inRow.length === 0) continue;
125
+ const rowText = grid[r].join('');
126
+ // Split the row into `│`-delimited segments; a clean band row yields one
127
+ // non-empty segment per box, matched by reading order.
128
+ const segs = rowText
129
+ .split(/[│┃║]/)
130
+ .map(cleanLabelText)
131
+ .filter((s) => s.length > 0);
132
+ if (segs.length === inRow.length) {
133
+ inRow.forEach((b, i) => {
134
+ const idx = index.get(b);
135
+ if (idx !== undefined && segs[i]) labels[idx].push(segs[i]);
136
+ });
137
+ } else {
138
+ // Fallback: slice each box's own columns (works when its border cols
139
+ // still line up — isolated boxes, or when the band-split is ambiguous).
140
+ for (const b of inRow) {
141
+ const idx = index.get(b);
142
+ if (idx === undefined) continue;
143
+ const t = cleanLabelText(grid[r].slice(b.c0 + 1, Math.min(b.c1, W)).join(''));
144
+ if (t) labels[idx].push(t);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ // Normalize rows onto well-spaced band offsets. Preserving the original
151
+ // (tight, broken) row positions makes the renderer route edges THROUGH
152
+ // boxes; stacking bands with a routing gap gives every band-to-band edge
153
+ // clean vertical space so the rendered art re-parses losslessly. Columns
154
+ // keep their original relative order (the horizontal layout was fine).
155
+ const bandRow = new Map<TracedRect[], number>();
156
+ const orderedBands = [...bands].sort(
157
+ (a, b) => Math.min(...a.map((x) => x.r0)) - Math.min(...b.map((x) => x.r0)),
158
+ );
159
+ let rowCursor = 0;
160
+ for (const band of orderedBands) {
161
+ bandRow.set(band, rowCursor);
162
+ const bandHeight = Math.max(...band.map((b) => b.r1 - b.r0 + 1));
163
+ rowCursor += bandHeight + BAND_ROW_GAP;
164
+ }
165
+ const rectBand = new Map<TracedRect, TracedRect[]>();
166
+ for (const band of bands) for (const r of band) rectBand.set(r, band);
167
+
168
+ // Normalize columns within each band by the boxes' FITTED widths. Recovered
169
+ // labels are usually wider than the broken art's boxes, so the original
170
+ // column spacing would make fitted boxes overlap — the renderer would then
171
+ // shove a box into the wrong band and route edges through it. Laying each
172
+ // band out left-to-right by fitted width + a routing gap keeps bands intact.
173
+ const fittedW = rects.map((rect, i) =>
174
+ Math.max(rect.c1 - rect.c0 + 1, ...labels[i].map((l) => l.length + 4), 3),
175
+ );
176
+ const rectIndex = new Map(rects.map((r, i) => [r, i] as const));
177
+ const bandCol = new Map<TracedRect, number>();
178
+ for (const band of bands) {
179
+ const ordered = [...band].sort((a, b) => a.c0 - b.c0);
180
+ let colCursor = Math.min(...band.map((b) => b.c0));
181
+ for (const r of ordered) {
182
+ bandCol.set(r, colCursor);
183
+ colCursor += (fittedW[rectIndex.get(r) ?? 0] ?? 3) + BAND_COL_GAP;
184
+ }
185
+ }
186
+
187
+ // Assign ids (slug of the first label line, deduped), then resolve
188
+ // containment (a box strictly inside another becomes its child).
189
+ const parentIdx = rects.map((_, i) => smallestContainer(rects, i));
190
+ const usedIds = new Map<string, number>();
191
+ const nodes: AsciiDiagramNode[] = rects.map((rect, i) => {
192
+ let id = slugify(labels[i][0] ?? '');
193
+ const n = usedIds.get(id) ?? 0;
194
+ usedIds.set(id, n + 1);
195
+ if (n > 0) id = `${id}-${n + 1}`;
196
+ const band = rectBand.get(rect);
197
+ const nested = parentIdx[i] >= 0;
198
+ const row = band && !nested ? (bandRow.get(band) ?? rect.r0) : rect.r0;
199
+ const col = !nested ? (bandCol.get(rect) ?? rect.c0) : rect.c0;
200
+ return {
201
+ id,
202
+ label: labels[i].join('\n'),
203
+ col,
204
+ row,
205
+ wCols: rect.c1 - rect.c0 + 1,
206
+ hRows: rect.r1 - rect.r0 + 1,
207
+ };
208
+ });
209
+ nodes.forEach((node, i) => {
210
+ if (parentIdx[i] >= 0) node.containerId = nodes[parentIdx[i]].id;
211
+ });
212
+ return nodes;
213
+ }
214
+
215
+ /**
216
+ * Strip UNICODE box-drawing chars and arrowheads (edge routing that bled into
217
+ * a label). ASCII `-`/`|`/`+` are kept — they're common in real label text
218
+ * (`molen-tooling`, `read-only`, `stdin | stdout`), and a unicode diagram
219
+ * (the common case) never uses them as borders anyway.
220
+ */
221
+ function cleanLabelText(seg: string): string {
222
+ let out = '';
223
+ for (const ch of seg) {
224
+ const code = ch.codePointAt(0) ?? 0;
225
+ const box = code >= 0x2500 && code <= 0x257f; // Box Drawing block
226
+ if (box || arrowDirection(ch) !== null) out += ' ';
227
+ else out += ch;
228
+ }
229
+ return out.replace(/\s+/g, ' ').trim();
230
+ }
231
+
232
+ function smallestContainer(rects: TracedRect[], i: number): number {
233
+ const child = rects[i];
234
+ let best = -1;
235
+ let bestArea = Infinity;
236
+ for (let j = 0; j < rects.length; j++) {
237
+ if (j === i) continue;
238
+ const p = rects[j];
239
+ if (p.r0 < child.r0 && p.c0 < child.c0 && p.r1 > child.r1 && p.c1 > child.c1) {
240
+ const a = (p.r1 - p.r0) * (p.c1 - p.c0);
241
+ if (a < bestArea) {
242
+ bestArea = a;
243
+ best = j;
244
+ }
245
+ }
246
+ }
247
+ return best;
248
+ }
249
+
250
+ function slugify(s: string): string {
251
+ const slug = s
252
+ .toLowerCase()
253
+ .replace(/[^a-z0-9]+/g, '-')
254
+ .replace(/^-+|-+$/g, '');
255
+ return slug || 'node';
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Edge recovery (facing-border adjacency + arrowhead direction)
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function recoverEdges(
263
+ grid: string[][],
264
+ rects: TracedRect[],
265
+ nodes: AsciiDiagramNode[],
266
+ ): AsciiDiagramEdge[] {
267
+ const H = grid.length;
268
+ const W = grid[0]?.length ?? 0;
269
+ const at = (r: number, c: number): string =>
270
+ r >= 0 && r < H && c >= 0 && c < W ? grid[r][c] : ' ';
271
+ const isConnector = (ch: string): boolean =>
272
+ isVert(ch) || isHoriz(ch) || isJunction(ch) || arrowDirection(ch) !== null;
273
+
274
+ const colOverlap = (a: TracedRect, b: TracedRect): boolean =>
275
+ Math.max(a.c0, b.c0) <= Math.min(a.c1, b.c1);
276
+ const rowOverlap = (a: TracedRect, b: TracedRect): boolean =>
277
+ Math.max(a.r0, b.r0) <= Math.min(a.r1, b.r1);
278
+
279
+ const edges: AsciiDiagramEdge[] = [];
280
+ const seen = new Set<string>();
281
+ const push = (s: number, t: number, directed: boolean) => {
282
+ if (s === t) return;
283
+ const key = `${nodes[s].id}|${nodes[t].id}`;
284
+ if (seen.has(key)) return;
285
+ seen.add(key);
286
+ edges.push({ source: nodes[s].id, target: nodes[t].id, directed });
287
+ };
288
+
289
+ for (let i = 0; i < rects.length; i++) {
290
+ for (let j = 0; j < rects.length; j++) {
291
+ if (i === j) continue;
292
+ const A = rects[i];
293
+ const B = rects[j];
294
+
295
+ // Vertical: A above B, columns overlap, a connector spans the gap, and
296
+ // no third box sits directly between them in that column band.
297
+ if (A.r1 < B.r0 && B.r0 - A.r1 <= MAX_EDGE_ROW_GAP && colOverlap(A, B)) {
298
+ const lo = Math.max(A.c0, B.c0);
299
+ const hi = Math.min(A.c1, B.c1);
300
+ if (
301
+ gapHasConnector(at, A.r1 + 1, B.r0 - 1, lo - 1, hi + 1, isConnector) &&
302
+ !boxBetweenVert(rects, i, j, lo, hi)
303
+ ) {
304
+ const directed = gapHasArrow(at, A.r1 + 1, B.r0, lo - 1, hi + 1, 'down');
305
+ push(i, j, directed);
306
+ }
307
+ }
308
+
309
+ // Horizontal: A left of B, rows overlap. Require an actual arrowhead in
310
+ // the gap — a label that overflowed its box leaks `│`/text into the gap
311
+ // between horizontally-adjacent boxes, which a plain-connector test
312
+ // would read as a phantom edge. A real horizontal edge carries `►`/`◄`.
313
+ if (A.c1 < B.c0 && B.c0 - A.c1 <= MAX_EDGE_COL_GAP && rowOverlap(A, B) && i < j) {
314
+ const lo = Math.max(A.r0, B.r0);
315
+ const hi = Math.min(A.r1, B.r1);
316
+ const rightArrow = gapHasArrow(at, lo - 1, hi + 1, A.c1 + 1, B.c0, 'right');
317
+ const leftArrow = gapHasArrow(at, lo - 1, hi + 1, A.c1 + 1, B.c0, 'left');
318
+ if ((rightArrow || leftArrow) && !boxBetweenHoriz(rects, i, j, lo, hi)) {
319
+ if (leftArrow && !rightArrow) push(j, i, true);
320
+ else push(i, j, true);
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return edges;
326
+ }
327
+
328
+ function gapHasConnector(
329
+ at: (r: number, c: number) => string,
330
+ r0: number,
331
+ r1: number,
332
+ c0: number,
333
+ c1: number,
334
+ isConnector: (ch: string) => boolean,
335
+ ): boolean {
336
+ for (let r = r0; r <= r1; r++)
337
+ for (let c = c0; c <= c1; c++) if (isConnector(at(r, c))) return true;
338
+ return false;
339
+ }
340
+
341
+ function gapHasArrow(
342
+ at: (r: number, c: number) => string,
343
+ r0: number,
344
+ r1: number,
345
+ c0: number,
346
+ c1: number,
347
+ dir: 'down' | 'up' | 'left' | 'right',
348
+ ): boolean {
349
+ for (let r = r0; r <= r1; r++) {
350
+ for (let c = c0; c <= c1; c++) {
351
+ if (arrowDirection(at(r, c)) === dir) return true;
352
+ }
353
+ }
354
+ return false;
355
+ }
356
+
357
+ function boxBetweenVert(
358
+ rects: TracedRect[],
359
+ i: number,
360
+ j: number,
361
+ lo: number,
362
+ hi: number,
363
+ ): boolean {
364
+ const A = rects[i];
365
+ const B = rects[j];
366
+ return rects.some((k, ki) => {
367
+ if (ki === i || ki === j) return false;
368
+ return k.r0 > A.r1 && k.r1 < B.r0 && Math.max(k.c0, lo) <= Math.min(k.c1, hi);
369
+ });
370
+ }
371
+
372
+ function boxBetweenHoriz(
373
+ rects: TracedRect[],
374
+ i: number,
375
+ j: number,
376
+ lo: number,
377
+ hi: number,
378
+ ): boolean {
379
+ const A = rects[i];
380
+ const B = rects[j];
381
+ return rects.some((k, ki) => {
382
+ if (ki === i || ki === j) return false;
383
+ return k.c0 > A.c1 && k.c1 < B.c0 && Math.max(k.r0, lo) <= Math.min(k.r1, hi);
384
+ });
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+
389
+ function detectGridStyle(grid: string[][]): 'unicode' | 'ascii' {
390
+ let unicode = 0;
391
+ let ascii = 0;
392
+ for (const row of grid) {
393
+ for (const ch of row) {
394
+ const code = ch.codePointAt(0) ?? 0;
395
+ if (code >= 0x2500 && code <= 0x257f) unicode++;
396
+ else if (ch === '+' || ch === '|' || ch === '-') ascii++;
397
+ }
398
+ }
399
+ return ascii > unicode ? 'ascii' : 'unicode';
400
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Public types for the ASCII diagram codec.
3
+ *
4
+ * Coordinates are grid-native: `col`/`row` count character cells from the
5
+ * top-left of the art (0-based), `wCols`/`hRows` include the borders. The
6
+ * mapping helpers in `mapping.ts` convert to/from diagram canvas units
7
+ * via {@link ASCII_CHAR_W} / {@link ASCII_CHAR_H}.
8
+ */
9
+
10
+ export interface AsciiDiagramNode {
11
+ /**
12
+ * Stable id: slug of the first label line, deduplicated with `-2`/`-3`
13
+ * suffixes in reading order. Identical art always re-parses to
14
+ * identical ids (editor selection and edges resolve by id).
15
+ */
16
+ id: string;
17
+ /** Interior text lines joined with `\n` (trimmed, blank lines dropped). May be `''`. */
18
+ label: string;
19
+ /** Left column of the box (0-based, includes the border). */
20
+ col: number;
21
+ /** Top row of the box (0-based, includes the border). */
22
+ row: number;
23
+ /** Full box width in columns, borders included. */
24
+ wCols: number;
25
+ /** Full box height in rows, borders included. */
26
+ hRows: number;
27
+ /** Id of the box that strictly contains this one, when nested. */
28
+ containerId?: string;
29
+ }
30
+
31
+ export interface AsciiDiagramEdge {
32
+ source: string;
33
+ target: string;
34
+ /** Edge label parsed from an embedded horizontal run (`──label──▶`). */
35
+ label?: string;
36
+ /** False when the line carried no arrowhead. */
37
+ directed: boolean;
38
+ }
39
+
40
+ export interface AsciiDiagram {
41
+ /** Nodes in reading order (row, then col). Containers are ordinary nodes with children pointing at them. */
42
+ nodes: AsciiDiagramNode[];
43
+ /** Deduplicated edges, sorted by (source, target, label). */
44
+ edges: AsciiDiagramEdge[];
45
+ /** Character vocabulary detected in the source; render emits the same style. */
46
+ style: 'unicode' | 'ascii';
47
+ /** Non-fatal parse notes (loose text, dropped labels, ambiguous buses, …). */
48
+ warnings: string[];
49
+ }
50
+
51
+ export interface AsciiDiagramDetection {
52
+ isDiagram: boolean;
53
+ /** Present when `isDiagram` — avoids a second parse in callers. */
54
+ diagram?: AsciiDiagram;
55
+ /** Machine-greppable accept/reject reasons, e.g. `too-few-boxes(1)`, `loose-ratio(0.41)`. */
56
+ reasons: string[];
57
+ }
58
+
59
+ /**
60
+ * Canvas pixels per grid column/row. Sized so a typical 12-col × 3-row
61
+ * ASCII box maps close to the native 180×64 diagram card, and a 60–100
62
+ * column diagram fits the 1600×900 diagram viewport.
63
+ */
64
+ export const ASCII_CHAR_W = 14;
65
+ export const ASCII_CHAR_H = 28;
@@ -0,0 +1,138 @@
1
+ /** Conservative detection for authored ASCII timeline fences. */
2
+
3
+ import type { MarkdownCodeBlock } from '../../markdown/types.js';
4
+ import { parseAsciiDiagramWithStats } from '../asciiDiagram/parse.js';
5
+ import { parseAsciiTimelineWithStats } from './parse.js';
6
+ import type { AsciiTimelineDetection } from './types.js';
7
+
8
+ export const ASCII_TIMELINE_FENCE_LANGS: ReadonlySet<string> = new Set([
9
+ 'text',
10
+ 'txt',
11
+ 'plaintext',
12
+ 'plain',
13
+ 'ascii',
14
+ 'timeline',
15
+ ]);
16
+
17
+ const MAX_LINES = 400;
18
+ const MAX_CANONICAL_LINES = 2000;
19
+ const MAX_COLS = 400;
20
+ const MAX_EXPLICIT_COLS = 4096;
21
+ const BOX_TOP_EDGE_RE = /[┌┏╔╭].*[┐┓╗╮]/u;
22
+ const CANONICAL_TRACK_RE = /\{#[^{}\s]+ start=[^{}\s]+ end=[^{}\s]+\}:\s/u;
23
+
24
+ export function isEligibleAsciiTimelineFenceLang(lang: string | null | undefined): boolean {
25
+ if (lang === null || lang === undefined) return true;
26
+ const normalized = lang.trim().toLowerCase();
27
+ return normalized === '' || ASCII_TIMELINE_FENCE_LANGS.has(normalized);
28
+ }
29
+
30
+ export function isExplicitTimelineLang(lang: string | null | undefined): boolean {
31
+ return typeof lang === 'string' && lang.trim().toLowerCase() === 'timeline';
32
+ }
33
+
34
+ export interface DetectAsciiTimelineOptions {
35
+ /** Explicit `timeline` language: allow one-point/ASCII-marker tracks. */
36
+ explicit?: boolean;
37
+ }
38
+
39
+ export function detectAsciiTimeline(
40
+ text: string,
41
+ options: DetectAsciiTimelineOptions = {},
42
+ ): AsciiTimelineDetection {
43
+ const explicit = options.explicit === true;
44
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
45
+ const canonicalTrackRows = lines.filter((line) => CANONICAL_TRACK_RE.test(line)).length;
46
+ const canonicalLineBudget = Math.min(
47
+ MAX_CANONICAL_LINES,
48
+ canonicalTrackRows * 6 +
49
+ lines.filter((line) => /^\s*(?:branch|link)\s*:/iu.test(line)).length +
50
+ 2,
51
+ );
52
+ if (
53
+ lines.length > MAX_LINES &&
54
+ (canonicalTrackRows === 0 || lines.length > canonicalLineBudget)
55
+ ) {
56
+ return { isTimeline: false, reasons: [`too-many-lines(${lines.length})`] };
57
+ }
58
+ const maxCols = Math.max(0, ...lines.map((line) => Array.from(line).length));
59
+ // Canonical renderer output carries bounded track metadata on every long
60
+ // row. Permit that high-confidence form up to the explicit-fence cap while
61
+ // retaining the conservative 400-column limit for arbitrary untagged art.
62
+ const canonicalLongForm =
63
+ canonicalTrackRows > 0 &&
64
+ lines.every((line) => Array.from(line).length <= MAX_COLS || CANONICAL_TRACK_RE.test(line));
65
+ const maxAllowedCols = explicit || canonicalLongForm ? MAX_EXPLICIT_COLS : MAX_COLS;
66
+ if (maxCols > maxAllowedCols) {
67
+ return { isTimeline: false, reasons: [`too-wide(${maxCols})`] };
68
+ }
69
+
70
+ const nonBlank = lines.filter((line) => line.trim().length > 0);
71
+ const pipeRows = nonBlank.filter((line) => /^\s*\|.*\|\s*$/.test(line)).length;
72
+ const hasGfmSeparator = nonBlank.some(
73
+ (line) => /^\s*\|?[\s:|-]+\|?\s*$/.test(line) && line.includes('-'),
74
+ );
75
+ if (pipeRows > nonBlank.length / 2 && hasGfmSeparator) {
76
+ return { isTimeline: false, reasons: ['markdown-table'] };
77
+ }
78
+
79
+ if (!explicit && lines.some((line) => BOX_TOP_EDGE_RE.test(line))) {
80
+ return { isTimeline: false, reasons: ['has-box-corners'] };
81
+ }
82
+
83
+ // Closed boxes own the art even if their borders happen to contain dots.
84
+ // Untagged detection rejects even one box; the diagram auto-template needs
85
+ // two, but silently turning a single boxed callout into a timeline is worse
86
+ // than leaving it as authored code. An explicit timeline may use one boxed
87
+ // callout, while two or more still belong to the diagram codec.
88
+ const boxes = parseAsciiDiagramWithStats(text).diagram.nodes.length;
89
+ if (boxes >= (explicit ? 2 : 1)) {
90
+ return { isTimeline: false, reasons: [`has-boxes(${boxes})`] };
91
+ }
92
+
93
+ // A tree item may itself contain a marker rail in its label. Connector
94
+ // prefixes (`├── ●──●`, `|-- ●--●`) identify the outer structure as a
95
+ // tree, so conservative untagged detection must not claim that row.
96
+ if (!explicit && /^[ \t]*(?:[│|][ \t]*)*[├└+|`][─━═-]{1,4}[ \t]+[●○◉◆◇•]/mu.test(text)) {
97
+ return { isTimeline: false, reasons: ['tree-connector-prefix'] };
98
+ }
99
+
100
+ const { timeline, stats } = parseAsciiTimelineWithStats(text);
101
+ if (stats.wrappedFlow) {
102
+ return {
103
+ isTimeline: true,
104
+ timeline,
105
+ reasons: [`tracks(${stats.axisLines})`, `events(${stats.markerCount})`, 'wrapped-flow'],
106
+ };
107
+ }
108
+ if (stats.axisLines === 0) return { isTimeline: false, reasons: ['no-axis-lines'] };
109
+ const explicitCadence = explicit && stats.horizontalChars === 0 && stats.markerCount >= 4;
110
+ if (!explicitCadence && stats.horizontalChars < (explicit ? 2 : 8)) {
111
+ return { isTimeline: false, reasons: [`too-few-horizontal(${stats.horizontalChars})`] };
112
+ }
113
+ if (stats.markerCount < (explicit ? 1 : 2)) {
114
+ return { isTimeline: false, reasons: [`too-few-markers(${stats.markerCount})`] };
115
+ }
116
+ // Untagged auto-detection requires unmistakable Unicode point markers.
117
+ // `foo * bar *` or `o---o` in real code therefore stays ordinary code.
118
+ if (!explicit && stats.strongMarkerCount < 2) {
119
+ return { isTimeline: false, reasons: [`too-few-strong-markers(${stats.strongMarkerCount})`] };
120
+ }
121
+
122
+ return {
123
+ isTimeline: true,
124
+ timeline,
125
+ reasons: [
126
+ `tracks(${stats.axisLines})`,
127
+ `events(${stats.markerCount + stats.pointerCount})`,
128
+ explicit ? 'explicit' : 'strong-markers',
129
+ ],
130
+ };
131
+ }
132
+
133
+ export function isAsciiTimelineFence(node: MarkdownCodeBlock): boolean {
134
+ if (node.type !== 'code') return false;
135
+ if (!isEligibleAsciiTimelineFenceLang(node.lang)) return false;
136
+ return detectAsciiTimeline(node.value, { explicit: isExplicitTimelineLang(node.lang) })
137
+ .isTimeline;
138
+ }
@@ -0,0 +1,26 @@
1
+ /** Public authored ASCII timeline codec. */
2
+
3
+ export { parseAsciiTimeline, parseAsciiTimelineWithStats } from './parse.js';
4
+ export type { AsciiTimelineStats } from './parse.js';
5
+ export { renderAsciiTimeline } from './render.js';
6
+ export type { RenderAsciiTimelineOptions } from './render.js';
7
+ export {
8
+ ASCII_TIMELINE_FENCE_LANGS,
9
+ detectAsciiTimeline,
10
+ isAsciiTimelineFence,
11
+ isEligibleAsciiTimelineFenceLang,
12
+ isExplicitTimelineLang,
13
+ } from './detect.js';
14
+ export type { DetectAsciiTimelineOptions } from './detect.js';
15
+ export { asciiTimelineToTemplateData } from './mapping.js';
16
+ export { isWrappedFlowTimelineSource, parseWrappedFlowTimeline } from './wrappedFlow.js';
17
+ export type {
18
+ AsciiTimeline,
19
+ AsciiTimelineDetection,
20
+ AsciiTimelineEvent,
21
+ AsciiTimelineLink,
22
+ AsciiTimelineMarker,
23
+ AsciiTimelineSide,
24
+ AsciiTimelineStyle,
25
+ AsciiTimelineTrack,
26
+ } from './types.js';
@@ -0,0 +1,43 @@
1
+ /** Mapping from source-grid timeline data to normalized template inputs. */
2
+
3
+ import type {
4
+ TimelineTemplateEvent,
5
+ TimelineTemplateLink,
6
+ TimelineTemplateTrack,
7
+ } from '../../schemas/BlockTemplates.js';
8
+ import type { AsciiTimeline } from './types.js';
9
+
10
+ export function asciiTimelineToTemplateData(timeline: AsciiTimeline): {
11
+ tracks: TimelineTemplateTrack[];
12
+ links: TimelineTemplateLink[];
13
+ } {
14
+ const globalStart =
15
+ timeline.tracks.length > 0 ? Math.min(...timeline.tracks.map((track) => track.startColumn)) : 0;
16
+ const globalEnd =
17
+ timeline.tracks.length > 0 ? Math.max(...timeline.tracks.map((track) => track.endColumn)) : 1;
18
+ const span = Math.max(1, globalEnd - globalStart);
19
+
20
+ const tracks: TimelineTemplateTrack[] = timeline.tracks.map((track) => ({
21
+ id: track.id,
22
+ ...(track.label ? { label: track.label } : {}),
23
+ ...(track.endLabel ? { endLabel: track.endLabel } : {}),
24
+ events: track.events.map(
25
+ (event): TimelineTemplateEvent => ({
26
+ id: event.id,
27
+ label: event.label,
28
+ ...(event.description ? { description: event.description } : {}),
29
+ position: clamp01((event.column - globalStart) / span),
30
+ ...(event.side ? { side: event.side } : {}),
31
+ ...(event.descriptionSide ? { descriptionSide: event.descriptionSide } : {}),
32
+ ...(event.callout === false ? { callout: false } : {}),
33
+ ...(event.marker ? { marker: event.marker } : {}),
34
+ }),
35
+ ),
36
+ }));
37
+ const links: TimelineTemplateLink[] = timeline.links.map((link) => ({ ...link }));
38
+ return { tracks, links };
39
+ }
40
+
41
+ function clamp01(value: number): number {
42
+ return Math.max(0, Math.min(1, value));
43
+ }