@diagrammo/dgmo 0.8.21 → 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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
@@ -6,10 +6,7 @@
6
6
  // post-layout bounding box wrappers around their children.
7
7
 
8
8
  import dagre from '@dagrejs/dagre';
9
- import type {
10
- ComputedInfraModel,
11
- ComputedInfraNode,
12
- } from './types';
9
+ import type { ComputedInfraModel, ComputedInfraNode } from './types';
13
10
 
14
11
  // ============================================================
15
12
  // Layout types
@@ -39,7 +36,7 @@ export interface InfraLayoutNode {
39
36
  properties: ComputedInfraNode['properties'];
40
37
  queueMetrics?: ComputedInfraNode['queueMetrics'];
41
38
  tags: Record<string, string>;
42
- description?: string;
39
+ description?: string[];
43
40
  lineNumber: number;
44
41
  }
45
42
 
@@ -101,24 +98,49 @@ const EDGE_MARGIN = 60;
101
98
 
102
99
  /** Display property keys shown as key: value rows. */
103
100
  const DISPLAY_KEYS = new Set([
104
- 'cache-hit', 'firewall-block', 'ratelimit-rps',
105
- 'latency-ms', 'uptime', 'instances', 'max-rps',
106
- 'cb-error-threshold', 'cb-latency-threshold-ms',
107
- 'concurrency', 'duration-ms', 'cold-start-ms',
108
- 'buffer', 'drain-rate', 'retention-hours', 'partitions',
101
+ 'cache-hit',
102
+ 'firewall-block',
103
+ 'ratelimit-rps',
104
+ 'latency-ms',
105
+ 'uptime',
106
+ 'instances',
107
+ 'max-rps',
108
+ 'cb-error-threshold',
109
+ 'cb-latency-threshold-ms',
110
+ 'concurrency',
111
+ 'duration-ms',
112
+ 'cold-start-ms',
113
+ 'buffer',
114
+ 'drain-rate',
115
+ 'retention-hours',
116
+ 'partitions',
109
117
  ]);
110
118
 
111
119
  /** Display names for width estimation. */
112
120
  const DISPLAY_NAMES: Record<string, string> = {
113
- 'cache-hit': 'cache hit', 'firewall-block': 'firewall block',
114
- 'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
115
- 'instances': 'instances', 'max-rps': 'max RPS',
116
- 'cb-error-threshold': 'CB error threshold', 'cb-latency-threshold-ms': 'CB latency threshold',
117
- 'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
118
- 'buffer': 'buffer', 'drain-rate': 'drain rate', 'retention-hours': 'retention', 'partitions': 'partitions',
121
+ 'cache-hit': 'cache hit',
122
+ 'firewall-block': 'firewall block',
123
+ 'ratelimit-rps': 'rate limit RPS',
124
+ 'latency-ms': 'latency',
125
+ uptime: 'uptime',
126
+ instances: 'instances',
127
+ 'max-rps': 'max RPS',
128
+ 'cb-error-threshold': 'CB error threshold',
129
+ 'cb-latency-threshold-ms': 'CB latency threshold',
130
+ concurrency: 'concurrency',
131
+ 'duration-ms': 'duration',
132
+ 'cold-start-ms': 'cold start',
133
+ buffer: 'buffer',
134
+ 'drain-rate': 'drain rate',
135
+ 'retention-hours': 'retention',
136
+ partitions: 'partitions',
119
137
  };
120
138
 
