@diagrammo/dgmo 0.8.20 → 0.8.22

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 (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -9,6 +9,8 @@ import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import type { InfraTagGroup } from './types';
11
11
  import { resolveColor } from '../colors';
12
+ import { renderInlineText } from '../utils/inline-markdown';
13
+ import { preprocessDescriptionLine } from '../utils/description-helpers';
12
14
  import type {
13
15
  InfraLayoutResult,
14
16
  InfraLayoutNode,
@@ -1432,11 +1434,14 @@ function renderNodes(
1432
1434
  const expanded = expandedNodeIds?.has(node.id) ?? false;
1433
1435
 
1434
1436
  // Description subtitle — shown below label only when node is selected
1435
- const descH =
1436
- expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
1437
- if (descH > 0 && node.description) {
1438
- const descTruncated = truncateDesc(node.description);
1439
- const isTruncated = descTruncated !== node.description;
1437
+ const descLines =
1438
+ expanded && node.description && !node.isEdge ? node.description : [];
1439
+ const descH = descLines.length * META_LINE_HEIGHT;
1440
+ for (let di = 0; di < descLines.length; di++) {
1441
+ const rawLine = descLines[di];
1442
+ const processed = preprocessDescriptionLine(rawLine);
1443
+ const descTruncated = truncateDesc(processed);
1444
+ const isTruncated = descTruncated !== processed;
1440
1445
  const textEl = g
1441
1446
  .append('text')
1442
1447
  .attr('x', node.x)
@@ -1444,15 +1449,16 @@ function renderNodes(
1444
1449
  'y',
1445
1450
  y +
1446
1451
  NODE_HEADER_HEIGHT +
1452
+ di * META_LINE_HEIGHT +
1447
1453
  META_LINE_HEIGHT / 2 +
1448
1454
  META_FONT_SIZE * 0.35
1449
1455
  )
1450
1456
  .attr('text-anchor', 'middle')
1451
1457
  .attr('font-family', FONT_FAMILY)
1452
1458
  .attr('font-size', META_FONT_SIZE)
1453
- .attr('fill', mutedColor)
1454
- .text(descTruncated);
1455
- if (isTruncated) textEl.append('title').text(node.description);
1459
+ .attr('fill', mutedColor);
1460
+ renderInlineText(textEl, descTruncated, palette, META_FONT_SIZE);
1461
+ if (isTruncated) textEl.append('title').text(rawLine);
1456
1462
  }
1457
1463
 
1458
1464
  // Declared properties only shown when node is selected (expanded)
@@ -62,7 +62,7 @@ export interface InfraNode {
62
62
  groupId: string | null;
63
63
  tags: Record<string, string>; // tagGroup -> tagValue
64
64
  isEdge: boolean; // true for the `edge` entry-point component
65
- description?: string;
65
+ description?: string[];
66
66
  lineNumber: number;
67
67
  }
68
68
 
@@ -170,7 +170,7 @@ export interface ComputedInfraNode {
170
170
  };
171
171
  properties: InfraProperty[];
172
172
  tags: Record<string, string>;
173
- description?: string;
173
+ description?: string[];
174
174
  lineNumber: number;
175
175
  }
176
176
 
@@ -186,7 +186,14 @@ export interface ComputedInfraEdge {
186
186
  }
187
187
 
188
188
  export interface InfraDiagnostic {
189
- type: 'SPLIT_SUM' | 'CYCLE' | 'OVERLOAD' | 'RATE_LIMITED' | 'ORPHAN' | 'SYNTAX' | 'UPTIME';
189
+ type:
190
+ | 'SPLIT_SUM'
191
+ | 'CYCLE'
192
+ | 'OVERLOAD'
193
+ | 'RATE_LIMITED'
194
+ | 'ORPHAN'
195
+ | 'SYNTAX'
196
+ | 'UPTIME';
190
197
  line: number;
191
198
  message: string;
192
199
  }
@@ -0,0 +1,386 @@
1
+ import type { PaletteColors } from '../palettes';
2
+ import { mix } from '../palettes/color-utils';
3
+ import type {
4
+ ParsedJourneyMap,
5
+ JourneyMapPhase,
6
+ JourneyMapStep,
7
+ } from './types';
8
+
9
+ // ============================================================
10
+ // Layout Types
11
+ // ============================================================
12
+
13
+ export interface CurvePoint {
14
+ x: number;
15
+ y: number;
16
+ score: number;
17
+ emotionLabel?: string;
18
+ stepIndex: number;
19
+ }
20
+
21
+ export interface StepLayout {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ step: JourneyMapStep;
27
+ color: string;
28
+ }
29
+
30
+ export interface PhaseLayout {
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ phase: JourneyMapPhase;
36
+ headerColor: string;
37
+ stepLayouts: StepLayout[];
38
+ }
39
+
40
+ export interface JourneyMapLayout {
41
+ phases: PhaseLayout[];
42
+ flatStepLayouts: StepLayout[];
43
+ curvePoints: CurvePoint[];
44
+ totalWidth: number;
45
+ totalHeight: number;
46
+ curveAreaTop: number;
47
+ curveAreaBottom: number;
48
+ cardAreaTop: number;
49
+ personaHeight: number;
50
+ titleHeight: number;
51
+ /** Whether any step has thought annotations */
52
+ hasThoughts: boolean;
53
+ }
54
+
55
+ // ============================================================
56
+ // Constants
57
+ // ============================================================
58
+
59
+ const PADDING = 24;
60
+ const TITLE_HEIGHT = 36;
61
+ const PERSONA_HEIGHT = 48;
62
+ const CURVE_AREA_HEIGHT = 260;
63
+ const CARD_GAP = 8;
64
+ const STEP_CARD_WIDTH = 190;
65
+ const CARD_HEADER_HEIGHT = 24;
66
+ const CARD_META_LINE_HEIGHT = 14;
67
+ const PHASE_HEADER_HEIGHT = 36;
68
+ const CARD_PADDING_X = 10;
69
+ const CARD_PADDING_Y = 6;
70
+ const ANNO_ICON_SIZE = 10;
71
+ const ANNO_ICON_GAP = 4;
72
+ export const TAG_STRIP_HEIGHT = 18;
73
+ const PHASE_GAP = 16;
74
+ const COLUMN_PADDING = 12;
75
+ const FACE_ICON_SIZE = 20;
76
+
77
+ // ============================================================
78
+ // Score-to-color
79
+ // ============================================================
80
+
81
+ export function scoreToColor(score: number, palette: PaletteColors): string {
82
+ // 5=green, 1=red — interpolate
83
+ const t = ((5 - score) / 4) * 100;
84
+ return mix(palette.colors.red, palette.colors.green, t);
85
+ }
86
+
87
+ // ============================================================
88
+ // Layout Engine
89
+ // ============================================================
90
+
91
+ export const COLLAPSED_PHASE_WIDTH = 60;
92
+
93
+ export function layoutJourneyMap(
94
+ parsed: ParsedJourneyMap,
95
+ palette: PaletteColors,
96
+ options?: {
97
+ exportDims?: { width: number; height: number };
98
+ collapsedPhases?: Set<string>;
99
+ }
100
+ ): JourneyMapLayout {
101
+ const hasTitle = !!parsed.title;
102
+ const hasPersona = !!parsed.persona;
103
+ const hasPhases = parsed.phases.length > 0;
104
+
105
+ const titleHeight = hasTitle ? TITLE_HEIGHT : 0;
106
+ const personaHeight = hasPersona ? PERSONA_HEIGHT : 0;
107
+
108
+ // Thought bubbles render as overlays on hover — no reserved vertical space
109
+ const allStepsForThoughts = hasPhases
110
+ ? parsed.phases.flatMap((p) => p.steps)
111
+ : parsed.steps;
112
+ const hasThoughts = allStepsForThoughts.some((s) =>
113
+ s.annotations.some((a) => a.type === 'thought')
114
+ );
115
+
116
+ const curveAreaTop = PADDING + titleHeight + personaHeight;
117
+ const curveAreaBottom = curveAreaTop + CURVE_AREA_HEIGHT;
118
+ const cardAreaTop = curveAreaBottom + PADDING;
119
+
120
+ const allSteps = hasPhases
121
+ ? parsed.phases.flatMap((p) => p.steps)
122
+ : parsed.steps;
123
+
124
+ // Compute step card heights based on content (matches kanban card sizing)
125
+ const annoIconIndent = ANNO_ICON_SIZE + ANNO_ICON_GAP;
126
+ const annoTextW = STEP_CARD_WIDTH - CARD_PADDING_X * 2 - annoIconIndent;
127
+ const descTextWidth = STEP_CARD_WIDTH - CARD_PADDING_X * 2;
128
+ const charWidth = 4.8; // average char width at FONT_SIZE_META (10px)
129
+
130
+ const titleTextWidth = STEP_CARD_WIDTH - CARD_PADDING_X * 2;
131
+ const titleCharWidth = 6.5; // average char width at FONT_SIZE_STEP (12px)
132
+ const TITLE_LINE_HEIGHT = 16;
133
+
134
+ const stepHeights = allSteps.map((step) => {
135
+ const titleLines = wrapLineCount(
136
+ step.title,
137
+ titleTextWidth,
138
+ titleCharWidth
139
+ );
140
+ let h = CARD_PADDING_Y + titleLines * TITLE_LINE_HEIGHT + CARD_PADDING_Y;
141
+ const cardAnnos = step.annotations;
142
+ let contentLines = 0;
143
+ // Description may wrap
144
+ if (step.description) {
145
+ contentLines += wrapLineCount(step.description, descTextWidth, charWidth);
146
+ }
147
+ // Annotations: all lines indented past icon
148
+ for (const anno of cardAnnos) {
149
+ contentLines += wrapLineCount(anno.text, annoTextW, charWidth);
150
+ }
151
+ if (contentLines > 0) {
152
+ h += contentLines * CARD_META_LINE_HEIGHT + 4; // 4px bottom padding
153
+ }
154
+ return h;
155
+ });
156
+
157
+ const minCardHeight = CARD_HEADER_HEIGHT + CARD_META_LINE_HEIGHT;
158
+ const maxCardHeight = Math.max(minCardHeight, ...stepHeights);
159
+
160
+ // Check if any step has tags — if so, reserve space for the tag strip above cards
161
+ const hasTags = allSteps.some((s) => Object.keys(s.tags).length > 0);
162
+ const tagStripOffset = hasTags ? TAG_STRIP_HEIGHT + 6 : 0;
163
+
164
+ // Layout phases or flat steps
165
+ const phaseLayouts: PhaseLayout[] = [];
166
+ const flatStepLayouts: StepLayout[] = [];
167
+ const curvePoints: CurvePoint[] = [];
168
+
169
+ let globalStepIndex = 0;
170
+
171
+ const collapsed = options?.collapsedPhases ?? new Set<string>();
172
+
173
+ if (hasPhases) {
174
+ let phaseX = PADDING;
175
+
176
+ for (const phase of parsed.phases) {
177
+ const isCollapsed = collapsed.has(phase.name);
178
+ const stepCount = Math.max(phase.steps.length, 1);
179
+ const phaseWidth = isCollapsed
180
+ ? STEP_CARD_WIDTH + COLUMN_PADDING * 2
181
+ : stepCount * STEP_CARD_WIDTH +
182
+ (stepCount - 1) * CARD_GAP +
183
+ COLUMN_PADDING * 2;
184
+
185
+ const stepLayouts: StepLayout[] = [];
186
+
187
+ if (!isCollapsed) {
188
+ let stepX = phaseX + COLUMN_PADDING;
189
+
190
+ for (let si = 0; si < phase.steps.length; si++) {
191
+ const step = phase.steps[si];
192
+ const color =
193
+ step.score !== undefined
194
+ ? scoreToColor(step.score, palette)
195
+ : palette.surface;
196
+
197
+ const sl: StepLayout = {
198
+ x: stepX,
199
+ y: cardAreaTop + PHASE_HEADER_HEIGHT + CARD_GAP + tagStripOffset,
200
+ width: STEP_CARD_WIDTH,
201
+ height: maxCardHeight,
202
+ step,
203
+ color,
204
+ };
205
+ stepLayouts.push(sl);
206
+
207
+ // Curve point
208
+ if (step.score !== undefined) {
209
+ const curveX = stepX + STEP_CARD_WIDTH / 2;
210
+ const curveY =
211
+ curveAreaBottom -
212
+ ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 120) -
213
+ 10;
214
+ curvePoints.push({
215
+ x: curveX,
216
+ y: curveY,
217
+ score: step.score,
218
+ emotionLabel: step.emotionLabel,
219
+ stepIndex: globalStepIndex,
220
+ });
221
+ }
222
+
223
+ stepX += STEP_CARD_WIDTH + CARD_GAP;
224
+ globalStepIndex++;
225
+ }
226
+ } else {
227
+ // Collapsed: spread curve points across the compressed column width
228
+ const stepCount = phase.steps.length;
229
+ const padX = COLUMN_PADDING + FACE_ICON_SIZE;
230
+ const availW = phaseWidth - padX * 2;
231
+ for (let si = 0; si < stepCount; si++) {
232
+ const step = phase.steps[si];
233
+ if (step.score !== undefined) {
234
+ const curveX =
235
+ stepCount === 1
236
+ ? phaseX + phaseWidth / 2
237
+ : phaseX + padX + (si / (stepCount - 1)) * availW;
238
+ const curveY =
239
+ curveAreaBottom -
240
+ ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 120) -
241
+ 10;
242
+ curvePoints.push({
243
+ x: curveX,
244
+ y: curveY,
245
+ score: step.score,
246
+ emotionLabel: step.emotionLabel,
247
+ stepIndex: globalStepIndex,
248
+ });
249
+ }
250
+ globalStepIndex++;
251
+ }
252
+ }
253
+
254
+ // Phase header color from average score
255
+ const scoredSteps = phase.steps.filter((s) => s.score !== undefined);
256
+ const avgScore =
257
+ scoredSteps.length > 0
258
+ ? scoredSteps.reduce((sum, s) => sum + s.score!, 0) /
259
+ scoredSteps.length
260
+ : 3;
261
+ const headerColor = mix(scoreToColor(avgScore, palette), palette.bg, 25);
262
+
263
+ const COLLAPSED_CARD_H = 26;
264
+ const COLLAPSED_GAP = 6;
265
+ const phaseHeight = isCollapsed
266
+ ? PHASE_HEADER_HEIGHT +
267
+ CARD_GAP +
268
+ phase.steps.length * (COLLAPSED_CARD_H + COLLAPSED_GAP) +
269
+ CARD_GAP
270
+ : PHASE_HEADER_HEIGHT +
271
+ CARD_GAP +
272
+ tagStripOffset +
273
+ maxCardHeight +
274
+ CARD_GAP;
275
+
276
+ phaseLayouts.push({
277
+ x: phaseX,
278
+ y: cardAreaTop,
279
+ width: phaseWidth,
280
+ height: phaseHeight,
281
+ phase,
282
+ headerColor,
283
+ stepLayouts,
284
+ });
285
+
286
+ phaseX += phaseWidth + PHASE_GAP;
287
+ }
288
+ } else {
289
+ // Flat mode
290
+ let stepX = PADDING;
291
+
292
+ for (let si = 0; si < parsed.steps.length; si++) {
293
+ const step = parsed.steps[si];
294
+ const color =
295
+ step.score !== undefined
296
+ ? scoreToColor(step.score, palette)
297
+ : palette.surface;
298
+
299
+ flatStepLayouts.push({
300
+ x: stepX,
301
+ y: cardAreaTop + CARD_GAP + tagStripOffset,
302
+ width: STEP_CARD_WIDTH,
303
+ height: maxCardHeight,
304
+ step,
305
+ color,
306
+ });
307
+
308
+ if (step.score !== undefined) {
309
+ const curveX = stepX + STEP_CARD_WIDTH / 2;
310
+ const curveY =
311
+ curveAreaBottom -
312
+ ((step.score - 1) / 4) * (CURVE_AREA_HEIGHT - 20) -
313
+ 10;
314
+ curvePoints.push({
315
+ x: curveX,
316
+ y: curveY,
317
+ score: step.score,
318
+ emotionLabel: step.emotionLabel,
319
+ stepIndex: si,
320
+ });
321
+ }
322
+
323
+ stepX += STEP_CARD_WIDTH + CARD_GAP;
324
+ globalStepIndex++;
325
+ }
326
+ }
327
+
328
+ // Compute total dimensions
329
+ const rightEdge = hasPhases
330
+ ? phaseLayouts.length > 0
331
+ ? phaseLayouts[phaseLayouts.length - 1].x +
332
+ phaseLayouts[phaseLayouts.length - 1].width +
333
+ PADDING
334
+ : PADDING * 2
335
+ : flatStepLayouts.length > 0
336
+ ? flatStepLayouts[flatStepLayouts.length - 1].x +
337
+ STEP_CARD_WIDTH +
338
+ PADDING
339
+ : PADDING * 2;
340
+
341
+ const bottomEdge = hasPhases
342
+ ? phaseLayouts.length > 0
343
+ ? phaseLayouts[0].y + phaseLayouts[0].height + PADDING + 40
344
+ : cardAreaTop + PADDING
345
+ : cardAreaTop + CARD_GAP + tagStripOffset + maxCardHeight + PADDING + 40;
346
+
347
+ // Add space for score legend at bottom
348
+ const totalWidth = Math.max(rightEdge, 400);
349
+ const totalHeight = bottomEdge;
350
+
351
+ return {
352
+ phases: phaseLayouts,
353
+ flatStepLayouts,
354
+ curvePoints,
355
+ totalWidth,
356
+ totalHeight,
357
+ curveAreaTop,
358
+ curveAreaBottom,
359
+ cardAreaTop,
360
+ personaHeight,
361
+ titleHeight,
362
+ hasThoughts,
363
+ };
364
+ }
365
+
366
+ /** Count how many visual lines a text string will occupy when wrapped. */
367
+ function wrapLineCount(
368
+ text: string,
369
+ maxWidth: number,
370
+ charWidth: number
371
+ ): number {
372
+ const maxChars = Math.max(1, Math.floor(maxWidth / charWidth));
373
+ const words = text.split(/\s+/);
374
+ let lines = 1;
375
+ let currentLen = 0;
376
+ for (const word of words) {
377
+ const needed = currentLen > 0 ? word.length + 1 : word.length;
378
+ if (currentLen + needed > maxChars && currentLen > 0) {
379
+ lines++;
380
+ currentLen = word.length;
381
+ } else {
382
+ currentLen += needed;
383
+ }
384
+ }
385
+ return lines;
386
+ }