@oh-my-pi/pi-utils 16.0.7 → 16.0.8

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 (87) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/types/mermaid-ascii.d.ts +1 -1
  3. package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
  4. package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
  5. package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
  6. package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
  7. package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
  8. package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
  9. package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
  10. package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
  11. package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
  12. package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
  13. package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
  14. package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
  15. package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
  16. package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
  17. package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
  18. package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
  19. package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
  20. package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
  21. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
  22. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
  23. package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
  24. package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
  25. package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
  26. package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
  27. package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
  28. package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
  29. package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
  30. package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
  31. package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
  32. package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
  33. package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
  34. package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
  35. package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
  36. package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
  37. package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
  38. package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
  39. package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
  40. package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
  41. package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
  42. package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
  43. package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
  44. package/package.json +2 -3
  45. package/src/mermaid-ascii.ts +1 -1
  46. package/src/vendor/mermaid-ascii/NOTICE +33 -0
  47. package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
  48. package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
  49. package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
  50. package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
  51. package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
  52. package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
  53. package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
  54. package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
  55. package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
  56. package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
  57. package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
  58. package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
  59. package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
  60. package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
  61. package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
  62. package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
  63. package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
  64. package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
  65. package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
  66. package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
  67. package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
  68. package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
  69. package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
  70. package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
  71. package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
  72. package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
  73. package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
  74. package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
  75. package/src/vendor/mermaid-ascii/class/types.ts +121 -0
  76. package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
  77. package/src/vendor/mermaid-ascii/er/types.ts +91 -0
  78. package/src/vendor/mermaid-ascii/index.ts +14 -0
  79. package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
  80. package/src/vendor/mermaid-ascii/parser.ts +645 -0
  81. package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
  82. package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
  83. package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
  84. package/src/vendor/mermaid-ascii/types.ts +164 -0
  85. package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
  86. package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
  87. package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