121
- function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
139
+ function countDisplayProps(
140
+ node: ComputedInfraNode,
141
+ expanded: boolean,
142
+ options?: Record<string, string>
143
+ ): number {
122
144
  // Declared properties are only shown when the node is selected (expanded)
123
145
  if (!expanded) return 0;
124
146
  let count = node.properties.filter((p) => DISPLAY_KEYS.has(p.key)).length;
@@ -154,7 +176,11 @@ function countComputedRows(node: ComputedInfraNode, expanded: boolean): number {
154
176
  // Queue computed rows: lag + overflow
155
177
  if (node.queueMetrics) {
156
178
  if (node.queueMetrics.fillRate > 0) count += 1; // lag row
157
- if (node.queueMetrics.fillRate > 0 && node.queueMetrics.timeToOverflow < Infinity) count += 1; // overflow row
179
+ if (
180
+ node.queueMetrics.fillRate > 0 &&
181
+ node.queueMetrics.timeToOverflow < Infinity
182
+ )
183
+ count += 1; // overflow row
158
184
  }
159
185
  return count;
160
186
  }
@@ -164,10 +190,16 @@ function hasRoles(node: ComputedInfraNode): boolean {
164
190
  return node.properties.some((p) => DISPLAY_KEYS.has(p.key));
165
191
  }
166
192
 
167
- function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
193
+ function computeNodeWidth(
194
+ node: ComputedInfraNode,
195
+ expanded: boolean,
196
+ options?: Record<string, string>
197
+ ): number {
168
198
  // Account for badge text (e.g., "3x") in header width — serverless nodes no longer show a badge
169
- const badgeVal = node.computedConcurrentInvocations === 0 && node.computedInstances > 1
170
- ? node.computedInstances : 0;
199
+ const badgeVal =
200
+ node.computedConcurrentInvocations === 0 && node.computedInstances > 1
201
+ ? node.computedInstances
202
+ : 0;
171
203
  const badgeLen = badgeVal > 0 ? `${badgeVal}x`.length + 2 : 0;
172
204
  const labelWidth = (node.label.length + badgeLen) * CHAR_WIDTH + PADDING_X;
173
205
 
@@ -185,8 +217,18 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
185
217
  const hasLatency = node.properties.some((p) => p.key === 'latency-ms');
186
218
  const hasUptime = node.properties.some((p) => p.key === 'uptime');
187
219
  const isServerless = node.properties.some((p) => p.key === 'concurrency');
188
- if (!hasLatency && !isServerless && (parseFloat(options['default-latency-ms'] ?? '') || 0) > 0) allKeys.push('latency');
189
- if (!hasUptime && (parseFloat(options['default-uptime'] ?? '') || 0) > 0 && parseFloat(options['default-uptime'] ?? '') < 100) allKeys.push('uptime');
220
+ if (
221
+ !hasLatency &&
222
+ !isServerless &&
223
+ (parseFloat(options['default-latency-ms'] ?? '') || 0) > 0
224
+ )
225
+ allKeys.push('latency');
226
+ if (
227
+ !hasUptime &&
228
+ (parseFloat(options['default-uptime'] ?? '') || 0) > 0 &&
229
+ parseFloat(options['default-uptime'] ?? '') < 100
230
+ )
231
+ allKeys.push('uptime');
190
232
  }
191
233
  }
192
234
  // Computed rows
@@ -203,8 +245,13 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
203
245
  }
204
246
  if (node.computedUptime < 1) {
205
247
  const declaredUptime = node.properties.find((p) => p.key === 'uptime');
206
- const declaredVal = declaredUptime ? Number(declaredUptime.value) / 100 : 1;
207
- if (Math.abs(node.computedUptime - declaredVal) > 0.000001 || node.isEdge) {
248
+ const declaredVal = declaredUptime
249
+ ? Number(declaredUptime.value) / 100
250
+ : 1;
251
+ if (
252
+ Math.abs(node.computedUptime - declaredVal) > 0.000001 ||
253
+ node.isEdge
254
+ ) {
208
255
  allKeys.push('eff. uptime');
209
256
  }
210
257
  }
@@ -212,7 +259,11 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
212
259
  if (node.computedCbState === 'open') allKeys.push('CB');
213
260
  if (node.queueMetrics) {
214
261
  if (node.queueMetrics.fillRate > 0) allKeys.push('lag');
215
- if (node.queueMetrics.fillRate > 0 && node.queueMetrics.timeToOverflow < Infinity) allKeys.push('overflow');
262
+ if (
263
+ node.queueMetrics.fillRate > 0 &&
264
+ node.queueMetrics.timeToOverflow < Infinity
265
+ )
266
+ allKeys.push('overflow');
216
267
  }
217
268
  }
218
269
  if (allKeys.length === 0) return Math.max(MIN_NODE_WIDTH, labelWidth);
@@ -226,33 +277,57 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
226
277
  const nodeRateLimit = getNumProp(node, 'ratelimit-rps', 0);
227
278
  const nodeConcurrency = getNumProp(node, 'concurrency', 0);
228
279
  const nodeDurationMs = getNumProp(node, 'duration-ms', 100);
