@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.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -6
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +11 -1
- package/src/boxes-and-lines/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1112 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +47 -7
- package/src/utils/tag-groups.ts +46 -60
package/src/org/renderer.ts
CHANGED
|
@@ -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
|
|
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, ${
|
|
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
|
+
}
|