zombie-mermaid 1.1.3

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 (252) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/dist/cli.js +11194 -0
  4. package/dist/index.cjs +11060 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +344 -0
  7. package/dist/index.d.ts +344 -0
  8. package/dist/index.js +11014 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +104 -0
  11. package/src/__tests__/ascii-ansi.test.ts +498 -0
  12. package/src/__tests__/ascii-canvas-write.test.ts +137 -0
  13. package/src/__tests__/ascii-charset-border-junctions.test.ts +63 -0
  14. package/src/__tests__/ascii-cjk-width.test.ts +185 -0
  15. package/src/__tests__/ascii-class-diagram-compartments.test.ts +70 -0
  16. package/src/__tests__/ascii-decision-lr-box-start.test.ts +106 -0
  17. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +171 -0
  18. package/src/__tests__/ascii-draw-arrows-single-point-path.test.ts +105 -0
  19. package/src/__tests__/ascii-draw-bundles.test.ts +638 -0
  20. package/src/__tests__/ascii-edge-bundling-routing.test.ts +210 -0
  21. package/src/__tests__/ascii-edge-routing-fixes.test.ts +178 -0
  22. package/src/__tests__/ascii-edge-routing-single-point-path.test.ts +103 -0
  23. package/src/__tests__/ascii-edge-styles.test.ts +149 -0
  24. package/src/__tests__/ascii-er-cardinality.test.ts +40 -0
  25. package/src/__tests__/ascii-er-label-padding.test.ts +118 -0
  26. package/src/__tests__/ascii-grid-deep-chain.test.ts +64 -0
  27. package/src/__tests__/ascii-multiline.test.ts +288 -0
  28. package/src/__tests__/ascii-pathfinder-route-edge.test.ts +184 -0
  29. package/src/__tests__/ascii-sequence-notes.test.ts +25 -0
  30. package/src/__tests__/ascii-sequence-self-arrow.test.ts +128 -0
  31. package/src/__tests__/ascii-shape-diamond.test.ts +38 -0
  32. package/src/__tests__/ascii-shape-rectangle.test.ts +258 -0
  33. package/src/__tests__/ascii-shape-rounded.test.ts +36 -0
  34. package/src/__tests__/ascii-shapes-circle.test.ts +41 -0
  35. package/src/__tests__/ascii-shapes-hexagon.test.ts +42 -0
  36. package/src/__tests__/ascii-shapes-special.test.ts +296 -0
  37. package/src/__tests__/ascii-shapes-stadium.test.ts +217 -0
  38. package/src/__tests__/ascii-shapes-state.test.ts +224 -0
  39. package/src/__tests__/ascii-subgraph-bbox.test.ts +135 -0
  40. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +89 -0
  41. package/src/__tests__/ascii-subgraph-title-padding.test.ts +77 -0
  42. package/src/__tests__/ascii-validate.test.ts +220 -0
  43. package/src/__tests__/ascii.test.ts +325 -0
  44. package/src/__tests__/browser.test.ts +34 -0
  45. package/src/__tests__/class-arrow-directions.test.ts +413 -0
  46. package/src/__tests__/class-integration.test.ts +118 -0
  47. package/src/__tests__/class-parser.test.ts +241 -0
  48. package/src/__tests__/cli-diagrams.test.ts +101 -0
  49. package/src/__tests__/cli-e2e.test.ts +213 -0
  50. package/src/__tests__/cli-entrypoint.test.ts +146 -0
  51. package/src/__tests__/cli-render.test.ts +175 -0
  52. package/src/__tests__/cli-test-helpers.ts +26 -0
  53. package/src/__tests__/cli.test.ts +204 -0
  54. package/src/__tests__/diagram-type.test.ts +28 -0
  55. package/src/__tests__/draw-lines.test.ts +93 -0
  56. package/src/__tests__/edge-approach-direction.test.ts +295 -0
  57. package/src/__tests__/er-integration.test.ts +528 -0
  58. package/src/__tests__/er-parser.test.ts +297 -0
  59. package/src/__tests__/grid-occupancy.test.ts +80 -0
  60. package/src/__tests__/integration.test.ts +595 -0
  61. package/src/__tests__/layout-disconnected.test.ts +384 -0
  62. package/src/__tests__/layout-engine.test.ts +88 -0
  63. package/src/__tests__/linkstyle.test.ts +133 -0
  64. package/src/__tests__/multiline-labels.test.ts +912 -0
  65. package/src/__tests__/nested-subgraph-direction-crossing.test.ts +304 -0
  66. package/src/__tests__/parser.test.ts +1187 -0
  67. package/src/__tests__/pathfinder.test.ts +239 -0
  68. package/src/__tests__/render-options-overrides.test.ts +250 -0
  69. package/src/__tests__/renderer.test.ts +743 -0
  70. package/src/__tests__/sequence-integration.test.ts +121 -0
  71. package/src/__tests__/sequence-layout.test.ts +861 -0
  72. package/src/__tests__/sequence-parser.test.ts +313 -0
  73. package/src/__tests__/styles.test.ts +201 -0
  74. package/src/__tests__/testdata/ascii/ampersand_lhs.txt +18 -0
  75. package/src/__tests__/testdata/ascii/ampersand_lhs_and_rhs.txt +18 -0
  76. package/src/__tests__/testdata/ascii/ampersand_rhs.txt +18 -0
  77. package/src/__tests__/testdata/ascii/ampersand_td_fanin.txt +18 -0
  78. package/src/__tests__/testdata/ascii/ampersand_td_fanout.txt +18 -0
  79. package/src/__tests__/testdata/ascii/ampersand_without_edge.txt +18 -0
  80. package/src/__tests__/testdata/ascii/back_reference_from_child.txt +10 -0
  81. package/src/__tests__/testdata/ascii/backlink_from_bottom.txt +22 -0
  82. package/src/__tests__/testdata/ascii/backlink_from_top.txt +22 -0
  83. package/src/__tests__/testdata/ascii/backlink_with_short_y_padding.txt +20 -0
  84. package/src/__tests__/testdata/ascii/cls_all_relationships.txt +17 -0
  85. package/src/__tests__/testdata/ascii/cls_annotation.txt +29 -0
  86. package/src/__tests__/testdata/ascii/cls_association.txt +14 -0
  87. package/src/__tests__/testdata/ascii/cls_basic.txt +15 -0
  88. package/src/__tests__/testdata/ascii/cls_dependency.txt +14 -0
  89. package/src/__tests__/testdata/ascii/cls_inheritance.txt +20 -0
  90. package/src/__tests__/testdata/ascii/cls_methods.txt +21 -0
  91. package/src/__tests__/testdata/ascii/comments.txt +23 -0
  92. package/src/__tests__/testdata/ascii/custom_padding.txt +10 -0
  93. package/src/__tests__/testdata/ascii/duplicate_labels.txt +19 -0
  94. package/src/__tests__/testdata/ascii/er_attributes.txt +21 -0
  95. package/src/__tests__/testdata/ascii/er_basic.txt +8 -0
  96. package/src/__tests__/testdata/ascii/er_identifying.txt +18 -0
  97. package/src/__tests__/testdata/ascii/flowchart_tb_simple.txt +29 -0
  98. package/src/__tests__/testdata/ascii/graph_bt_direction.txt +28 -0
  99. package/src/__tests__/testdata/ascii/graph_tb_direction.txt +26 -0
  100. package/src/__tests__/testdata/ascii/nested_subgraphs_with_labels.txt +36 -0
  101. package/src/__tests__/testdata/ascii/preserve_order_of_definition.txt +23 -0
  102. package/src/__tests__/testdata/ascii/self_reference.txt +10 -0
  103. package/src/__tests__/testdata/ascii/self_reference_with_edge.txt +10 -0
  104. package/src/__tests__/testdata/ascii/seq_basic.txt +17 -0
  105. package/src/__tests__/testdata/ascii/seq_multiple_messages.txt +25 -0
  106. package/src/__tests__/testdata/ascii/seq_self_message.txt +18 -0
  107. package/src/__tests__/testdata/ascii/single_node.txt +8 -0
  108. package/src/__tests__/testdata/ascii/single_node_longer_name.txt +8 -0
  109. package/src/__tests__/testdata/ascii/subgraph_complex_mixed.txt +38 -0
  110. package/src/__tests__/testdata/ascii/subgraph_complex_nested.txt +49 -0
  111. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +37 -0
  112. package/src/__tests__/testdata/ascii/subgraph_empty.txt +10 -0
  113. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes.txt +20 -0
  114. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +48 -0
  115. package/src/__tests__/testdata/ascii/subgraph_multiple_edges.txt +32 -0
  116. package/src/__tests__/testdata/ascii/subgraph_multiple_nodes.txt +16 -0
  117. package/src/__tests__/testdata/ascii/subgraph_nested.txt +24 -0
  118. package/src/__tests__/testdata/ascii/subgraph_nested_with_external.txt +30 -0
  119. package/src/__tests__/testdata/ascii/subgraph_node_outside_lr.txt +17 -0
  120. package/src/__tests__/testdata/ascii/subgraph_single_node.txt +16 -0
  121. package/src/__tests__/testdata/ascii/subgraph_td_direction.txt +26 -0
  122. package/src/__tests__/testdata/ascii/subgraph_td_multiple.txt +44 -0
  123. package/src/__tests__/testdata/ascii/subgraph_td_multiple_paddingy.txt +42 -0
  124. package/src/__tests__/testdata/ascii/subgraph_three_levels_nested.txt +32 -0
  125. package/src/__tests__/testdata/ascii/subgraph_three_separate.txt +24 -0
  126. package/src/__tests__/testdata/ascii/subgraph_two_separate.txt +20 -0
  127. package/src/__tests__/testdata/ascii/subgraph_with_labels.txt +20 -0
  128. package/src/__tests__/testdata/ascii/three_nodes.txt +9 -0
  129. package/src/__tests__/testdata/ascii/three_nodes_single_line.txt +8 -0
  130. package/src/__tests__/testdata/ascii/two_layer_single_graph.txt +19 -0
  131. package/src/__tests__/testdata/ascii/two_layer_single_graph_longer_names.txt +19 -0
  132. package/src/__tests__/testdata/ascii/two_nodes_linked.txt +8 -0
  133. package/src/__tests__/testdata/ascii/two_nodes_longer_names.txt +8 -0
  134. package/src/__tests__/testdata/ascii/two_root_nodes.txt +19 -0
  135. package/src/__tests__/testdata/ascii/two_root_nodes_longer_names.txt +19 -0
  136. package/src/__tests__/testdata/ascii/two_single_root_nodes.txt +19 -0
  137. package/src/__tests__/testdata/unicode/ampersand_lhs.txt +18 -0
  138. package/src/__tests__/testdata/unicode/ampersand_lhs_and_rhs.txt +18 -0
  139. package/src/__tests__/testdata/unicode/ampersand_rhs.txt +18 -0
  140. package/src/__tests__/testdata/unicode/ampersand_without_edge.txt +18 -0
  141. package/src/__tests__/testdata/unicode/back_reference_from_child.txt +10 -0
  142. package/src/__tests__/testdata/unicode/backlink_from_bottom.txt +22 -0
  143. package/src/__tests__/testdata/unicode/backlink_from_top.txt +22 -0
  144. package/src/__tests__/testdata/unicode/cls_all_relationships.txt +17 -0
  145. package/src/__tests__/testdata/unicode/cls_annotation.txt +29 -0
  146. package/src/__tests__/testdata/unicode/cls_association.txt +14 -0
  147. package/src/__tests__/testdata/unicode/cls_basic.txt +15 -0
  148. package/src/__tests__/testdata/unicode/cls_dependency.txt +14 -0
  149. package/src/__tests__/testdata/unicode/cls_inheritance.txt +20 -0
  150. package/src/__tests__/testdata/unicode/cls_methods.txt +21 -0
  151. package/src/__tests__/testdata/unicode/comments.txt +23 -0
  152. package/src/__tests__/testdata/unicode/duplicate_labels.txt +19 -0
  153. package/src/__tests__/testdata/unicode/er_attributes.txt +21 -0
  154. package/src/__tests__/testdata/unicode/er_basic.txt +8 -0
  155. package/src/__tests__/testdata/unicode/er_identifying.txt +18 -0
  156. package/src/__tests__/testdata/unicode/graph_bt_direction.txt +28 -0
  157. package/src/__tests__/testdata/unicode/preserve_order_of_definition.txt +23 -0
  158. package/src/__tests__/testdata/unicode/self_reference.txt +10 -0
  159. package/src/__tests__/testdata/unicode/self_reference_with_edge.txt +10 -0
  160. package/src/__tests__/testdata/unicode/seq_basic.txt +17 -0
  161. package/src/__tests__/testdata/unicode/seq_multiple_messages.txt +25 -0
  162. package/src/__tests__/testdata/unicode/seq_self_message.txt +18 -0
  163. package/src/__tests__/testdata/unicode/single_node.txt +8 -0
  164. package/src/__tests__/testdata/unicode/single_node_longer_name.txt +8 -0
  165. package/src/__tests__/testdata/unicode/three_nodes.txt +9 -0
  166. package/src/__tests__/testdata/unicode/three_nodes_single_line.txt +8 -0
  167. package/src/__tests__/testdata/unicode/two_layer_single_graph.txt +19 -0
  168. package/src/__tests__/testdata/unicode/two_layer_single_graph_longer_names.txt +19 -0
  169. package/src/__tests__/testdata/unicode/two_nodes_linked.txt +8 -0
  170. package/src/__tests__/testdata/unicode/two_nodes_longer_names.txt +8 -0
  171. package/src/__tests__/testdata/unicode/two_root_nodes.txt +19 -0
  172. package/src/__tests__/testdata/unicode/two_root_nodes_longer_names.txt +19 -0
  173. package/src/__tests__/testdata/unicode/two_single_root_nodes.txt +19 -0
  174. package/src/__tests__/text-metrics.test.ts +298 -0
  175. package/src/__tests__/theme.test.ts +108 -0
  176. package/src/__tests__/xychart-ascii.test.ts +327 -0
  177. package/src/__tests__/xychart-colors.test.ts +148 -0
  178. package/src/__tests__/xychart-integration.test.ts +137 -0
  179. package/src/__tests__/xychart-layout.test.ts +167 -0
  180. package/src/ascii/ansi.ts +500 -0
  181. package/src/ascii/canvas.ts +633 -0
  182. package/src/ascii/class-diagram.ts +765 -0
  183. package/src/ascii/converter.ts +393 -0
  184. package/src/ascii/display-width.ts +65 -0
  185. package/src/ascii/draw-arrows.ts +468 -0
  186. package/src/ascii/draw-boxes.ts +231 -0
  187. package/src/ascii/draw-bundles.ts +488 -0
  188. package/src/ascii/draw-lines.ts +165 -0
  189. package/src/ascii/draw-subgraphs.ts +93 -0
  190. package/src/ascii/draw.ts +303 -0
  191. package/src/ascii/edge-bundling.ts +343 -0
  192. package/src/ascii/edge-routing.ts +431 -0
  193. package/src/ascii/er-diagram.ts +565 -0
  194. package/src/ascii/grid-occupancy.ts +136 -0
  195. package/src/ascii/grid.ts +894 -0
  196. package/src/ascii/index.ts +166 -0
  197. package/src/ascii/multiline-utils.ts +82 -0
  198. package/src/ascii/pathfinder.ts +446 -0
  199. package/src/ascii/sequence.ts +578 -0
  200. package/src/ascii/shapes/circle.ts +31 -0
  201. package/src/ascii/shapes/corners.ts +130 -0
  202. package/src/ascii/shapes/diamond.ts +31 -0
  203. package/src/ascii/shapes/hexagon.ts +31 -0
  204. package/src/ascii/shapes/index.ts +119 -0
  205. package/src/ascii/shapes/rectangle.ts +199 -0
  206. package/src/ascii/shapes/rounded.ts +31 -0
  207. package/src/ascii/shapes/special.ts +310 -0
  208. package/src/ascii/shapes/stadium.ts +122 -0
  209. package/src/ascii/shapes/state.ts +204 -0
  210. package/src/ascii/shapes/types.ts +78 -0
  211. package/src/ascii/types.ts +373 -0
  212. package/src/ascii/validate.ts +189 -0
  213. package/src/ascii/xychart.ts +1060 -0
  214. package/src/browser.ts +36 -0
  215. package/src/class/layout.ts +274 -0
  216. package/src/class/parser.ts +347 -0
  217. package/src/class/renderer.ts +449 -0
  218. package/src/class/types.ts +121 -0
  219. package/src/cli/parse-args.ts +121 -0
  220. package/src/cli/render.ts +101 -0
  221. package/src/cli.ts +82 -0
  222. package/src/diagram-type.ts +32 -0
  223. package/src/elk-instance.ts +159 -0
  224. package/src/er/layout.ts +229 -0
  225. package/src/er/parser.ts +253 -0
  226. package/src/er/renderer.ts +474 -0
  227. package/src/er/types.ts +99 -0
  228. package/src/index.ts +174 -0
  229. package/src/layout-engine/constants.ts +19 -0
  230. package/src/layout-engine/edge-bundling.ts +287 -0
  231. package/src/layout-engine/from-elk.ts +616 -0
  232. package/src/layout-engine/layer-alignment.ts +170 -0
  233. package/src/layout-engine/to-elk.ts +645 -0
  234. package/src/layout-engine.ts +70 -0
  235. package/src/layout.ts +8 -0
  236. package/src/multiline-utils.ts +246 -0
  237. package/src/parser.ts +833 -0
  238. package/src/renderer.ts +810 -0
  239. package/src/sequence/layout.ts +561 -0
  240. package/src/sequence/parser.ts +283 -0
  241. package/src/sequence/renderer.ts +410 -0
  242. package/src/sequence/types.ts +146 -0
  243. package/src/shape-clipping.ts +197 -0
  244. package/src/styles.ts +118 -0
  245. package/src/text-metrics.ts +291 -0
  246. package/src/theme.ts +498 -0
  247. package/src/types.ts +198 -0
  248. package/src/xychart/colors.ts +177 -0
  249. package/src/xychart/layout.ts +682 -0
  250. package/src/xychart/parser.ts +125 -0
  251. package/src/xychart/renderer.ts +657 -0
  252. package/src/xychart/types.ts +150 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Coverage-focused tests for src/xychart/layout.ts.