229
- const serverlessCap = nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
230
- const effectiveCap = serverlessCap > 0 ? serverlessCap
231
- : nodeMaxRps > 0 && nodeRateLimit > 0
232
- ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
233
- : nodeMaxRps > 0 ? nodeMaxRps * node.computedInstances
234
- : nodeRateLimit > 0 ? nodeRateLimit
235
- : 0;
236
- const rpsVal = effectiveCap > 0 && !node.isEdge
237
- ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
238
- : formatRps(node.computedRps);
239
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + rpsVal.length) * META_CHAR_WIDTH);
280
+ const serverlessCap =
281
+ nodeConcurrency > 0 ? nodeConcurrency / (nodeDurationMs / 1000) : 0;
282
+ const effectiveCap =
283
+ serverlessCap > 0
284
+ ? serverlessCap
285
+ : nodeMaxRps > 0 && nodeRateLimit > 0
286
+ ? Math.min(nodeMaxRps * node.computedInstances, nodeRateLimit)
287
+ : nodeMaxRps > 0
288
+ ? nodeMaxRps * node.computedInstances
289
+ : nodeRateLimit > 0
290
+ ? nodeRateLimit
291
+ : 0;
292
+ const rpsVal =
293
+ effectiveCap > 0 && !node.isEdge
294
+ ? `${formatRpsShort(node.computedRps)} / ${formatRpsShort(effectiveCap)}`
295
+ : formatRps(node.computedRps);
296
+ maxRowWidth = Math.max(
297
+ maxRowWidth,
298
+ (maxKeyLen + 2 + rpsVal.length) * META_CHAR_WIDTH
299
+ );
240
300
  }
241
301
  // Declared property value widths only when expanded
242
302
  if (expanded) {
243
303
  for (const p of node.properties) {
244
304
  const dk = DISPLAY_NAMES[p.key];
245
305
  if (!dk) continue;
246
- const numVal = typeof p.value === 'number' ? p.value : parseFloat(String(p.value)) || 0;
247
- const PCT_KEYS = ['cache-hit', 'firewall-block', 'uptime', 'cb-error-threshold'];
248
- const valLen = (p.key === 'max-rps' || p.key === 'ratelimit-rps')
249
- ? formatRpsShort(numVal).length
250
- : (p.key === 'latency-ms' || p.key === 'cb-latency-threshold-ms' || p.key === 'duration-ms' || p.key === 'cold-start-ms')
251
- ? formatMs(numVal).length
252
- : PCT_KEYS.includes(p.key)
253
- ? `${numVal}%`.length
254
- : String(p.value).length;
255
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
306
+ const numVal =
307
+ typeof p.value === 'number'
308
+ ? p.value
309
+ : parseFloat(String(p.value)) || 0;
310
+ const PCT_KEYS = [
311
+ 'cache-hit',
312
+ 'firewall-block',
313
+ 'uptime',
314
+ 'cb-error-threshold',
315
+ ];
316
+ const valLen =
317
+ p.key === 'max-rps' || p.key === 'ratelimit-rps'
318
+ ? formatRpsShort(numVal).length
319
+ : p.key === 'latency-ms' ||
320
+ p.key === 'cb-latency-threshold-ms' ||
321
+ p.key === 'duration-ms' ||
322
+ p.key === 'cold-start-ms'
323
+ ? formatMs(numVal).length
324
+ : PCT_KEYS.includes(p.key)
325
+ ? `${numVal}%`.length
326
+ : String(p.value).length;
327
+ maxRowWidth = Math.max(
328
+ maxRowWidth,
329
+ (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
330
+ );
256
331
  }
257
332
  }
258
333
  // Computed row widths (e.g., "p90: 520ms" or "p90: 520ms / 500ms" when SLO configured)
@@ -262,7 +337,10 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
262
337
  for (const ms of msValues) {
263
338
  if (ms > 0) {
264
339
  const valLen = formatMs(ms).length;
265
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
340
+ maxRowWidth = Math.max(
341
+ maxRowWidth,
342
+ (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
343
+ );
266
344
  }
267
345
  }
268
346
  // p90 may show "<current> / <threshold>" when non-green. Always reserve combined width
@@ -271,41 +349,69 @@ function computeNodeWidth(node: ComputedInfraNode, expanded: boolean, options?:
271
349
  const rawThreshold =
272
350
  node.properties.find((p) => p.key === 'slo-p90-latency-ms')?.value ??
273
351
  options?.['slo-p90-latency-ms'];
274
- const threshold = rawThreshold != null ? parseFloat(String(rawThreshold)) : NaN;
352
+ const threshold =
353
+ rawThreshold != null ? parseFloat(String(rawThreshold)) : NaN;
275
354
  if (!isNaN(threshold) && threshold > 0) {
276
355
  // formatMs here must produce the same string as formatMsShort in renderer.ts — both are identical.
277
356
  // If either changes, the reserved width and the rendered text will diverge.
278
357
  const combinedVal = `${formatMs(perc.p90)} / ${formatMs(threshold)}`;
279
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + combinedVal.length) * META_CHAR_WIDTH);
358
+ maxRowWidth = Math.max(
359
+ maxRowWidth,
360
+ (maxKeyLen + 2 + combinedVal.length) * META_CHAR_WIDTH
361
+ );
280
362
  }
281
363
  }
