@diagrammo/dgmo 0.8.21 → 0.8.23

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 (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -10,8 +10,10 @@ import {
10
10
  } from '../utils/export-container';
11
11
  import type { PaletteColors } from '../palettes';
12
12
  import { mix } from '../palettes/color-utils';
13
+ import { resolveTagColor } from '../utils/tag-groups';
13
14
  import type { ParsedOrg } from './parser';
14
15
  import type { OrgLayoutResult } from './layout';
16
+ import type { AncestorInfo } from './collapse';
15
17
  import { parseOrg } from './parser';
16
18
  import { layoutOrg } from './layout';
17
19
  import {
@@ -51,6 +53,12 @@ const CONTAINER_HEADER_HEIGHT = 28;
51
53
  const COLLAPSE_BAR_HEIGHT = 6;
52
54
  const COLLAPSE_BAR_INSET = 0;
53
55
 
56
+ // Ancestor breadcrumb trail (focus mode)
57
+ const ANCESTOR_DOT_R = 4;
58
+ const ANCESTOR_LABEL_FONT_SIZE = 11;
59
+ const ANCESTOR_ROW_HEIGHT = 22;
60
+ const ANCESTOR_TRAIL_BOTTOM_GAP = 16;
61
+
54
62
  const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram — local, not shared
55
63
 
56
64
  // ============================================================
@@ -100,7 +108,8 @@ export function renderOrg(
100
108
  onClickItem?: (lineNumber: number) => void,
101
109
  exportDims?: { width?: number; height?: number },
102
110
  activeTagGroup?: string | null,
103
- hiddenAttributes?: Set<string>
111
+ hiddenAttributes?: Set<string>,
112
+ ancestorPath?: AncestorInfo[]
104
113
  ): void {
105
114
  // Clear existing content
106
115
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
@@ -128,9 +137,15 @@ export function renderOrg(
128
137
  const fixedTitle = !exportDims && !!parsed.title;
129
138
  const titleReserve = fixedTitle ? TITLE_HEIGHT : 0;
130
139
 
140
+ // Ancestor breadcrumb trail (focus mode) — rendered inside the scaled group
141
+ const hasAncestorTrail = !exportDims && ancestorPath && ancestorPath.length > 0;
142
+ const ancestorTrailHeight = hasAncestorTrail
143
+ ? ancestorPath.length * ANCESTOR_ROW_HEIGHT + ANCESTOR_TRAIL_BOTTOM_GAP
144
+ : 0;
145
+
131
146
  // Compute scale to fit diagram in viewport
132
147
  const diagramW = layout.width;
133
- let diagramH = layout.height + (fixedTitle ? 0 : titleOffset);
148
+ let diagramH = layout.height + (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
134
149
  if (fixedLegend) {
135
150
  // Remove the legend space from diagram height — legend is rendered separately
136
151
  diagramH -= layoutLegendShift;
@@ -200,10 +215,11 @@ export function renderOrg(
200
215
  }
201
216
  }
202
217
 
203
- // Content group (offset by title only when title is inside the scaled group)
218
+ // Content group (offset by title + ancestor trail height)
219
+ const contentYShift = (fixedTitle ? 0 : titleOffset) + ancestorTrailHeight;
204
220
  const contentG = mainG
205
221
  .append('g')
206
- .attr('transform', `translate(0, ${fixedTitle ? 0 : titleOffset})`);
222
+ .attr('transform', `translate(0, ${contentYShift})`);
207
223
 
208
224
  // Build display name map from tag groups (lowercase key → original casing)
209
225
  const displayNames = new Map<string, string>();
@@ -211,6 +227,9 @@ export function renderOrg(
211
227
  displayNames.set(group.name.toLowerCase(), group.name);
212
228
  }
213
229
 
230
+ // Root node IDs — focus icon is suppressed on these (already the tree root)
231
+ const rootNodeIds = new Set(parsed.roots.map((r) => r.id));
232
+
214
233
  // Render container backgrounds (bottom layer)
215
234
  const colorOff = parsed.options?.color === 'off';
216
235
  for (const c of layout.containers) {
@@ -322,6 +341,45 @@ export function renderOrg(
322
341
  .attr('clip-path', `url(#${clipId})`)
323
342
  .attr('class', 'org-collapse-bar');
324
343
  }
344
+
345
+ // Focus icon (hover-reveal, interactive only) — for non-root containers with children
346
+ if (!exportDims && c.hasChildren && !rootNodeIds.has(c.nodeId)) {
347
+ const iconSize = 14;
348
+ const iconPad = 5;
349
+ const iconX = c.width - iconSize - iconPad;
350
+ const iconY = iconPad;
351
+
352
+ const focusG = cG
353
+ .append('g')
354
+ .attr('class', 'org-focus-icon')
355
+ .attr('data-focus-node', c.nodeId)
356
+ .attr('transform', `translate(${iconX}, ${iconY})`);
357
+
358
+ focusG
359
+ .append('rect')
360
+ .attr('x', -3)
361
+ .attr('y', -3)
362
+ .attr('width', iconSize + 6)
363
+ .attr('height', iconSize + 6)
364
+ .attr('fill', 'transparent');
365
+
366
+ const cx = iconSize / 2;
367
+ const cy = iconSize / 2;
368
+ focusG
369
+ .append('circle')
370
+ .attr('cx', cx)
371
+ .attr('cy', cy)
372
+ .attr('r', iconSize / 2 - 1)
373
+ .attr('fill', 'none')
374
+ .attr('stroke', palette.textMuted)
375
+ .attr('stroke-width', 1.5);
376
+ focusG
377
+ .append('circle')
378
+ .attr('cx', cx)
379
+ .attr('cy', cy)
380
+ .attr('r', 2)
381
+ .attr('fill', palette.textMuted);
382
+ }
325
383
  }
326
384
 
327
385
  // Render edges
@@ -479,6 +537,156 @@ export function renderOrg(
479
537
  .attr('clip-path', `url(#${clipId})`)
480
538
  .attr('class', 'org-collapse-bar');
481
539
  }
540
+
541
+ // Focus icon (hover-reveal, interactive only) — for non-root nodes with children
542
+ if (!exportDims && node.hasChildren && !rootNodeIds.has(node.id)) {
543
+ const iconSize = 14;
544
+ const iconPad = 5;
545
+ const iconX = node.width - iconSize - iconPad;
546
+ const iconY = iconPad;
547
+
548
+ const focusG = nodeG
549
+ .append('g')
550
+ .attr('class', 'org-focus-icon')
551
+ .attr('data-focus-node', node.id)
552
+ .attr('transform', `translate(${iconX}, ${iconY})`);
553
+
554
+ // Hit area
555
+ focusG
556
+ .append('rect')
557
+ .attr('x', -3)
558
+ .attr('y', -3)
559
+ .attr('width', iconSize + 6)
560
+ .attr('height', iconSize + 6)
561
+ .attr('fill', 'transparent');
562
+
563
+ // Scope/target icon: outer circle + inner dot
564
+ const cx = iconSize / 2;
565
+ const cy = iconSize / 2;
566
+ focusG
567
+ .append('circle')
568
+ .attr('cx', cx)
569
+ .attr('cy', cy)
570
+ .attr('r', iconSize / 2 - 1)
571
+ .attr('fill', 'none')
572
+ .attr('stroke', palette.textMuted)
573
+ .attr('stroke-width', 1.5);
574
+ focusG
575
+ .append('circle')
576
+ .attr('cx', cx)
577
+ .attr('cy', cy)
578
+ .attr('r', 2)
579
+ .attr('fill', palette.textMuted);
580
+ }
581
+ }
582
+
583
+ // Render ancestor breadcrumb trail (focus mode) — inside scaled group,
584
+ // centered on and connected to the root node
585
+ if (hasAncestorTrail) {
586
+ // Find the root node/container position in the layout
587
+ const rootNode = layout.nodes.find((n) => rootNodeIds.has(n.id));
588
+ const rootContainer = !rootNode
589
+ ? layout.containers.find((c) => rootNodeIds.has(c.nodeId))
590
+ : null;
591
+ // Nodes: x is center. Containers: x is left edge, so center = x + width/2
592
+ const rootCenterX = rootNode
593
+ ? rootNode.x
594
+ : rootContainer
595
+ ? rootContainer.x + rootContainer.width / 2
596
+ : null;
597
+ const rootTopY = rootNode ? rootNode.y : rootContainer ? rootContainer.y : null;
598
+ if (rootCenterX !== null && rootTopY !== null) {
599
+ // Trail connects directly to the top edge of the root node.
600
+ // The last ancestor dot sits ANCESTOR_TRAIL_BOTTOM_GAP above the root.
601
+ const trailBottomY = rootTopY - ANCESTOR_TRAIL_BOTTOM_GAP;
602
+
603
+ const trailG = contentG
604
+ .append('g')
605
+ .attr('class', 'org-ancestor-trail');
606
+
607
+ const count = ancestorPath!.length;
608
+
609
+ // Compute dot positions (top-down order, topmost ancestor highest)
610
+ const dotPositions: number[] = [];
611
+ for (let i = 0; i < count; i++) {
612
+ const fromBottom = count - 1 - i;
613
+ dotPositions.push(trailBottomY - fromBottom * ANCESTOR_ROW_HEIGHT);
614
+ }
615
+
616
+ // Single continuous line from topmost dot to root node top edge
617
+ const lineTopY = dotPositions[0];
618
+ trailG
619
+ .append('line')
620
+ .attr('x1', rootCenterX)
621
+ .attr('y1', lineTopY)
622
+ .attr('x2', rootCenterX)
623
+ .attr('y2', rootTopY)
624
+ .attr('stroke', palette.textMuted)
625
+ .attr('stroke-width', 1.5)
626
+ .attr('stroke-opacity', 0.4);
627
+
628
+ // Dots and labels on top of the line
629
+ for (let i = 0; i < count; i++) {
630
+ const ancestor = ancestorPath![i];
631
+ const dotY = dotPositions[i];
632
+
633
+ // Resolve color from tag groups (same logic as node cards)
634
+ const resolvedColor = ancestor.color
635
+ ?? resolveTagColor(
636
+ ancestor.metadata,
637
+ parsed.tagGroups,
638
+ activeTagGroup ?? null,
639
+ ancestor.isContainer
640
+ );
641
+ const dotColor = resolvedColor ?? palette.textMuted;
642
+
643
+ const rowG = trailG
644
+ .append('g')
645
+ .attr('class', 'org-ancestor-node')
646
+ .attr('data-focus-ancestor', ancestor.id)
647
+ .style('cursor', 'pointer')
648
+ .attr('transform', `translate(${rootCenterX}, ${dotY})`);
649
+
650
+ // Hit area
651
+ rowG
652
+ .append('rect')
653
+ .attr('x', -ANCESTOR_DOT_R - 2)
654
+ .attr('y', -ANCESTOR_DOT_R - 2)
655
+ .attr('width', 120)
656
+ .attr('height', ANCESTOR_DOT_R * 2 + 4)
657
+ .attr('fill', 'transparent');
658
+
659
+ // Dot — colored by tag group value
660
+ rowG
661
+ .append('circle')
662
+ .attr('cx', 0)
663
+ .attr('cy', 0)
664
+ .attr('r', ANCESTOR_DOT_R)
665
+ .attr('fill', dotColor);
666
+
667
+ // Label
668
+ rowG
669
+ .append('text')
670
+ .attr('x', ANCESTOR_DOT_R + 6)
671
+ .attr('y', ANCESTOR_LABEL_FONT_SIZE * 0.35)
672
+ .attr('fill', palette.textMuted)
673
+ .attr('font-size', ANCESTOR_LABEL_FONT_SIZE)
674
+ .text(ancestor.label);
675
+
676
+ // Hover effect
677
+ rowG
678
+ .on('mouseenter', function () {
679
+ d3Selection.select(this).select('circle')
680
+ .attr('r', ANCESTOR_DOT_R + 1);
681
+ d3Selection.select(this).select('text').attr('fill', palette.text);
682
+ })
683
+ .on('mouseleave', function () {
684
+ d3Selection.select(this).select('circle')
685
+ .attr('r', ANCESTOR_DOT_R);
686
+ d3Selection.select(this).select('text').attr('fill', palette.textMuted);
687
+ });
688
+ }
689
+ }
482
690
  }
483
691
 
484
692
  // Render legend — capsule pills.
@@ -0,0 +1,172 @@
1
+ // ============================================================
2
+ // Pyramid Diagram — Parser
3
+ // ============================================================
4
+
5
+ import { makeDgmoError, formatDgmoError } from '../diagnostics';
6
+ import {
7
+ measureIndent,
8
+ parseFirstLine,
9
+ parsePipeMetadata,
10
+ } from '../utils/parsing';
11
+ import type { ParsedPyramid, PyramidLayer } from './types';
12
+
13
+ /** Heuristic: pipe content is key:value form if it starts with `word:`. */
14
+ const KEY_VALUE_PREFIX_RE = /^\s*[A-Za-z][A-Za-z0-9_-]*\s*:/;
15
+
16
+ /**
17
+ * Parse a `.dgmo` pyramid diagram document.
18
+ *
19
+ * Top of file = apex of pyramid (reads top-down).
20
+ *
21
+ * Syntax:
22
+ * ```
23
+ * pyramid Maslow's Hierarchy of Needs
24
+ *
25
+ * inverted // optional — flips apex to bottom
26
+ *
27
+ * Self-Actualization // indented body = description
28
+ * Achieving one's full potential.
29
+ *
30
+ * Esteem | Respect, recognition // bare pipe shorthand = description
31
+ *
32
+ * Love & Belonging | color: blue // structured metadata
33
+ * Friendship, intimacy, family.
34
+ *
35
+ * Physiological | Food, water, rest
36
+ * ```
37
+ */
38
+ export function parsePyramid(content: string): ParsedPyramid {
39
+ const result: ParsedPyramid = {
40
+ type: 'pyramid',
41
+ title: '',
42
+ titleLineNumber: 0,
43
+ layers: [],
44
+ inverted: false,
45
+ options: {},
46
+ diagnostics: [],
47
+ error: null,
48
+ };
49
+
50
+ const lines = content.split('\n');
51
+ let headerParsed = false;
52
+ let currentLayer: PyramidLayer | null = null;
53
+
54
+ const fail = (line: number, message: string): ParsedPyramid => {
55
+ const diag = makeDgmoError(line, message);
56
+ result.diagnostics.push(diag);
57
+ result.error = formatDgmoError(diag);
58
+ return result;
59
+ };
60
+
61
+ const warn = (
62
+ line: number,
63
+ message: string,
64
+ severity: 'warning' | 'error' = 'warning'
65
+ ): void => {
66
+ result.diagnostics.push(makeDgmoError(line, message, severity));
67
+ };
68
+
69
+ const flushLayer = (): void => {
70
+ if (currentLayer) {
71
+ result.layers.push(currentLayer);
72
+ currentLayer = null;
73
+ }
74
+ };
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const lineNum = i + 1;
78
+ const raw = lines[i];
79
+ const trimmed = raw.trim();
80
+
81
+ if (!trimmed || trimmed.startsWith('//')) continue;
82
+
83
+ const indent = measureIndent(raw);
84
+
85
+ // ── First line: chart type declaration ──
86
+ if (!headerParsed) {
87
+ const firstLineResult = parseFirstLine(trimmed);
88
+ if (firstLineResult && firstLineResult.chartType === 'pyramid') {
89
+ result.title = firstLineResult.title ?? '';
90
+ result.titleLineNumber = lineNum;
91
+ headerParsed = true;
92
+ continue;
93
+ }
94
+ return fail(lineNum, 'Expected "pyramid [Title]" as the first line.');
95
+ }
96
+
97
+ // ── Bare directive: inverted ──
98
+ if (indent === 0 && trimmed.toLowerCase() === 'inverted') {
99
+ result.inverted = true;
100
+ continue;
101
+ }
102
+
103
+ // ── Top-level: layer declaration ──
104
+ if (indent === 0) {
105
+ flushLayer();
106
+
107
+ const pipeIdx = trimmed.indexOf('|');
108
+ let label: string;
109
+ const description: string[] = [];
110
+ let color: string | undefined;
111
+ let restMeta: Record<string, string> = {};
112
+
113
+ if (pipeIdx < 0) {
114
+ label = trimmed;
115
+ } else {
116
+ label = trimmed.substring(0, pipeIdx).trim();
117
+ const after = trimmed.substring(pipeIdx + 1).trim();
118
+
119
+ if (!after) {
120
+ // Trailing pipe with nothing after — ignore.
121
+ } else if (KEY_VALUE_PREFIX_RE.test(after)) {
122
+ // Structured metadata: color: foo, other: bar
123
+ const metadata = parsePipeMetadata([label, after]);
124
+ color = metadata['color'];
125
+ const descFromPipe = metadata['description'];
126
+ if (descFromPipe) description.push(descFromPipe);
127
+ restMeta = { ...metadata };
128
+ delete restMeta['color'];
129
+ delete restMeta['description'];
130
+ } else {
131
+ // Bare shorthand: pipe content is the description.
132
+ description.push(after);
133
+ }
134
+ }
135
+
136
+ if (!label) {
137
+ warn(lineNum, 'Empty layer label.');
138
+ continue;
139
+ }
140
+
141
+ currentLayer = {
142
+ label,
143
+ lineNumber: lineNum,
144
+ color,
145
+ description,
146
+ metadata: restMeta,
147
+ };
148
+ continue;
149
+ }
150
+
151
+ // ── Indented: description line under current layer ──
152
+ if (!currentLayer) {
153
+ warn(lineNum, `Unexpected indented line: "${trimmed}".`);
154
+ continue;
155
+ }
156
+ const descLine = trimmed.startsWith('- ')
157
+ ? `• ${trimmed.substring(2)}`
158
+ : trimmed;
159
+ currentLayer.description.push(descLine);
160
+ }
161
+
162
+ flushLayer();
163
+
164
+ if (result.layers.length < 2) {
165
+ return fail(
166
+ result.titleLineNumber || 1,
167
+ 'pyramid requires at least 2 layers.'
168
+ );
169
+ }
170
+
171
+ return result;
172
+ }