3
+ *
4
+ * Exercises the horizontal layout path (previously untested), numeric
5
+ * x-axis ranges (category label interpolation), multi-series bar groups,
6
+ * and axis-title branch combinations that xychart-integration.test.ts
7
+ * doesn't reach.
8
+ */
9
+ import { describe, it, expect } from 'vitest'
10
+ import { renderMermaid } from '../index.ts'
11
+
12
+ describe('xychart – horizontal layout', () => {
13
+ it('lays out a single-series horizontal bar chart', async () => {
14
+ const svg = await renderMermaid(`xychart-beta horizontal
15
+ x-axis [A, B, C]
16
+ y-axis 0 --> 100
17
+ bar [10, 50, 90]`)
18
+ expect(svg).toContain('data-value="10"')
19
+ expect(svg).toContain('data-value="90"')
20
+ expect(svg).toContain('data-label="A"')
21
+ expect(svg).toContain('data-label="C"')
22
+ expect(svg).not.toContain('NaN')
23
+ })
24
+
25
+ it('lays out a horizontal chart with title, axis titles, multiple bar series, and a line series', async () => {
26
+ const svg = await renderMermaid(`xychart-beta horizontal
27
+ title "Horizontal Combo"
28
+ x-axis "Category" [A, B, C]
29
+ y-axis "Value" 0 --> 100
30
+ bar [10, 50, 90]
31
+ bar [20, 40, 60]
32
+ line [15, 45, 75]`)
33
+ expect(svg).toContain('Horizontal Combo')
34
+ expect(svg).toContain('Category')
35
+ expect(svg).toContain('Value')
36
+ expect(svg).toContain('Bar 1')
37
+ expect(svg).toContain('Bar 2')
38
+ expect(svg).toContain('Line 1')
39
+ expect(svg).toContain('data-value="20"')
40
+ expect(svg).toContain('data-value="60"')
41
+ expect(svg).toContain('data-value="15"')
42
+ expect(svg).not.toContain('NaN')
43
+ })
44
+
45
+ it('lays out a horizontal chart with only a line series (no bars)', async () => {
46
+ const svg = await renderMermaid(`xychart-beta horizontal
47
+ x-axis [X, Y]
48
+ y-axis 0 --> 10
49
+ line [3, 8]`)
50
+ expect(svg).toContain('data-value="3"')
51
+ expect(svg).toContain('data-value="8"')
52
+ expect(svg).toContain('data-label="X"')
53
+ expect(svg).not.toContain('NaN')
54
+ })
55
+
56
+ it('lays out a horizontal chart with negative values', async () => {
57
+ const svg = await renderMermaid(`xychart-beta horizontal
58
+ x-axis [A, B, C]
59
+ y-axis -50 --> 50
60
+ bar [-30, 20, 40]`)
61
+ expect(svg).toContain('data-value="-30"')
62
+ expect(svg).toContain('data-value="40"')
63
+ expect(svg).not.toContain('NaN')
64
+ })
65
+ })
66
+
67
+ describe('xychart – numeric x-axis range (category interpolation)', () => {
68
+ it('interpolates category labels across a numeric x-axis range with a title', async () => {
69
+ const svg = await renderMermaid(`xychart-beta
70
+ x-axis "Distance" 0 --> 17
71
+ y-axis "Value" 0 --> 100
72
+ bar [10, 40, 70, 90]`)
73
+ expect(svg).toContain('Distance')
74
+ expect(svg).toContain('data-label="0"')
75
+ expect(svg).toContain('data-label="17"')
76
+ expect(svg).toContain('data-label="5.7"')
77
+ expect(svg).toContain('data-label="11"')
78
+ expect(svg).not.toContain('NaN')
79
+ })
80
+
81
+ it('falls back to numeric index labels when x-axis is unspecified', async () => {
82
+ const svg = await renderMermaid(`xychart-beta
83
+ y-axis 0 --> 100
84
+ bar [10, 20, 30]`)
85
+ expect(svg).toContain('data-value="10"')
86
+ expect(svg).toContain('data-label="1"')
87
+ expect(svg).toContain('data-label="3"')
88
+ expect(svg).not.toContain('NaN')
89
+ })
90
+
91
+ it('handles a single-point series on a numeric x-axis range', async () => {
92
+ const svg = await renderMermaid(`xychart-beta
93
+ x-axis 0 --> 10
94
+ y-axis 0 --> 50
95
+ bar [25]`)
96
+ expect(svg).toContain('data-value="25"')
97
+ expect(svg).toContain('data-label="0"')
98
+ expect(svg).not.toContain('NaN')
99
+ })
100
+ })
101
+
102
+ describe('xychart – vertical layout branch coverage', () => {
103
+ it('lays out a chart without a y-axis title (single series, no legend)', async () => {
104
+ const svg = await renderMermaid(`xychart-beta
105
+ x-axis [A, B]
106
+ y-axis 0 --> 10
107
+ bar [3, 8]`)
108
+ expect(svg).toContain('data-value="3"')
109
+ expect(svg).toContain('data-value="8"')
110
+ expect(svg).not.toContain('class="xychart-axis-title"')
111
+ })
112
+
113
+ it('lays out a chart with two bar series and a line series (multi-bar grouping)', async () => {
114
+ const svg = await renderMermaid(`xychart-beta
115
+ title "Multi Bar"
116
+ x-axis [Q1, Q2, Q3]
117
+ y-axis "Sales" 0 --> 200
118
+ bar [50, 80, 120]
119
+ bar [30, 60, 90]
120
+ line [40, 70, 100]`)
121
+ expect(svg).toContain('Multi Bar')
122
+ expect(svg).toContain('Bar 1')
123
+ expect(svg).toContain('Bar 2')
124
+ expect(svg).toContain('Line 1')
125
+ expect(svg).toContain('data-value="120"')
126
+ expect(svg).toContain('data-value="90"')
127
+ expect(svg).not.toContain('NaN')
128
+ })
129
+
130
+ it('lays out a chart with negative values (baseline clamped to zero)', async () => {
131
+ const svg = await renderMermaid(`xychart-beta
132
+ x-axis [A, B, C]
133
+ y-axis -50 --> 50
134
+ bar [-30, 20, 40]`)
135
+ expect(svg).toContain('data-value="-30"')
136
+ expect(svg).toContain('data-value="40"')
137
+ expect(svg).not.toContain('NaN')
138
+ })
139
+
140
+ it('renders a chart with no series at all', async () => {
141
+ const svg = await renderMermaid(`xychart-beta
142
+ x-axis 0 --> 10
143
+ y-axis 0 --> 100`)
144
+ expect(svg).toContain('<svg')
145
+ expect(svg).not.toContain('NaN')
146
+ })
147
+
148
+ it('handles a degenerate y-axis range where min equals max', async () => {
149
+ const svg = await renderMermaid(`xychart-beta
150
+ x-axis [A, B]
151
+ y-axis 5 --> 5
152
+ bar [5, 5]`)
153
+ expect(svg).toContain('<svg')
154
+ expect(svg).toContain('data-value="5"')
155
+ expect(svg).not.toContain('NaN')
156
+ })
157
+
158
+ it('picks a tick interval equal to the magnitude for a small range', async () => {
159
+ const svg = await renderMermaid(`xychart-beta
160
+ x-axis [A, B]
161
+ y-axis 0 --> 6
162
+ bar [2, 5]`)
163
+ expect(svg).toContain('data-label="A"')
164
+ expect(svg).toContain('data-value="2"')
165
+ expect(svg).not.toContain('NaN')
166
+ })
167
+ })
@@ -0,0 +1,500 @@
1
+ // ============================================================================
2
+ // ASCII renderer — color utilities
3
+ //
4
+ // Provides color output for themed ASCII diagrams.
5
+ // Supports ANSI terminal modes (16/256/truecolor) and HTML <span> tags
6
+ // for browser rendering.
7
+ // ============================================================================
8
+
9
+ import type { CharRole, AsciiTheme, ColorMode } from './types.ts'
10
+ import type { DiagramColors } from '../theme.ts'
11
+ import { MIX } from '../theme.ts'
12
+
13
+ declare const document: unknown
14
+
15
+ // ============================================================================
16
+ // Default theme — matches SVG theme colors for consistency
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Default ASCII theme derived from the SVG renderer's color palette.
21
+ * Uses the same mixing ratios to maintain visual consistency.
22
+ */
23
+ export const DEFAULT_ASCII_THEME: AsciiTheme = {
24
+ fg: '#27272a', // zinc-800 — primary text
25
+ border: '#a1a1aa', // zinc-400 — node borders (12% mix)
26
+ line: '#71717a', // zinc-500 — edge lines (35% mix)
27
+ arrow: '#52525b', // zinc-600 — arrowheads (60% mix)
28
+ corner: '#71717a', // same as line
29
+ junction: '#a1a1aa', // same as border
30
+ }
31
+
32
+ // ============================================================================
33
+ // DiagramColors → AsciiTheme bridge
34
+ //
35
+ // Converts SVG DiagramColors into an AsciiTheme using the same MIX ratios
36
+ // that the SVG renderer uses via CSS color-mix(). This ensures visual
37
+ // consistency between SVG and ASCII output for any theme.
38
+ // ============================================================================
39
+
40
+ /** Mix fg into bg at a given percentage (replicates CSS color-mix(in srgb)). */
41
+ function mixColors(fg: string, bg: string, pct: number): string {
42
+ const f = parseHex(fg),
43
+ b = parseHex(bg)
44
+ const mix = (a: number, z: number) =>
45
+ Math.round(a * (pct / 100) + z * (1 - pct / 100))
46
+ const r = mix(f.r, b.r),
47
+ g = mix(f.g, b.g),
48
+ bl = mix(f.b, b.b)
49
+ return '#' + [r, g, bl].map((c) => c.toString(16).padStart(2, '0')).join('')
50
+ }
51
+
52
+ /**
53
+ * Derive an AsciiTheme from SVG DiagramColors using the same mixing ratios.
54
+ * Honors optional enrichment colors (line, accent, border) when present,
55
+ * otherwise falls back to color-mix derivation — matching SVG behavior.
56
+ */
57
+ export function diagramColorsToAsciiTheme(colors: DiagramColors): AsciiTheme {
58
+ const line = colors.line ?? mixColors(colors.fg, colors.bg, MIX.line)
59
+ const border =
60
+ colors.border ?? mixColors(colors.fg, colors.bg, MIX.nodeStroke)
61
+ return {
62
+ fg: colors.fg,
63
+ border,
64
+ line,
65
+ arrow: colors.accent ?? mixColors(colors.fg, colors.bg, MIX.arrow),
66
+ accent: colors.accent,
67
+ bg: colors.bg,
68
+ corner: line,
69
+ junction: border,
70
+ }
71
+ }
72
+
73
+ // ============================================================================
74
+ // Color mode detection
75
+ // ============================================================================
76
+
77
+ /**
78
+ * Detect the best color mode for the current environment.
79
+ *
80
+ * Terminal detection order:
81
+ * 1. COLORTERM=truecolor or COLORTERM=24bit → truecolor
82
+ * 2. TERM contains "256color" → ansi256
83
+ * 3. TERM is set and not "dumb" → ansi16
84
+ *
85
+ * Browser: returns 'html' (uses <span> tags with inline styles).
86
+ * Unknown/piped: returns 'none'.
87
+ */
88
+ export function detectColorMode(): ColorMode {
89
+ // Check if we're in a Node.js-like environment with process object
90
+ // Use globalThis to safely check for process without TypeScript errors
91
+ const proc = (
92
+ globalThis as {
93
+ process?: {
94
+ stdout?: { isTTY?: boolean }
95
+ env?: Record<string, string | undefined>
96
+ }
97
+ }
98
+ ).process
99
+
100
+ if (proc) {
101
+ // Check if stdout is a TTY (not piped/redirected)
102
+ if (!proc.stdout?.isTTY) {
103
+ return 'none'
104
+ }
105
+
106
+ const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? ''
107
+ const term = proc.env?.TERM?.toLowerCase() ?? ''
108
+
109
+ // True color support
110
+ if (colorTerm === 'truecolor' || colorTerm === '24bit') {
111
+ return 'truecolor'
112
+ }
113
+
114
+ // 256 color support
115
+ if (term.includes('256color') || term.includes('256')) {
116
+ return 'ansi256'
117
+ }
118
+
119
+ // Basic color support
120
+ if (term && term !== 'dumb') {
121
+ return 'ansi16'
122
+ }
123
+
124
+ return 'none'
125
+ }
126
+
127
+ // No process object → browser environment → use HTML color output
128
+ if (typeof document !== 'undefined') {
129
+ return 'html'
130
+ }
131
+
132
+ return 'none'
133
+ }
134
+
135
+ // ============================================================================
136
+ // Hex color parsing
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Parse a hex color string to RGB values.
141
+ * Supports both 3-char (#RGB) and 6-char (#RRGGBB) formats.
142
+ */
143
+ function parseHex(hex: string): { r: number; g: number; b: number } {
144
+ const h = hex.replace('#', '')
145
+ if (h.length === 3) {
146
+ // .charAt() always returns a string (never undefined) — unlike bracket
147
+ // indexing, it isn't subject to noUncheckedIndexedAccess.
148
+ const c0 = h.charAt(0)
149
+ const c1 = h.charAt(1)
150
+ const c2 = h.charAt(2)
151
+ return {
152
+ r: parseInt(c0 + c0, 16),
153
+ g: parseInt(c1 + c1, 16),
154
+ b: parseInt(c2 + c2, 16),
155
+ }
156
+ }
157
+ return {
158
+ r: parseInt(h.substring(0, 2), 16),
159
+ g: parseInt(h.substring(2, 4), 16),
160
+ b: parseInt(h.substring(4, 6), 16),
161
+ }
162
+ }
163
+
164
+ // ============================================================================
165
+ // ANSI escape code generation
166
+ // ============================================================================
167
+
168
+ /** ANSI escape sequence prefix */
169
+ const ESC = '\x1b['
170
+ /** Reset all attributes */
171
+ const RESET = `${ESC}0m`
172
+
173
+ /**
174
+ * Generate ANSI foreground color escape sequence for 24-bit true color.
175
+ * Format: ESC[38;2;R;G;Bm
176
+ */
177
+ function truecolorFg(hex: string): string {
178
+ const { r, g, b } = parseHex(hex)
179
+ return `${ESC}38;2;${r};${g};${b}m`
180
+ }
181
+
182
+ /**
183
+ * Find the closest 256-color palette index for an RGB color.
184
+ * The 256-color palette has:
185
+ * - 0-15: Standard colors (duplicates of 16-color)
186
+ * - 16-231: 6x6x6 color cube (216 colors)
187
+ * - 232-255: Grayscale ramp (24 shades)
188
+ */
189
+ function rgbTo256(r: number, g: number, b: number): number {
190
+ // Check if it's close to grayscale
191
+ const avg = (r + g + b) / 3
192
+ const maxDiff = Math.max(
193
+ Math.abs(r - avg),
194
+ Math.abs(g - avg),
195
+ Math.abs(b - avg),
196
+ )
197
+
198
+ if (maxDiff < 10) {
199
+ // Use grayscale ramp (232-255)
200
+ // Each step is ~10.625 (256/24)
201
+ const gray = Math.round((avg / 255) * 23)
202
+ return 232 + Math.min(23, Math.max(0, gray))
203
+ }
204
+
205
+ // Use 6x6x6 color cube (16-231)
206
+ // Each channel maps to 0-5: 0, 95, 135, 175, 215, 255
207
+ const toIndex = (v: number): number => {
208
+ if (v < 48) return 0
209
+ if (v < 115) return 1
210
+ return Math.min(5, Math.floor((v - 35) / 40))
211
+ }
212
+
213
+ const ri = toIndex(r)
214
+ const gi = toIndex(g)
215
+ const bi = toIndex(b)
216
+
217
+ return 16 + 36 * ri + 6 * gi + bi
218
+ }
219
+
220
+ /**
221
+ * Generate ANSI foreground color escape sequence for 256-color mode.
222
+ * Format: ESC[38;5;Nm
223
+ */
224
+ function ansi256Fg(hex: string): string {
225
+ const { r, g, b } = parseHex(hex)
226
+ const index = rgbTo256(r, g, b)
227
+ return `${ESC}38;5;${index}m`
228
+ }
229
+
230
+ /**
231
+ * Map an RGB color to the closest 16-color ANSI code.
232
+ * Returns the foreground color escape sequence.
233
+ *
234
+ * Standard 16 colors:
235
+ * 0=black, 1=red, 2=green, 3=yellow, 4=blue, 5=magenta, 6=cyan, 7=white
236
+ * 8-15 = bright versions
237
+ */
238
+ function ansi16Fg(hex: string): string {
239
+ const { r, g, b } = parseHex(hex)
240
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b
241
+
242
+ // Determine brightness (use bright colors for better visibility)
243
+ const bright = luma > 100 ? 0 : 60 // 60 = bright variant offset
244
+
245
+ // Determine base color based on dominant channel
246
+ let code: number
247
+ if (r > 180 && g < 100 && b < 100)
248
+ code = 31 // red
249
+ else if (g > 180 && r < 100 && b < 100)
250
+ code = 32 // green
251
+ else if (r > 150 && g > 150 && b < 100)
252
+ code = 33 // yellow
253
+ else if (b > 180 && r < 100 && g < 100)
254
+ code = 34 // blue
255
+ else if (r > 150 && b > 150 && g < 100)
256
+ code = 35 // magenta
257
+ else if (g > 150 && b > 150 && r < 100)
258
+ code = 36 // cyan
259
+ else if (luma > 200)
260
+ code = 37 // white
261
+ else if (luma < 50)
262
+ code = 30 // black
263
+ else code = 37 // default to white for grays
264
+
265
+ return `${ESC}${code + bright}m`
266
+ }
267
+
268
+ // ============================================================================
269
+ // HTML color output (for browser rendering)
270
+ // ============================================================================
271
+
272
+ /** Escape characters that would break HTML output. */
273
+ function escapeHtml(text: string): string {
274
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
275
+ }
276
+
277
+ /** Wrap text in a <span> with an inline color style. */
278
+ function htmlSpan(hex: string, text: string): string {
279
+ return `<span style="color:${hex}">${escapeHtml(text)}</span>`
280
+ }
281
+
282
+ // ============================================================================
283
+ // Role → color mapping
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Get the color for a character role from the theme.
288
+ */
289
+ function getRoleColor(role: CharRole, theme: AsciiTheme): string {
290
+ switch (role) {
291
+ case 'text':
292
+ return theme.fg
293
+ case 'border':
294
+ return theme.border
295
+ case 'line':
296
+ return theme.line
297
+ case 'arrow':
298
+ return theme.arrow
299
+ case 'corner':
300
+ return theme.corner ?? theme.line
301
+ case 'junction':
302
+ return theme.junction ?? theme.border
303
+ default:
304
+ return theme.fg
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Generate the ANSI escape sequence for a role color.
310
+ */
311
+ export function getAnsiColor(
312
+ role: CharRole,
313
+ theme: AsciiTheme,
314
+ mode: ColorMode,
315
+ ): string {
316
+ if (mode === 'none') return ''
317
+
318
+ const hex = getRoleColor(role, theme)
319
+
320
+ switch (mode) {
321
+ case 'truecolor':
322
+ return truecolorFg(hex)
323
+ case 'ansi256':
324
+ return ansi256Fg(hex)
325
+ case 'ansi16':
326
+ return ansi16Fg(hex)
327
+ default:
328
+ return ''
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Get the ANSI reset sequence.
334
+ */
335
+ export function getAnsiReset(mode: ColorMode): string {
336
+ return mode === 'none' ? '' : RESET
337
+ }
338
+
339
+ /**
340
+ * Wrap a character with ANSI color codes based on its role.
341
+ */
342
+ export function colorizeChar(
343
+ char: string,
344
+ role: CharRole | null,
345
+ theme: AsciiTheme,
346
+ mode: ColorMode,
347
+ ): string {
348
+ if (mode === 'none' || role === null || char === ' ') {
349
+ return char
350
+ }
351
+
352
+ const colorCode = getAnsiColor(role, theme, mode)
353
+ return `${colorCode}${char}${RESET}`
354
+ }
355
+
356
+ /**
357
+ * Colorize an entire line efficiently by grouping consecutive same-role characters.
358
+ * This reduces the number of escape sequences (ANSI) or span tags (HTML) in the output.
359
+ */
360
+ export function colorizeLine(
361
+ chars: string[],
362
+ roles: (CharRole | null)[],
363
+ theme: AsciiTheme,
364
+ mode: ColorMode,
365
+ ): string {
366
+ if (mode === 'none') {
367
+ return chars.join('')
368
+ }
369
+
370
+ if (mode === 'html') {
371
+ return colorizeLineHtml(chars, roles, theme)
372
+ }
373
+
374
+ let result = ''
375
+ let currentRole: CharRole | null = null
376
+ let buffer = ''
377
+
378
+ for (const [i, char] of chars.entries()) {
379
+ const role = roles[i] ?? null
380
+
381
+ // Whitespace doesn't need coloring
382
+ if (char === ' ') {
383
+ // Flush any buffered characters (with or without color)
384
+ if (buffer.length > 0) {
385
+ if (currentRole !== null) {
386
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
387
+ } else {
388
+ result += buffer
389
+ }
390
+ buffer = ''
391
+ currentRole = null
392
+ }
393
+ result += char
394
+ continue
395
+ }
396
+
397
+ // Same role as previous — accumulate
398
+ if (role === currentRole) {
399
+ buffer += char
400
+ continue
401
+ }
402
+
403
+ // Role changed — flush buffer (with or without color) and start new
404
+ if (buffer.length > 0) {
405
+ if (currentRole !== null) {
406
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
407
+ } else {
408
+ result += buffer
409
+ }
410
+ }
411
+ buffer = char
412
+ currentRole = role
413
+ }
414
+
415
+ // Flush remaining buffer
416
+ if (buffer.length > 0 && currentRole !== null) {
417
+ result += getAnsiColor(currentRole, theme, mode) + buffer + RESET
418
+ } else if (buffer.length > 0) {
419
+ result += buffer
420
+ }
421
+
422
+ return result
423
+ }
424
+
425
+ /**
426
+ * HTML-specific line colorization.
427
+ * Groups consecutive same-role characters into <span> tags with inline color styles.
428
+ * Whitespace is emitted bare (no wrapping) to keep output compact.
429
+ */
430
+ function colorizeLineHtml(
431
+ chars: string[],
432
+ roles: (CharRole | null)[],
433
+ theme: AsciiTheme,
434
+ ): string {
435
+ let result = ''
436
+ let currentRole: CharRole | null = null
437
+ let buffer = ''
438
+
439
+ const flush = () => {
440
+ if (buffer.length === 0) return
441
+ if (currentRole !== null) {
442
+ result += htmlSpan(getRoleColor(currentRole, theme), buffer)
443
+ } else {
444
+ result += escapeHtml(buffer)
445
+ }
446
+ buffer = ''
447
+ currentRole = null
448
+ }
449
+
450
+ for (const [i, char] of chars.entries()) {
451
+ const role = roles[i] ?? null
452
+
453
+ if (char === ' ') {
454
+ flush()
455
+ result += ' '
456
+ continue
457
+ }
458
+
459
+ if (role === currentRole) {
460
+ buffer += char
461
+ continue
462
+ }
463
+
464
+ flush()
465
+ buffer = char
466
+ currentRole = role
467
+ }
468
+
469
+ flush()
470
+ return result
471
+ }
472
+
473
+ /**
474
+ * Colorize a text string with a direct hex color.
475
+ * Used by renderers that need per-cell color control (e.g. multi-series xychart).
476
+ * Handles all output modes: ANSI (16/256/truecolor) and HTML.
477
+ */
478
+ export function colorizeText(
479
+ text: string,
480
+ hex: string,
481
+ mode: ColorMode,
482
+ ): string {
483
+ if (mode === 'none' || text.length === 0) return text
484
+ if (mode === 'html') return htmlSpan(hex, text)
485
+ let code: string
486
+ switch (mode) {
487
+ case 'truecolor':
488
+ code = truecolorFg(hex)
489
+ break
490
+ case 'ansi256':
491
+ code = ansi256Fg(hex)
492
+ break
493
+ case 'ansi16':
494
+ code = ansi16Fg(hex)
495
+ break
496
+ default:
497
+ return text
498
+ }
499
+ return `${code}${text}${RESET}`
500
+ }