282
364
  if (node.computedUptime < 1) {
283
365
  const valLen = formatUptime(node.computedUptime).length;
284
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
366
+ maxRowWidth = Math.max(
367
+ maxRowWidth,
368
+ (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
369
+ );
285
370
  }
286
371
  if (node.computedAvailability < 1) {
287
372
  const valLen = formatUptime(node.computedAvailability).length;
288
- maxRowWidth = Math.max(maxRowWidth, (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH);
373
+ maxRowWidth = Math.max(
374
+ maxRowWidth,
375
+ (maxKeyLen + 2 + valLen) * META_CHAR_WIDTH
376
+ );
289
377
  }
290
378
  // CB state row ("CB: OPEN") — inverted pill, use full text width
291
379
  if (node.computedCbState === 'open') {
292
- maxRowWidth = Math.max(maxRowWidth, 'CB: OPEN'.length * META_CHAR_WIDTH + 8);
380
+ maxRowWidth = Math.max(
381
+ maxRowWidth,
382
+ 'CB: OPEN'.length * META_CHAR_WIDTH + 8
383
+ );
293
384
  }
294
385
  }
295
386
 
296
387
  const DESC_MAX_CHARS = 120;
297
- const descText = (expanded && node.description && !node.isEdge) ? node.description : '';
298
- const descTruncated = descText.length > DESC_MAX_CHARS ? descText.slice(0, DESC_MAX_CHARS - 1) + '…' : descText;
299
- const descWidth = descTruncated.length > 0 ? descTruncated.length * META_CHAR_WIDTH + PADDING_X : 0;
388
+ const descLines =
389
+ expanded && node.description && !node.isEdge ? node.description : [];
390
+ let descWidth = 0;
391
+ for (const dl of descLines) {
392
+ const truncated =
393
+ dl.length > DESC_MAX_CHARS ? dl.slice(0, DESC_MAX_CHARS - 1) + '…' : dl;
394
+ descWidth = Math.max(
395
+ descWidth,
396
+ truncated.length * META_CHAR_WIDTH + PADDING_X
397
+ );
398
+ }
300
399
  return Math.max(MIN_NODE_WIDTH, labelWidth, maxRowWidth + 20, descWidth);
301
400
  }
302
401
 