@@ -0,0 +1,875 @@
1
+ // ============================================================================
2
+ // ASCII renderer — XY Chart
3
+ //
4
+ // Renders xychart-beta diagrams to ASCII/Unicode text art.
5
+ // Uses the parsed XYChart type directly (not PositionedXYChart) since
6
+ // pixel coordinates don't map to character grids.
7
+ //
8
+ // Bar charts: █ (Unicode) or # (ASCII) block characters.
9
+ // Line charts: continuous staircase routing with rounded corners (╭╮╰╯│─).
10
+ //
11
+ // Multi-series support: each series gets a distinct color from a palette.
12
+ // ============================================================================
13
+
14
+ import { parseXYChart } from '../xychart/parser'
15
+ import type { XYChart } from '../xychart/types'
16
+ import type { AsciiConfig, AsciiTheme, ColorMode, CharRole, Canvas, RoleCanvas } from './types'
17
+ import { colorizeText } from './ansi'
18
+ import { getSeriesColor, CHART_ACCENT_FALLBACK } from '../xychart/colors'
19
+ import { displayWidth, toCells, WIDE_PAD } from '../text-metrics'
20
+
21
+ // ============================================================================
22
+ // Constants
23
+ // ============================================================================
24
+
25
+ const PLOT_WIDTH = 60
26
+ const PLOT_HEIGHT = 20
27
+
28
+ // Unicode box-drawing characters
29
+ const UNI = {
30
+ hLine: '─',
31
+ vLine: '│',
32
+ origin: '┼',
33
+ yTick: '┤',
34
+ xTick: '┬',
35
+ bar: '█',
36
+ grid: '·',
37
+ cornerTL: '╭', // top-left: down+right
38
+ cornerTR: '╮', // top-right: down+left
39
+ cornerBL: '╰', // bottom-left: up+right
40
+ cornerBR: '╯', // bottom-right: up+left
41
+ } as const
42
+
43
+ // ASCII fallback characters
44
+ const ASC = {
45
+ hLine: '-',
46
+ vLine: '|',
47
+ origin: '+',
48
+ yTick: '+',
49
+ xTick: '+',
50
+ bar: '#',
51
+ grid: '.',
52
+ cornerTL: '+',
53
+ cornerTR: '+',
54
+ cornerBL: '+',
55
+ cornerBR: '+',
56
+ } as const
57
+
58
+ // ============================================================================
59
+ // Multi-series color support
60
+ // ============================================================================
61
+
62
+ /** Per-cell hex color override canvas. Parallel to RoleCanvas. */
63
+ type HexCanvas = (string | null)[][]
64
+
65
+ /** Generate an array of hex colors, one per series. */
66
+ function getSeriesColors(total: number, theme: AsciiTheme): string[] {
67
+ const accent = theme.accent ?? CHART_ACCENT_FALLBACK
68
+ if (total <= 1) return [accent]
69
+ return Array.from({ length: total }, (_, i) => getSeriesColor(i, accent, theme.bg))
70
+ }
71
+
72
+ /** Map a CharRole to its hex color from the theme (for canvasToString fallback). */
73
+ function roleToHex(role: CharRole, theme: AsciiTheme): string {
74
+ switch (role) {
75
+ case 'text': return theme.fg
76
+ case 'border': return theme.border
77
+ case 'line': return theme.line
78
+ case 'arrow': return theme.arrow
79
+ case 'corner': return theme.corner ?? theme.line
80
+ case 'junction': return theme.junction ?? theme.border
81
+ default: return theme.fg
82
+ }
83
+ }
84
+
85
+ // ============================================================================
86
+ // Public API
87
+ // ============================================================================
88
+
89
+ export function renderXYChartAscii(
90
+ text: string,
91
+ config: AsciiConfig,
92
+ colorMode: ColorMode,
93
+ theme: AsciiTheme,
94
+ ): string {
95
+ const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%'))
96
+ const chart = parseXYChart(lines)
97
+ const ch = config.useAscii ? ASC : UNI
98
+
99
+ if (chart.horizontal) {
100
+ return renderHorizontal(chart, ch, colorMode, theme)
101
+ }
102
+ return renderVertical(chart, ch, colorMode, theme)
103
+ }
104
+
105
+ // ============================================================================
106
+ // Vertical chart layout + rendering
107
+ // ============================================================================
108
+
109
+ function renderVertical(
110
+ chart: XYChart,
111
+ ch: typeof UNI | typeof ASC,
112
+ colorMode: ColorMode,
113
+ theme: AsciiTheme,
114
+ ): string {
115
+ const dataCount = getDataCount(chart)
116
+ if (dataCount === 0) return ''
117
+
118
+ const yRange = chart.yAxis.range!
119
+ const yTicks = niceTickValues(yRange.min, yRange.max)
120
+ const yLabels = yTicks.map(v => formatTickValue(v))
121
+ const yGutter = Math.max(...yLabels.map(l => displayWidth(l))) + 1
122
+
123
+ const plotW = Math.max(PLOT_WIDTH, dataCount * 6)
124
+ const plotH = PLOT_HEIGHT
125
+ const bandW = Math.floor(plotW / dataCount)
126
+ const catLabels = getCategoryLabels(chart, dataCount)
127
+
128
+ // Canvas dimensions
129
+ const hasTitle = !!chart.title
130
+ const hasXTitle = !!chart.xAxis.title
131
+ const hasLegend = chart.series.length > 1
132
+ const titleRow = hasTitle ? 0 : -1
133
+ const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0)
134
+ const plotLeft = yGutter + 1 // +1 for axis character
135
+ const totalW = plotLeft + bandW * dataCount + 2
136
+ const xAxisRow = plotTop + plotH
137
+ const xLabelRow = xAxisRow + 1
138
+ const xTitleRow = hasXTitle ? xLabelRow + 1 : -1
139
+ const totalH = xLabelRow + 1 + (hasXTitle ? 1 : 0) + (hasLegend && !hasTitle ? 0 : 0)
140
+
141
+ // Create canvas
142
+ const canvas = createCanvas(totalW, totalH)
143
+ const roles = createRoleCanvas(totalW, totalH)
144
+ const hexColors = createHexCanvas(totalW, totalH)
145
+
146
+ // Series colors
147
+ const seriesColors = getSeriesColors(chart.series.length, theme)
148
+
149
+ // Scales
150
+ const valueToRow = (v: number): number => {
151
+ const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
152
+ return Math.round(t * (plotH - 1))
153
+ }
154
+ const bandCenter = (i: number): number => plotLeft + Math.floor(bandW * (i + 0.5))
155
+
156
+ // 1. Title
157
+ if (hasTitle && titleRow >= 0) {
158
+ writeText(canvas, roles, titleRow, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text')
159
+ }
160
+
161
+ // 2. Legend
162
+ if (hasLegend) {
163
+ const legendRow = hasTitle ? 1 : 0
164
+ drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors)
165
+ }
166
+
167
+ // 3. Y-axis line + ticks + labels
168
+ for (let row = 0; row < plotH; row++) {
169
+ const displayRow = plotTop + (plotH - 1 - row)
170
+ set(canvas, roles, displayRow, plotLeft - 1, ch.vLine, 'border')
171
+ }
172
+ // Origin
173
+ set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border')
174
+
175
+ for (const tick of yTicks) {
176
+ const row = valueToRow(tick)
177
+ if (row < 0 || row >= plotH) continue
178
+ const displayRow = plotTop + (plotH - 1 - row)
179
+ const label = formatTickValue(tick)
180
+ // Tick mark on axis
181
+ set(canvas, roles, displayRow, plotLeft - 1, row === 0 ? ch.origin : ch.yTick, 'border')
182
+ // Label
183
+ const labelStart = yGutter - displayWidth(label)
184
+ writeText(canvas, roles, displayRow, Math.max(0, labelStart), label, 'text')
185
+ }
186
+
187
+ // 4. X-axis line + ticks + labels
188
+ for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) {
189
+ set(canvas, roles, xAxisRow, c, ch.hLine, 'border')
190
+ }
191
+ for (let i = 0; i < dataCount; i++) {
192
+ const cx = bandCenter(i)
193
+ set(canvas, roles, xAxisRow, cx, ch.xTick, 'border')
194
+ // Label below
195
+ const label = catLabels[i]!
196
+ const labelStart = cx - Math.floor(displayWidth(label) / 2)
197
+ writeText(canvas, roles, xLabelRow, Math.max(0, labelStart), label, 'text')
198
+ }
199
+
200
+ // 5. X-axis title
201
+ if (hasXTitle && xTitleRow >= 0) {
202
+ const title = chart.xAxis.title!
203
+ writeText(canvas, roles, xTitleRow, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text')
204
+ }
205
+
206
+ // 6. Grid lines (subtle horizontal dots at y-tick positions)
207
+ for (const tick of yTicks) {
208
+ const row = valueToRow(tick)
209
+ if (row < 0 || row >= plotH) continue
210
+ const displayRow = plotTop + (plotH - 1 - row)
211
+ for (let c = plotLeft; c < plotLeft + bandW * dataCount; c++) {
212
+ if (get(canvas, displayRow, c) === ' ') {
213
+ set(canvas, roles, displayRow, c, ch.grid, 'line')
214
+ }
215
+ }
216
+ }
217
+
218
+ // 7. Bars — track global series index for per-series colors
219
+ const barEntries: { data: number[]; globalIdx: number }[] = []
220
+ for (let si = 0; si < chart.series.length; si++) {
221
+ if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si })
222
+ }
223
+
224
+ if (barEntries.length > 0) {
225
+ const barCount = barEntries.length
226
+ const usable = Math.max(1, bandW - 2)
227
+ const singleBarW = Math.max(1, Math.min(Math.floor(usable / barCount), 8))
228
+ const groupW = singleBarW * barCount + (barCount - 1)
229
+ const baseRow = valueToRow(Math.max(0, yRange.min))
230
+
231
+ for (let bIdx = 0; bIdx < barEntries.length; bIdx++) {
232
+ const entry = barEntries[bIdx]!
233
+ const hexColor = seriesColors[entry.globalIdx]!
234
+ for (let i = 0; i < entry.data.length; i++) {
235
+ const cx = bandCenter(i)
236
+ const groupLeft = cx - Math.floor(groupW / 2)
237
+ const bx = groupLeft + bIdx * (singleBarW + 1)
238
+ const valRow = valueToRow(entry.data[i]!)
239
+ const fromRow = Math.min(baseRow, valRow)
240
+ const toRow = Math.max(baseRow, valRow)
241
+
242
+ for (let row = fromRow; row <= toRow; row++) {
243
+ const displayRow = plotTop + (plotH - 1 - row)
244
+ for (let c = bx; c < bx + singleBarW; c++) {
245
+ set(canvas, roles, displayRow, c, ch.bar, 'arrow', hexColors, hexColor)
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ // 8. Lines (staircase routing with rounded corners)
253
+ const lineEntries: { data: number[]; globalIdx: number }[] = []
254
+ for (let si = 0; si < chart.series.length; si++) {
255
+ if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si })
256
+ }
257
+
258
+ for (const entry of lineEntries) {
259
+ if (entry.data.length === 0) continue
260
+ const hexColor = seriesColors[entry.globalIdx]!
261
+ drawStaircaseLine(canvas, roles, entry.data, bandCenter, valueToRow, plotTop, plotH, plotLeft, bandW * dataCount, ch, hexColors, hexColor)
262
+ }
263
+
264
+ return canvasToString(canvas, roles, hexColors, colorMode, theme)
265
+ }
266
+
267
+ // ============================================================================
268
+ // Horizontal chart layout + rendering
269
+ // ============================================================================
270
+
271
+ function renderHorizontal(
272
+ chart: XYChart,
273
+ ch: typeof UNI | typeof ASC,
274
+ colorMode: ColorMode,
275
+ theme: AsciiTheme,
276
+ ): string {
277
+ const dataCount = getDataCount(chart)
278
+ if (dataCount === 0) return ''
279
+
280
+ const yRange = chart.yAxis.range!
281
+ const valueTicks = niceTickValues(yRange.min, yRange.max)
282
+ const catLabels = getCategoryLabels(chart, dataCount)
283
+ const catGutter = Math.max(...catLabels.map(l => displayWidth(l))) + 1
284
+
285
+ const plotW = Math.max(PLOT_WIDTH, 40)
286
+ const bandH = Math.max(2, Math.floor(PLOT_HEIGHT / dataCount))
287
+ const plotH = bandH * dataCount
288
+
289
+ const hasTitle = !!chart.title
290
+ const hasYTitle = !!chart.yAxis.title
291
+ const hasLegend = chart.series.length > 1
292
+ const plotTop = (hasTitle ? 2 : 0) + (hasLegend ? 1 : 0)
293
+ const plotLeft = catGutter + 1
294
+ const totalW = plotLeft + plotW + 2
295
+ const totalH = plotTop + plotH + 2 + (hasYTitle ? 1 : 0)
296
+ const xAxisRow = plotTop + plotH
297
+
298
+ const canvas = createCanvas(totalW, totalH)
299
+ const roles = createRoleCanvas(totalW, totalH)
300
+ const hexColors = createHexCanvas(totalW, totalH)
301
+
302
+ // Series colors
303
+ const seriesColors = getSeriesColors(chart.series.length, theme)
304
+
305
+ // Value scale (horizontal)
306
+ const valueToCol = (v: number): number => {
307
+ const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
308
+ return plotLeft + Math.round(t * (plotW - 1))
309
+ }
310
+ const bandMid = (i: number): number => plotTop + Math.floor(bandH * (i + 0.5))
311
+
312
+ // Title
313
+ if (hasTitle) {
314
+ writeText(canvas, roles, 0, Math.floor(totalW / 2 - displayWidth(chart.title!) / 2), chart.title!, 'text')
315
+ }
316
+
317
+ // Legend
318
+ if (hasLegend) {
319
+ const legendRow = hasTitle ? 1 : 0
320
+ drawLegend(canvas, roles, hexColors, chart, legendRow, totalW, ch, seriesColors)
321
+ }
322
+
323
+ // Y-axis (category axis on left)
324
+ for (let r = plotTop; r < plotTop + plotH; r++) {
325
+ set(canvas, roles, r, plotLeft - 1, ch.vLine, 'border')
326
+ }
327
+ set(canvas, roles, xAxisRow, plotLeft - 1, ch.origin, 'border')
328
+
329
+ for (let i = 0; i < dataCount; i++) {
330
+ const my = bandMid(i)
331
+ const label = catLabels[i]!
332
+ const labelStart = catGutter - displayWidth(label)
333
+ writeText(canvas, roles, my, Math.max(0, labelStart), label, 'text')
334
+ }
335
+
336
+ // X-axis (value axis on bottom)
337
+ for (let c = plotLeft; c < plotLeft + plotW; c++) {
338
+ set(canvas, roles, xAxisRow, c, ch.hLine, 'border')
339
+ }
340
+ for (const tick of valueTicks) {
341
+ const cx = valueToCol(tick)
342
+ if (cx < plotLeft || cx >= plotLeft + plotW) continue
343
+ set(canvas, roles, xAxisRow, cx, ch.xTick, 'border')
344
+ const label = formatTickValue(tick)
345
+ writeText(canvas, roles, xAxisRow + 1, cx - Math.floor(displayWidth(label) / 2), label, 'text')
346
+ }
347
+
348
+ // Y-axis title
349
+ if (hasYTitle) {
350
+ const title = chart.yAxis.title!
351
+ writeText(canvas, roles, totalH - 1, Math.floor(totalW / 2 - displayWidth(title) / 2), title, 'text')
352
+ }
353
+
354
+ // Grid lines (vertical at value tick positions)
355
+ for (const tick of valueTicks) {
356
+ const cx = valueToCol(tick)
357
+ if (cx < plotLeft || cx >= plotLeft + plotW) continue
358
+ for (let r = plotTop; r < plotTop + plotH; r++) {
359
+ if (get(canvas, r, cx) === ' ') {
360
+ set(canvas, roles, r, cx, ch.grid, 'line')
361
+ }
362
+ }
363
+ }
364
+
365
+ // Bars (horizontal) — with per-series colors
366
+ const barEntries: { data: number[]; globalIdx: number }[] = []
367
+ for (let si = 0; si < chart.series.length; si++) {
368
+ if (chart.series[si]!.type === 'bar') barEntries.push({ data: chart.series[si]!.data, globalIdx: si })
369
+ }
370
+
371
+ if (barEntries.length > 0) {
372
+ const barCount = barEntries.length
373
+ const singleBarH = 1
374
+ const groupH = singleBarH * barCount + (barCount - 1)
375
+ const baseCol = valueToCol(Math.max(0, yRange.min))
376
+
377
+ for (let bIdx = 0; bIdx < barEntries.length; bIdx++) {
378
+ const entry = barEntries[bIdx]!
379
+ const hexColor = seriesColors[entry.globalIdx]!
380
+ for (let i = 0; i < entry.data.length; i++) {
381
+ const my = bandMid(i)
382
+ const groupTop = my - Math.floor(groupH / 2)
383
+ const by = groupTop + bIdx * (singleBarH + 1)
384
+ const valCol = valueToCol(entry.data[i]!)
385
+ const fromCol = Math.min(baseCol, valCol)
386
+ const toCol = Math.max(baseCol, valCol)
387
+
388
+ for (let r = by; r < by + singleBarH; r++) {
389
+ for (let c = fromCol; c <= toCol; c++) {
390
+ set(canvas, roles, r, c, ch.bar, 'arrow', hexColors, hexColor)
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ // Lines (horizontal staircase: value on x, category on y) — with per-series colors
398
+ const lineEntries: { data: number[]; globalIdx: number }[] = []
399
+ for (let si = 0; si < chart.series.length; si++) {
400
+ if (chart.series[si]!.type === 'line') lineEntries.push({ data: chart.series[si]!.data, globalIdx: si })
401
+ }
402
+
403
+ for (const entry of lineEntries) {
404
+ if (entry.data.length === 0) continue
405
+ const hexColor = seriesColors[entry.globalIdx]!
406
+ drawHorizontalStaircaseLine(canvas, roles, entry.data, bandMid, valueToCol, plotTop, plotH, plotLeft, plotW, ch, hexColors, hexColor)
407
+ }
408
+
409
+ return canvasToString(canvas, roles, hexColors, colorMode, theme)
410
+ }
411
+
412
+ // ============================================================================
413
+ // Staircase line drawing — vertical charts
414
+ //
415
+ // Connects data points with flat segments (─) at each value's row,
416
+ // vertical segments (│) between rows, and rounded corners (╭╮╰╯)
417
+ // at transitions. The vertical step happens at the midpoint column
418
+ // between adjacent data points.
419
+ // ============================================================================
420
+
421
+ function drawStaircaseLine(
422
+ canvas: Canvas,
423
+ roles: RoleCanvas,
424
+ data: number[],
425
+ bandCenter: (i: number) => number,
426
+ valueToRow: (v: number) => number,
427
+ plotTop: number,
428
+ plotH: number,
429
+ plotLeft: number,
430
+ plotTotalW: number,
431
+ ch: typeof UNI | typeof ASC,
432
+ hexCanvas?: HexCanvas,
433
+ hexColor?: string | null,
434
+ ): void {
435
+ if (data.length === 0) return
436
+
437
+ const points = data.map((v, i) => ({
438
+ col: bandCenter(i),
439
+ row: valueToRow(v),
440
+ }))
441
+
442
+ // Helper to draw on the canvas (row 0 = bottom, displayed inverted)
443
+ const drawAt = (col: number, row: number, char: string) => {
444
+ const displayRow = plotTop + (plotH - 1 - row)
445
+ if (displayRow >= 0 && col >= plotLeft && col < plotLeft + plotTotalW) {
446
+ set(canvas, roles, displayRow, col, char, 'arrow', hexCanvas, hexColor)
447
+ }
448
+ }
449
+
450
+ // Single point: just draw a flat segment
451
+ if (points.length === 1) {
452
+ drawAt(points[0]!.col, points[0]!.row, ch.hLine)
453
+ return
454
+ }
455
+
456
+ for (let i = 0; i < points.length - 1; i++) {
457
+ const p1 = points[i]!
458
+ const p2 = points[i + 1]!
459
+
460
+ if (p1.row === p2.row) {
461
+ // Flat: draw ─ across
462
+ for (let c = p1.col; c <= p2.col; c++) {
463
+ drawAt(c, p1.row, ch.hLine)
464
+ }
465
+ continue
466
+ }
467
+
468
+ const midCol = Math.round((p1.col + p2.col) / 2)
469
+ const goingUp = p2.row > p1.row
470
+
471
+ // 1. Flat at p1's row from p1.col to midCol-1
472
+ for (let c = p1.col; c < midCol; c++) {
473
+ drawAt(c, p1.row, ch.hLine)
474
+ }
475
+
476
+ // 2. Corner at (midCol, p1.row)
477
+ // goingUp: ─ from LEFT, │ going UP → LEFT+TOP = ╯ (cornerBR)
478
+ // goingDown: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR)
479
+ if (goingUp) {
480
+ drawAt(midCol, p1.row, ch.cornerBR) // ╯
481
+ } else {
482
+ drawAt(midCol, p1.row, ch.cornerTR) // ╮
483
+ }
484
+
485
+ // 3. Vertical from p1.row to p2.row (exclusive of endpoints)
486
+ const minRow = Math.min(p1.row, p2.row)
487
+ const maxRow = Math.max(p1.row, p2.row)
488
+ for (let row = minRow + 1; row < maxRow; row++) {
489
+ drawAt(midCol, row, ch.vLine)
490
+ }
491
+
492
+ // 4. Corner at (midCol, p2.row)
493
+ // goingUp: │ from BOTTOM, ─ going RIGHT → BOT+RIGHT = ╭ (cornerTL)
494
+ // goingDown: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL)
495
+ if (goingUp) {
496
+ drawAt(midCol, p2.row, ch.cornerTL) // ╭
497
+ } else {
498
+ drawAt(midCol, p2.row, ch.cornerBL) // ╰
499
+ }
500
+
501
+ // 5. Flat at p2's row from midCol+1 to p2.col
502
+ for (let c = midCol + 1; c <= p2.col; c++) {
503
+ drawAt(c, p2.row, ch.hLine)
504
+ }
505
+
506
+ // Leading flat for first segment (before p1.col)
507
+ if (i === 0) {
508
+ const leadStart = Math.max(plotLeft, p1.col - Math.floor((p2.col - p1.col) / 4))
509
+ for (let c = leadStart; c < p1.col; c++) {
510
+ drawAt(c, p1.row, ch.hLine)
511
+ }
512
+ }
513
+
514
+ // Trailing flat for last segment (after p2.col)
515
+ if (i === points.length - 2) {
516
+ const trailEnd = Math.min(plotLeft + plotTotalW - 1, p2.col + Math.floor((p2.col - p1.col) / 4))
517
+ for (let c = p2.col + 1; c <= trailEnd; c++) {
518
+ drawAt(c, p2.row, ch.hLine)
519
+ }
520
+ }
521
+ }
522
+ }
523
+
524
+ // ============================================================================
525
+ // Staircase line drawing — horizontal charts
526
+ //
527
+ // Same staircase approach but with axes swapped:
528
+ // data values map to columns (horizontal position) and categories map to
529
+ // rows (vertical position). Flat segments are vertical (│), transitions
530
+ // are horizontal (─), with the same rounded corners.
531
+ // ============================================================================
532
+
533
+ function drawHorizontalStaircaseLine(
534
+ canvas: Canvas,
535
+ roles: RoleCanvas,
536
+ data: number[],
537
+ bandMid: (i: number) => number,
538
+ valueToCol: (v: number) => number,
539
+ plotTop: number,
540
+ plotH: number,
541
+ plotLeft: number,
542
+ plotW: number,
543
+ ch: typeof UNI | typeof ASC,
544
+ hexCanvas?: HexCanvas,
545
+ hexColor?: string | null,
546
+ ): void {
547
+ if (data.length === 0) return
548
+
549
+ const points = data.map((v, i) => ({
550
+ row: bandMid(i),
551
+ col: valueToCol(v),
552
+ }))
553
+
554
+ const drawAt = (row: number, col: number, char: string) => {
555
+ if (row >= plotTop && row < plotTop + plotH && col >= plotLeft && col < plotLeft + plotW) {
556
+ set(canvas, roles, row, col, char, 'arrow', hexCanvas, hexColor)
557
+ }
558
+ }
559
+
560
+ if (points.length === 1) {
561
+ drawAt(points[0]!.row, points[0]!.col, ch.vLine)
562
+ return
563
+ }
564
+
565
+ for (let i = 0; i < points.length - 1; i++) {
566
+ const p1 = points[i]!
567
+ const p2 = points[i + 1]!
568
+
569
+ if (p1.col === p2.col) {
570
+ // Same value: draw │ down
571
+ for (let r = p1.row; r <= p2.row; r++) {
572
+ drawAt(r, p1.col, ch.vLine)
573
+ }
574
+ continue
575
+ }
576
+
577
+ const midRow = Math.round((p1.row + p2.row) / 2)
578
+ const goingRight = p2.col > p1.col
579
+
580
+ // 1. Vertical at p1's col from p1.row to midRow-1
581
+ for (let r = p1.row; r < midRow; r++) {
582
+ drawAt(r, p1.col, ch.vLine)
583
+ }
584
+
585
+ // 2. Corner at (midRow, p1.col)
586
+ // goingRight: │ from TOP, ─ going RIGHT → TOP+RIGHT = ╰ (cornerBL)
587
+ // goingLeft: │ from TOP, ─ going LEFT → TOP+LEFT = ╯ (cornerBR)
588
+ if (goingRight) {
589
+ drawAt(midRow, p1.col, ch.cornerBL) // ╰
590
+ } else {
591
+ drawAt(midRow, p1.col, ch.cornerBR) // ╯
592
+ }
593
+
594
+ // 3. Horizontal from p1.col to p2.col (exclusive)
595
+ const minCol = Math.min(p1.col, p2.col)
596
+ const maxCol = Math.max(p1.col, p2.col)
597
+ for (let c = minCol + 1; c < maxCol; c++) {
598
+ drawAt(midRow, c, ch.hLine)
599
+ }
600
+
601
+ // 4. Corner at (midRow, p2.col)
602
+ // goingRight: ─ from LEFT, │ going DOWN → LEFT+BOT = ╮ (cornerTR)
603
+ // goingLeft: ─ from RIGHT, │ going DOWN → RIGHT+BOT = ╭ (cornerTL)
604
+ if (goingRight) {
605
+ drawAt(midRow, p2.col, ch.cornerTR) // ╮
606
+ } else {
607
+ drawAt(midRow, p2.col, ch.cornerTL) // ╭
608
+ }
609
+
610
+ // 5. Vertical at p2's col from midRow+1 to p2.row
611
+ for (let r = midRow + 1; r <= p2.row; r++) {
612
+ drawAt(r, p2.col, ch.vLine)
613
+ }
614
+ }
615
+ }
616
+
617
+ // ============================================================================
618
+ // Legend — shows series symbols with per-series colors
619
+ // ============================================================================
620
+
621
+ function drawLegend(
622
+ canvas: Canvas,
623
+ roles: RoleCanvas,
624
+ hexCanvas: HexCanvas,
625
+ chart: XYChart,
626
+ row: number,
627
+ totalW: number,
628
+ ch: typeof UNI | typeof ASC,
629
+ seriesColors: string[],
630
+ ): void {
631
+ // Build legend items with global series indices
632
+ type LegendItem = { symbol: string; label: string; globalIdx: number }
633
+ const items: LegendItem[] = []
634
+ let barIdx = 0, lineIdx = 0
635
+ for (let si = 0; si < chart.series.length; si++) {
636
+ const s = chart.series[si]!
637
+ if (s.type === 'bar') {
638
+ items.push({ symbol: ch.bar, label: `Bar ${barIdx + 1}`, globalIdx: si })
639
+ barIdx++
640
+ } else {
641
+ items.push({ symbol: ch.hLine, label: `Line ${lineIdx + 1}`, globalIdx: si })
642
+ lineIdx++
643
+ }
644
+ }
645
+
646
+ // Calculate total legend width: "symbol space label symbol space label ..."
647
+ let totalLen = 0
648
+ for (let i = 0; i < items.length; i++) {
649
+ if (i > 0) totalLen += 2 // gap between items
650
+ totalLen += 1 + 1 + displayWidth(items[i]!.label) // symbol + space + label
651
+ }
652
+
653
+ const startCol = Math.max(0, Math.floor(totalW / 2 - totalLen / 2))
654
+ let col = startCol
655
+
656
+ for (let i = 0; i < items.length; i++) {
657
+ if (i > 0) col += 2 // gap
658
+ const item = items[i]!
659
+ // Symbol with series-specific color
660
+ set(canvas, roles, row, col, item.symbol, 'arrow', hexCanvas, seriesColors[item.globalIdx])
661
+ col += 1
662
+ // Space (already ' ' from canvas init)
663
+ col += 1
664
+ // Label text
665
+ writeText(canvas, roles, row, col, item.label, 'text')
666
+ col += displayWidth(item.label)
667
+ }
668
+ }
669
+
670
+ // ============================================================================
671
+ // Canvas utilities
672
+ // ============================================================================
673
+
674
+ function createCanvas(width: number, height: number): Canvas {
675
+ return Array.from({ length: width }, () => Array.from({ length: height }, () => ' '))
676
+ }
677
+
678
+ function createRoleCanvas(width: number, height: number): RoleCanvas {
679
+ return Array.from({ length: width }, () => Array.from<CharRole | null>({ length: height }).fill(null))
680
+ }
681
+
682
+ function createHexCanvas(width: number, height: number): HexCanvas {
683
+ return Array.from({ length: width }, () => Array.from<string | null>({ length: height }).fill(null))
684
+ }
685
+
686
+ function set(
687
+ canvas: Canvas, roles: RoleCanvas, row: number, col: number,
688
+ char: string, role: CharRole,
689
+ hexCanvas?: HexCanvas, hex?: string | null,
690
+ ): void {
691
+ if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) {
692
+ canvas[col]![row] = char
693
+ roles[col]![row] = role
694
+ if (hexCanvas && hex) hexCanvas[col]![row] = hex
695
+ }
696
+ }
697
+
698
+ function get(canvas: Canvas, row: number, col: number): string {
699
+ if (col >= 0 && col < canvas.length && row >= 0 && row < canvas[0]!.length) {
700
+ return canvas[col]![row]!
701
+ }
702
+ return ' '
703
+ }
704
+
705
+ function writeText(canvas: Canvas, roles: RoleCanvas, row: number, startCol: number, text: string, role: CharRole): void {
706
+ const cells = toCells(text)
707
+ for (let i = 0; i < cells.length; i++) {
708
+ const cell = cells[i]!
709
+ if (cell === WIDE_PAD) continue // written atomically with its lead
710
+ const col = startCol + i
711
+ const wide = cells[i + 1] === WIDE_PAD
712
+ // Keep wide-glyph pairs atomic at canvas bounds
713
+ if (col < 0 || col + (wide ? 1 : 0) >= canvas.length) continue
714
+ set(canvas, roles, row, col, cell, role)
715
+ if (wide) set(canvas, roles, row, col + 1, WIDE_PAD, role)
716
+ }
717
+ }
718
+
719
+ // ============================================================================
720
+ // Canvas → string (with per-cell hex color support)
721
+ // ============================================================================
722
+
723
+ function canvasToString(
724
+ canvas: Canvas,
725
+ roles: RoleCanvas,
726
+ hexCanvas: HexCanvas,
727
+ colorMode: ColorMode,
728
+ theme: AsciiTheme,
729
+ ): string {
730
+ if (canvas.length === 0) return ''
731
+ const height = canvas[0]!.length
732
+ const width = canvas.length
733
+ const lines: string[] = []
734
+
735
+ for (let row = 0; row < height; row++) {
736
+ const chars: string[] = []
737
+ const rowRoles: (CharRole | null)[] = []
738
+ const rowHex: (string | null)[] = []
739
+ for (let col = 0; col < width; col++) {
740
+ const c = canvas[col]![row]!
741
+ // Skip wide-glyph continuation cells: the glyph itself spans 2 columns
742
+ if (c === WIDE_PAD) continue
743
+ chars.push(c)
744
+ rowRoles.push(roles[col]![row]!)
745
+ rowHex.push(hexCanvas[col]![row]!)
746
+ }
747
+ // Trim trailing spaces
748
+ let end = chars.length - 1
749
+ while (end >= 0 && chars[end] === ' ') end--
750
+ if (end < 0) {
751
+ lines.push('')
752
+ } else {
753
+ lines.push(colorizeRow(
754
+ chars.slice(0, end + 1),
755
+ rowRoles.slice(0, end + 1),
756
+ rowHex.slice(0, end + 1),
757
+ theme,
758
+ colorMode,
759
+ ))
760
+ }
761
+ }
762
+
763
+ // Trim trailing empty lines
764
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
765
+ lines.pop()
766
+ }
767
+
768
+ return lines.join('\n')
769
+ }
770
+
771
+ /**
772
+ * Colorize a row of characters, using hex color overrides where available
773
+ * and falling back to role-based theme colors otherwise.
774
+ * Groups consecutive same-color characters for efficient escape sequences.
775
+ */
776
+ function colorizeRow(
777
+ chars: string[],
778
+ roles: (CharRole | null)[],
779
+ hexOverrides: (string | null)[],
780
+ theme: AsciiTheme,
781
+ mode: ColorMode,
782
+ ): string {
783
+ if (mode === 'none') return chars.join('')
784
+
785
+ let result = ''
786
+ let currentColor: string | null = null
787
+ let buffer = ''
788
+
789
+ for (let i = 0; i < chars.length; i++) {
790
+ const char = chars[i]!
791
+
792
+ if (char === ' ') {
793
+ // Flush buffer before whitespace
794
+ if (buffer.length > 0) {
795
+ result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer
796
+ buffer = ''
797
+ currentColor = null
798
+ }
799
+ result += ' '
800
+ continue
801
+ }
802
+
803
+ // Effective color: hex override > role-based > null
804
+ const hexOvr = hexOverrides[i] ?? null
805
+ const roleVal = roles[i] ?? null
806
+ const color = hexOvr ?? (roleVal ? roleToHex(roleVal, theme) : null)
807
+
808
+ if (color === currentColor) {
809
+ buffer += char
810
+ } else {
811
+ // Flush previous group
812
+ if (buffer.length > 0) {
813
+ result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer
814
+ }
815
+ buffer = char
816
+ currentColor = color
817
+ }
818
+ }
819
+
820
+ // Flush remaining
821
+ if (buffer.length > 0) {
822
+ result += currentColor ? colorizeText(buffer, currentColor, mode) : buffer
823
+ }
824
+
825
+ return result
826
+ }
827
+
828
+ // ============================================================================
829
+ // Helpers (chart-level)
830
+ // ============================================================================
831
+
832
+ function getDataCount(chart: XYChart): number {
833
+ if (chart.xAxis.categories) return chart.xAxis.categories.length
834
+ for (const s of chart.series) {
835
+ if (s.data.length > 0) return s.data.length
836
+ }
837
+ return 0
838
+ }
839
+
840
+ function getCategoryLabels(chart: XYChart, count: number): string[] {
841
+ if (chart.xAxis.categories) return chart.xAxis.categories
842
+ if (chart.xAxis.range) {
843
+ const { min, max } = chart.xAxis.range
844
+ const step = count > 1 ? (max - min) / (count - 1) : 0
845
+ return Array.from({ length: count }, (_, i) => formatTickValue(min + step * i))
846
+ }
847
+ return Array.from({ length: count }, (_, i) => String(i + 1))
848
+ }
849
+
850
+ /** Generate nice tick values for a numeric range. */
851
+ function niceTickValues(min: number, max: number): number[] {
852
+ const range = max - min
853
+ if (range <= 0) return [min]
854
+
855
+ const rawInterval = range / 6
856
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)))
857
+ const residual = rawInterval / magnitude
858
+ let niceInterval: number
859
+ if (residual <= 1.5) niceInterval = magnitude
860
+ else if (residual <= 3) niceInterval = 2 * magnitude
861
+ else if (residual <= 7) niceInterval = 5 * magnitude
862
+ else niceInterval = 10 * magnitude
863
+
864
+ const start = Math.ceil(min / niceInterval) * niceInterval
865
+ const ticks: number[] = []
866
+ for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) {
867
+ ticks.push(Math.round(v * 1e10) / 1e10)
868
+ }
869
+ return ticks
870
+ }
871
+
872
+ function formatTickValue(v: number): string {
873
+ if (Number.isInteger(v)) return String(v)
874
+ return v.toFixed(Math.abs(v) < 10 ? 1 : 0)
875
+ }