303
- function computeNodeHeight(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
402
+ function computeNodeHeight(
403
+ node: ComputedInfraNode,
404
+ expanded: boolean,
405
+ options?: Record<string, string>
406
+ ): number {
304
407
  const propCount = countDisplayProps(node, expanded, options);
305
408
  const computedCount = countComputedRows(node, expanded);
306
409
  const hasRps = node.computedRps > 0;
307
- const descH = expanded && node.description && !node.isEdge ? META_LINE_HEIGHT : 0;
308
- if (propCount === 0 && computedCount === 0 && !hasRps) return NODE_HEADER_HEIGHT + descH + NODE_PAD_BOTTOM;
410
+ const descLineCount =
411
+ expanded && node.description && !node.isEdge ? node.description.length : 0;
412
+ const descH = descLineCount * META_LINE_HEIGHT;
413
+ if (propCount === 0 && computedCount === 0 && !hasRps)
414
+ return NODE_HEADER_HEIGHT + descH + NODE_PAD_BOTTOM;
309
415
 
310
416
  let h = NODE_HEADER_HEIGHT + descH + NODE_SEPARATOR_GAP;
311
417
  // Computed section: RPS + computed rows
@@ -333,10 +439,16 @@ function formatRpsShort(rps: number): string {
333
439
  return `${Math.round(rps)}`;
334
440
  }
335
441
 
336
- function getNumProp(node: ComputedInfraNode, key: string, fallback: number): number {
442
+ function getNumProp(
443
+ node: ComputedInfraNode,
444
+ key: string,
445
+ fallback: number
446
+ ): number {
337
447
  const p = node.properties.find((pr) => pr.key === key);
338
448
  if (!p) return fallback;
339
- return typeof p.value === 'number' ? p.value : parseFloat(String(p.value)) || fallback;
449
+ return typeof p.value === 'number'
450
+ ? p.value
451
+ : parseFloat(String(p.value)) || fallback;
340
452
  }
341
453
 
342
454
  function formatMs(ms: number): string {
@@ -362,7 +474,7 @@ export function separateGroups(
362
474
  groups: InfraLayoutGroup[],
363
475
  nodes: InfraLayoutNode[],
364
476
  isLR: boolean,
365
- maxIterations = 20,
477
+ maxIterations = 20
366
478
  ): Map<string, { dx: number; dy: number }> {
367
479
  // Symmetric 2D rectangle intersection — no sorting needed, handles all
368
480
  // relative positions correctly, stable after mid-pass shifts.
@@ -402,8 +514,16 @@ export function separateGroups(
402
514
 
403
515
  // Accumulate the total delta for this group (used by fixEdgeWaypoints)
404
516
  const prev = groupDeltas.get(groupToShift.id) ?? { dx: 0, dy: 0 };
405
- if (isLR) groupDeltas.set(groupToShift.id, { dx: prev.dx, dy: prev.dy + shift });
406
- else groupDeltas.set(groupToShift.id, { dx: prev.dx + shift, dy: prev.dy });
517
+ if (isLR)
518
+ groupDeltas.set(groupToShift.id, {
519
+ dx: prev.dx,
520
+ dy: prev.dy + shift,
521
+ });
522
+ else
523
+ groupDeltas.set(groupToShift.id, {
524
+ dx: prev.dx + shift,
525
+ dy: prev.dy,
526
+ });
407
527
 
408
528
  for (const node of nodes) {
409
529
  if (node.groupId === groupToShift.id) {
@@ -413,10 +533,15 @@ export function separateGroups(
413
533
  }
414
534
  }
415
535
  }
416
- if (!anyOverlap) { converged = true; break; }
536
+ if (!anyOverlap) {
537
+ converged = true;
538
+ break;
539
+ }
417
540
  }
418
541
  if (!converged && maxIterations > 0) {
419
- console.warn(`separateGroups: hit maxIterations (${maxIterations}) without fully resolving all group overlaps`);
542
+ console.warn(
543
+ `separateGroups: hit maxIterations (${maxIterations}) without fully resolving all group overlaps`
544
+ );
420
545
  }
421
546
  return groupDeltas;
422
547
  }
@@ -424,7 +549,7 @@ export function separateGroups(
424
549
  export function fixEdgeWaypoints(
425
550
  edges: InfraLayoutEdge[],
426
551
  nodes: InfraLayoutNode[],
427
- groupDeltas: Map<string, { dx: number; dy: number }>,
552
+ groupDeltas: Map<string, { dx: number; dy: number }>
428
553
  ): void {
429
554
  if (groupDeltas.size === 0) return;
430
555
  const nodeToGroup = new Map<string, string | null>();
@@ -458,9 +583,21 @@ export function fixEdgeWaypoints(
458
583
  // Layout engine
459
584
  // ============================================================
460
585
 
461
- export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
586
+ export function layoutInfra(
587
+ computed: ComputedInfraModel,
588
+ expandedNodeIds?: Set<string> | null,
589
+ collapsedNodes?: Set<string> | null
590
+ ): InfraLayoutResult {
462
591
  if (computed.nodes.length === 0) {
463
- return { nodes: [], edges: [], groups: [], options: {}, direction: computed.direction, width: 0, height: 0 };
592
+ return {
593
+ nodes: [],
594
+ edges: [],
595
+ groups: [],
596
+ options: {},
597
+ direction: computed.direction,
598
+ width: 0,
599
+ height: 0,
600
+ };
464
601
  }
465
602
 
466
603
  const isLR = computed.direction !== 'TB';
@@ -487,7 +624,8 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
487
624
  const heightMap = new Map<string, number>();
488
625
  for (const node of computed.nodes) {
489
626
  const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
490
- const expanded = !isNodeCollapsed && (expandedNodeIds?.has(node.id) ?? false);
627
+ const expanded =
628
+ !isNodeCollapsed && (expandedNodeIds?.has(node.id) ?? false);
491
629
  const width = computeNodeWidth(node, expanded, computed.options);
492
630
  const height = isNodeCollapsed
493
631
  ? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
@@ -614,7 +752,10 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
614
752
  lineNumber: group.lineNumber,
615
753
  };
616
754
  }
617
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
755
+ let minX = Infinity,
756
+ minY = Infinity,
757
+ maxX = -Infinity,
758
+ maxY = -Infinity;
618
759
  for (const child of childNodes) {
619
760
  const left = child.x - child.width / 2;
620
761
  const right = child.x + child.width / 2;
@@ -642,7 +783,10 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
642
783
  fixEdgeWaypoints(layoutEdges, layoutNodes, groupDeltas);
643
784
 
644
785
  // Compute total dimensions
645
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
786
+ let minX = Infinity,
787
+ minY = Infinity,
788
+ maxX = -Infinity,
789
+ maxY = -Infinity;
646
790
  for (const node of layoutNodes) {
647
791
  const left = node.x - node.width / 2;
648
792
  const right = node.x + node.width / 2;
@@ -696,8 +840,8 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
696
840
  group.y += shiftY;
697
841
  }
698
842
 
699
- const totalWidth = (maxX + shiftX) + EDGE_MARGIN;
700
- const totalHeight = (maxY + shiftY) + EDGE_MARGIN;
843
+ const totalWidth = maxX + shiftX + EDGE_MARGIN;
844
+ const totalHeight = maxY + shiftY + EDGE_MARGIN;
701
845
 
702
846
  return {
703
847
  nodes: layoutNodes,
@@ -7,6 +7,7 @@
7
7
  // and connections, [Group] containers, tag groups, pipe metadata.
8
8
 
9
9
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
10
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
10
11
  import { resolveColorWithDiagnostic } from '../colors';
11
12
  import { parseInArrowLabel } from '../utils/arrows';
12
13
  import {
@@ -610,17 +611,33 @@ export function parseInfra(content: string): ParsedInfra {
610
611
  // description is display metadata, not a behavior key; silently ignored on edge nodes.
611
612
  // Single-line only — no length enforcement, but keep it short for legibility.
612
613
  if (key === 'description' && currentNode) {
613
- if (!currentNode.isEdge) currentNode.description = rawVal;
614
+ if (!currentNode.isEdge) {
615
+ if (!currentNode.description) currentNode.description = [];
616
+ currentNode.description.push(rawVal);
617
+ }
614
618
  continue;
615
619
  }
616
620
 
617
- // Validate property key
621
+ // Unknown keys: decide between property typo warning vs description collection.
622
+ // Heuristic: if the key looks like a plausible property (alphanumeric-hyphen, close
623
+ // match to a known key, or the value looks numeric/percentage), warn as typo.
624
+ // Otherwise treat the whole line as description text.
618
625
  if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
619
626
  const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];
620
- let msg = `Unknown property '${key}'.`;
621
627
  const hint = suggest(key, allKeys);
622
- if (hint) msg += ` ${hint}`;
623
- warn(lineNumber, msg);
628
+ const valueLooksNumeric = /^[\d.]+%?$/.test(rawVal);
629
+ if (hint || valueLooksNumeric) {
630
+ // Likely a typo — warn
631
+ let msg = `Unknown property '${key}'.`;
632
+ if (hint) msg += ` ${hint}`;
633
+ warn(lineNumber, msg);
634
+ } else if (!currentNode.isEdge) {
635
+ // Likely prose — collect as description
636
+ if (!currentNode.description) currentNode.description = [];
637
+ currentNode.description.push(trimmed);
638
+ continue;
639
+ }
640
+ continue;
624
641
  }
625
642
 
626
643
  // Validate edge-only keys
@@ -636,7 +653,14 @@ export function parseInfra(content: string): ParsedInfra {
636
653
  continue;
637
654
  }
638
655
 
639
- // Unknown indented line
656
+ // Unknown indented line — try as keywordless description
657
+ if (!currentNode.isEdge) {
658
+ const descResult = tryStripDescriptionKeyword(trimmed);
659
+ const descText = descResult.isKeyword ? descResult.text : trimmed;
660
+ if (!currentNode.description) currentNode.description = [];
661
+ currentNode.description.push(descText);
662
+ continue;
663
+ }
640
664
  warn(
641
665
  lineNumber,
642
666
  `Unexpected line inside component '${currentNode.label}'.`
@@ -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
  }