@diagrammo/dgmo 0.4.1 → 0.4.3
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/.claude/skills/dgmo-chart/SKILL.md +28 -0
- package/.claude/skills/dgmo-generate/SKILL.md +1 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
- package/.cursorrules +27 -2
- package/.github/copilot-instructions.md +36 -3
- package/.windsurfrules +27 -2
- package/README.md +12 -3
- package/dist/cli.cjs +611 -153
- package/dist/index.cjs +8371 -3200
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +502 -58
- package/dist/index.d.ts +502 -58
- package/dist/index.js +8594 -3444
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +1 -1
- package/docs/language-reference.md +336 -17
- package/docs/migration-sequence-color-to-tags.md +98 -0
- package/package.json +1 -1
- package/src/c4/renderer.ts +1 -20
- package/src/class/renderer.ts +1 -11
- package/src/cli.ts +40 -0
- package/src/d3.ts +92 -2
- package/src/dgmo-router.ts +11 -0
- package/src/echarts.ts +74 -8
- package/src/er/parser.ts +29 -3
- package/src/er/renderer.ts +1 -15
- package/src/graph/flowchart-parser.ts +7 -30
- package/src/graph/flowchart-renderer.ts +62 -69
- package/src/graph/layout.ts +5 -0
- package/src/graph/state-parser.ts +388 -0
- package/src/graph/state-renderer.ts +496 -0
- package/src/graph/types.ts +4 -2
- package/src/index.ts +42 -1
- package/src/infra/compute.ts +1113 -0
- package/src/infra/layout.ts +575 -0
- package/src/infra/parser.ts +559 -0
- package/src/infra/renderer.ts +1509 -0
- package/src/infra/roles.ts +60 -0
- package/src/infra/serialize.ts +67 -0
- package/src/infra/types.ts +221 -0
- package/src/infra/validation.ts +192 -0
- package/src/initiative-status/layout.ts +56 -61
- package/src/initiative-status/renderer.ts +13 -13
- package/src/kanban/renderer.ts +1 -24
- package/src/org/layout.ts +28 -37
- package/src/org/parser.ts +16 -1
- package/src/org/renderer.ts +159 -121
- package/src/org/resolver.ts +90 -23
- package/src/palettes/color-utils.ts +30 -0
- package/src/render.ts +2 -0
- package/src/sequence/parser.ts +202 -42
- package/src/sequence/renderer.ts +576 -113
- package/src/sequence/tag-resolution.ts +163 -0
- package/src/sitemap/collapse.ts +187 -0
- package/src/sitemap/layout.ts +738 -0
- package/src/sitemap/parser.ts +489 -0
- package/src/sitemap/renderer.ts +774 -0
- package/src/sitemap/types.ts +42 -0
- package/src/utils/tag-groups.ts +119 -0
package/src/org/renderer.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as d3Selection from 'd3-selection';
|
|
6
6
|
import { FONT_FAMILY } from '../fonts';
|
|
7
7
|
import type { PaletteColors } from '../palettes';
|
|
8
|
+
import { mix } from '../palettes/color-utils';
|
|
8
9
|
import type { ParsedOrg } from './parser';
|
|
9
10
|
import type { OrgLayoutResult, OrgLayoutNode } from './layout';
|
|
10
11
|
import { parseOrg } from './parser';
|
|
@@ -47,31 +48,13 @@ const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
|
47
48
|
const LEGEND_ENTRY_FONT_W = LEGEND_ENTRY_FONT_SIZE * 0.6;
|
|
48
49
|
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
49
50
|
const LEGEND_ENTRY_TRAIL = 8;
|
|
51
|
+
const LEGEND_GROUP_GAP = 12;
|
|
52
|
+
const LEGEND_FIXED_GAP = 8; // gap between fixed legend and scaled diagram
|
|
50
53
|
|
|
51
54
|
// ============================================================
|
|
52
|
-
// Color helpers
|
|
55
|
+
// Color helpers
|
|
53
56
|
// ============================================================
|
|
54
57
|
|
|
55
|
-
function mix(a: string, b: string, pct: number): string {
|
|
56
|
-
const parse = (h: string) => {
|
|
57
|
-
const r = h.replace('#', '');
|
|
58
|
-
const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
|
|
59
|
-
return [
|
|
60
|
-
parseInt(f.substring(0, 2), 16),
|
|
61
|
-
parseInt(f.substring(2, 4), 16),
|
|
62
|
-
parseInt(f.substring(4, 6), 16),
|
|
63
|
-
];
|
|
64
|
-
};
|
|
65
|
-
const [ar, ag, ab] = parse(a),
|
|
66
|
-
[br, bg, bb] = parse(b),
|
|
67
|
-
t = pct / 100;
|
|
68
|
-
const c = (x: number, y: number) =>
|
|
69
|
-
Math.round(x * t + y * (1 - t))
|
|
70
|
-
.toString(16)
|
|
71
|
-
.padStart(2, '0');
|
|
72
|
-
return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
58
|
function nodeFill(
|
|
76
59
|
palette: PaletteColors,
|
|
77
60
|
isDark: boolean,
|
|
@@ -125,19 +108,39 @@ export function renderOrg(
|
|
|
125
108
|
if (width <= 0 || height <= 0) return;
|
|
126
109
|
|
|
127
110
|
const titleOffset = parsed.title ? TITLE_HEIGHT : 0;
|
|
111
|
+
const legendOnly = layout.nodes.length === 0;
|
|
112
|
+
const legendPosition = parsed.options?.['legend-position'] ?? 'top';
|
|
113
|
+
const hasLegend = layout.legend.length > 0;
|
|
114
|
+
|
|
115
|
+
// In app mode (not export), render the legend at a fixed size outside the
|
|
116
|
+
// scaled diagram group so it stays legible on large org charts.
|
|
117
|
+
// The layout already shifted chart content down by legendShift for top legends
|
|
118
|
+
// (LEGEND_HEIGHT + LEGEND_GROUP_GAP = 40px). We subtract that from the
|
|
119
|
+
// diagram height so the scale is computed on chart content only, then
|
|
120
|
+
// reserve fixed pixel space for the legend above or below.
|
|
121
|
+
const layoutLegendShift = LEGEND_HEIGHT + LEGEND_GROUP_GAP; // 40px — what layout added
|
|
122
|
+
const fixedLegend = !exportDims && hasLegend && !legendOnly;
|
|
123
|
+
const legendReserve = fixedLegend ? LEGEND_HEIGHT + LEGEND_FIXED_GAP : 0;
|
|
128
124
|
|
|
129
125
|
// Compute scale to fit diagram in viewport
|
|
130
126
|
const diagramW = layout.width;
|
|
131
|
-
|
|
127
|
+
let diagramH = layout.height + titleOffset;
|
|
128
|
+
if (fixedLegend) {
|
|
129
|
+
// Remove the legend space from diagram height — legend is rendered separately
|
|
130
|
+
diagramH -= layoutLegendShift;
|
|
131
|
+
}
|
|
132
|
+
const availH = height - DIAGRAM_PADDING * 2 - legendReserve;
|
|
132
133
|
const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
|
|
133
|
-
const scaleY =
|
|
134
|
+
const scaleY = availH / diagramH;
|
|
134
135
|
const scale = Math.min(MAX_SCALE, scaleX, scaleY);
|
|
135
136
|
|
|
136
137
|
// Center the diagram
|
|
137
138
|
const scaledW = diagramW * scale;
|
|
138
|
-
const scaledH = diagramH * scale;
|
|
139
139
|
const offsetX = (width - scaledW) / 2;
|
|
140
|
-
const offsetY =
|
|
140
|
+
const offsetY =
|
|
141
|
+
legendPosition === 'top' && fixedLegend
|
|
142
|
+
? DIAGRAM_PADDING + legendReserve
|
|
143
|
+
: DIAGRAM_PADDING;
|
|
141
144
|
|
|
142
145
|
// Create SVG
|
|
143
146
|
const svg = d3Selection
|
|
@@ -462,112 +465,147 @@ export function renderOrg(
|
|
|
462
465
|
}
|
|
463
466
|
|
|
464
467
|
// Render legend — kanban-style pills.
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
(
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
// When a group is active, skip all other groups entirely (not in legend-only mode)
|
|
477
|
-
if (!legendOnly && activeTagGroup != null && !isActive) continue;
|
|
478
|
-
|
|
479
|
-
const groupBg = isDark
|
|
480
|
-
? mix(palette.surface, palette.bg, 50)
|
|
481
|
-
: mix(palette.surface, palette.bg, 30);
|
|
482
|
-
|
|
483
|
-
// Pill label: just the group name (alias is for DSL shorthand only)
|
|
484
|
-
const pillLabel = group.name;
|
|
485
|
-
const pillWidth =
|
|
486
|
-
pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
487
|
-
|
|
488
|
-
const gEl = contentG
|
|
489
|
-
.append('g')
|
|
490
|
-
.attr('transform', `translate(${group.x}, ${group.y})`)
|
|
491
|
-
.attr('class', 'org-legend-group')
|
|
492
|
-
.attr('data-legend-group', group.name.toLowerCase())
|
|
493
|
-
.style('cursor', legendOnly ? 'default' : 'pointer');
|
|
468
|
+
// In app mode (fixedLegend): render at native size outside the scaled group.
|
|
469
|
+
// In export mode: skip legend (unless legend-only chart).
|
|
470
|
+
// Legend-only (no nodes): all groups rendered as expanded capsules inside scaled group.
|
|
471
|
+
if (fixedLegend || legendOnly || (exportDims && hasLegend)) {
|
|
472
|
+
// Determine which groups to render
|
|
473
|
+
const visibleGroups = layout.legend.filter((group) => {
|
|
474
|
+
if (legendOnly) return true;
|
|
475
|
+
if (activeTagGroup == null) return true;
|
|
476
|
+
return group.name.toLowerCase() === activeTagGroup.toLowerCase();
|
|
477
|
+
});
|
|
494
478
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
.
|
|
501
|
-
|
|
502
|
-
.
|
|
479
|
+
// For fixedLegend: compute positions in pixel space, centered in SVG
|
|
480
|
+
let fixedPositions: Map<string, number> | undefined;
|
|
481
|
+
if (fixedLegend && visibleGroups.length > 0) {
|
|
482
|
+
fixedPositions = new Map();
|
|
483
|
+
const effectiveW = (g: typeof visibleGroups[0]) =>
|
|
484
|
+
activeTagGroup != null ? g.width : g.minifiedWidth;
|
|
485
|
+
const totalW =
|
|
486
|
+
visibleGroups.reduce((s, g) => s + effectiveW(g), 0) +
|
|
487
|
+
(visibleGroups.length - 1) * LEGEND_GROUP_GAP;
|
|
488
|
+
let cx = (width - totalW) / 2;
|
|
489
|
+
for (const g of visibleGroups) {
|
|
490
|
+
fixedPositions.set(g.name, cx);
|
|
491
|
+
cx += effectiveW(g) + LEGEND_GROUP_GAP;
|
|
492
|
+
}
|
|
503
493
|
}
|
|
504
494
|
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
495
|
+
// Choose parent: unscaled group for fixedLegend, contentG for legend-only
|
|
496
|
+
const legendParent = fixedLegend
|
|
497
|
+
? svg
|
|
498
|
+
.append('g')
|
|
499
|
+
.attr('class', 'org-legend-fixed')
|
|
500
|
+
.attr(
|
|
501
|
+
'transform',
|
|
502
|
+
legendPosition === 'bottom'
|
|
503
|
+
? `translate(0, ${height - DIAGRAM_PADDING - LEGEND_HEIGHT})`
|
|
504
|
+
: `translate(0, ${DIAGRAM_PADDING})`
|
|
505
|
+
)
|
|
506
|
+
: contentG;
|
|
507
|
+
|
|
508
|
+
for (const group of visibleGroups) {
|
|
509
|
+
const isActive =
|
|
510
|
+
legendOnly ||
|
|
511
|
+
(activeTagGroup != null &&
|
|
512
|
+
group.name.toLowerCase() === activeTagGroup.toLowerCase());
|
|
513
|
+
|
|
514
|
+
const groupBg = isDark
|
|
515
|
+
? mix(palette.surface, palette.bg, 50)
|
|
516
|
+
: mix(palette.surface, palette.bg, 30);
|
|
517
|
+
|
|
518
|
+
const pillLabel = group.name;
|
|
519
|
+
const pillWidth =
|
|
520
|
+
pillLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
|
|
521
|
+
|
|
522
|
+
const gX = fixedPositions?.get(group.name) ?? group.x;
|
|
523
|
+
const gY = fixedPositions ? 0 : group.y;
|
|
524
|
+
|
|
525
|
+
const gEl = legendParent
|
|
526
|
+
.append('g')
|
|
527
|
+
.attr('transform', `translate(${gX}, ${gY})`)
|
|
528
|
+
.attr('class', 'org-legend-group')
|
|
529
|
+
.attr('data-legend-group', group.name.toLowerCase())
|
|
530
|
+
.style('cursor', legendOnly ? 'default' : 'pointer');
|
|
531
|
+
|
|
532
|
+
// Outer capsule background (active only)
|
|
533
|
+
if (isActive) {
|
|
534
|
+
gEl
|
|
535
|
+
.append('rect')
|
|
536
|
+
.attr('width', group.width)
|
|
537
|
+
.attr('height', LEGEND_HEIGHT)
|
|
538
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
539
|
+
.attr('fill', groupBg);
|
|
540
|
+
}
|
|
508
541
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
.attr('width', pillWidth)
|
|
515
|
-
.attr('height', pillH)
|
|
516
|
-
.attr('rx', pillH / 2)
|
|
517
|
-
.attr('fill', isActive ? palette.bg : groupBg);
|
|
518
|
-
|
|
519
|
-
// Active pill border
|
|
520
|
-
if (isActive) {
|
|
542
|
+
const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
543
|
+
const pillYOff = isActive ? LEGEND_CAPSULE_PAD : 0;
|
|
544
|
+
const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
545
|
+
|
|
546
|
+
// Pill background
|
|
521
547
|
gEl
|
|
522
548
|
.append('rect')
|
|
523
|
-
.attr('x',
|
|
524
|
-
.attr('y',
|
|
549
|
+
.attr('x', pillXOff)
|
|
550
|
+
.attr('y', pillYOff)
|
|
525
551
|
.attr('width', pillWidth)
|
|
526
552
|
.attr('height', pillH)
|
|
527
553
|
.attr('rx', pillH / 2)
|
|
528
|
-
.attr('fill',
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
// Entries inside capsule (active only)
|
|
545
|
-
if (isActive) {
|
|
546
|
-
let entryX = pillX + pillWidth + 4;
|
|
547
|
-
for (const entry of group.entries) {
|
|
548
|
-
const entryG = gEl
|
|
549
|
-
.append('g')
|
|
550
|
-
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
551
|
-
.style('cursor', 'pointer');
|
|
552
|
-
|
|
553
|
-
entryG
|
|
554
|
-
.append('circle')
|
|
555
|
-
.attr('cx', entryX + LEGEND_DOT_R)
|
|
556
|
-
.attr('cy', LEGEND_HEIGHT / 2)
|
|
557
|
-
.attr('r', LEGEND_DOT_R)
|
|
558
|
-
.attr('fill', entry.color);
|
|
559
|
-
|
|
560
|
-
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
561
|
-
const entryLabel = entry.value;
|
|
562
|
-
entryG
|
|
563
|
-
.append('text')
|
|
564
|
-
.attr('x', textX)
|
|
565
|
-
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
566
|
-
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
567
|
-
.attr('fill', palette.textMuted)
|
|
568
|
-
.text(entryLabel);
|
|
554
|
+
.attr('fill', isActive ? palette.bg : groupBg);
|
|
555
|
+
|
|
556
|
+
// Active pill border
|
|
557
|
+
if (isActive) {
|
|
558
|
+
gEl
|
|
559
|
+
.append('rect')
|
|
560
|
+
.attr('x', pillXOff)
|
|
561
|
+
.attr('y', pillYOff)
|
|
562
|
+
.attr('width', pillWidth)
|
|
563
|
+
.attr('height', pillH)
|
|
564
|
+
.attr('rx', pillH / 2)
|
|
565
|
+
.attr('fill', 'none')
|
|
566
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
567
|
+
.attr('stroke-width', 0.75);
|
|
568
|
+
}
|
|
569
569
|
|
|
570
|
-
|
|
570
|
+
// Pill text
|
|
571
|
+
gEl
|
|
572
|
+
.append('text')
|
|
573
|
+
.attr('x', pillXOff + pillWidth / 2)
|
|
574
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
|
|
575
|
+
.attr('font-size', LEGEND_PILL_FONT_SIZE)
|
|
576
|
+
.attr('font-weight', '500')
|
|
577
|
+
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
578
|
+
.attr('text-anchor', 'middle')
|
|
579
|
+
.text(pillLabel);
|
|
580
|
+
|
|
581
|
+
// Entries inside capsule (active only)
|
|
582
|
+
if (isActive) {
|
|
583
|
+
let entryX = pillXOff + pillWidth + 4;
|
|
584
|
+
for (const entry of group.entries) {
|
|
585
|
+
const entryG = gEl
|
|
586
|
+
.append('g')
|
|
587
|
+
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
588
|
+
.style('cursor', 'pointer');
|
|
589
|
+
|
|
590
|
+
entryG
|
|
591
|
+
.append('circle')
|
|
592
|
+
.attr('cx', entryX + LEGEND_DOT_R)
|
|
593
|
+
.attr('cy', LEGEND_HEIGHT / 2)
|
|
594
|
+
.attr('r', LEGEND_DOT_R)
|
|
595
|
+
.attr('fill', entry.color);
|
|
596
|
+
|
|
597
|
+
const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
|
|
598
|
+
const entryLabel = entry.value;
|
|
599
|
+
entryG
|
|
600
|
+
.append('text')
|
|
601
|
+
.attr('x', textX)
|
|
602
|
+
.attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
603
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
604
|
+
.attr('fill', palette.textMuted)
|
|
605
|
+
.text(entryLabel);
|
|
606
|
+
|
|
607
|
+
entryX = textX + entryLabel.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
|
|
608
|
+
}
|
|
571
609
|
}
|
|
572
610
|
}
|
|
573
611
|
}
|
package/src/org/resolver.ts
CHANGED
|
@@ -12,9 +12,21 @@ import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
|
|
|
12
12
|
*/
|
|
13
13
|
export type ReadFileFn = (path: string) => string | Promise<string>;
|
|
14
14
|
|
|
15
|
+
/** Tracks the original source file and line for an imported line. */
|
|
16
|
+
export interface ImportSource {
|
|
17
|
+
/** Absolute path of the file this line originates from */
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** 1-based line number in the original (pre-resolution) source file */
|
|
20
|
+
sourceLine: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export interface ResolveImportsResult {
|
|
16
24
|
content: string;
|
|
17
25
|
diagnostics: DgmoError[];
|
|
26
|
+
/** resolvedLine (1-based index) → originalLine (1-based) or null for inserted lines */
|
|
27
|
+
lineMap: (number | null)[];
|
|
28
|
+
/** resolvedLine (1-based index) → import source info or null for non-imported lines */
|
|
29
|
+
importSourceMap: (ImportSource | null)[];
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
// ============================================================
|
|
@@ -96,6 +108,8 @@ function extractTagGroups(lines: string[]): TagGroupBlock[] {
|
|
|
96
108
|
interface ParsedHeader {
|
|
97
109
|
/** Lines that are NOT header/tags/tag-groups — the "content" body */
|
|
98
110
|
contentLines: string[];
|
|
111
|
+
/** For each contentLine, its 0-based index in the input lines[] array */
|
|
112
|
+
contentLineIndices: number[];
|
|
99
113
|
tagGroups: TagGroupBlock[];
|
|
100
114
|
tagsDirective: string | null;
|
|
101
115
|
}
|
|
@@ -121,6 +135,7 @@ function parseFileHeader(lines: string[]): ParsedHeader {
|
|
|
121
135
|
|
|
122
136
|
let tagsDirective: string | null = null;
|
|
123
137
|
const contentLines: string[] = [];
|
|
138
|
+
const contentLineIndices: number[] = [];
|
|
124
139
|
let headerDone = false;
|
|
125
140
|
|
|
126
141
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -155,9 +170,10 @@ function parseFileHeader(lines: string[]): ParsedHeader {
|
|
|
155
170
|
}
|
|
156
171
|
|
|
157
172
|
contentLines.push(lines[i]);
|
|
173
|
+
contentLineIndices.push(i);
|
|
158
174
|
}
|
|
159
175
|
|
|
160
|
-
return { contentLines, tagGroups, tagsDirective };
|
|
176
|
+
return { contentLines, contentLineIndices, tagGroups, tagsDirective };
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
// ============================================================
|
|
@@ -179,7 +195,12 @@ export async function resolveOrgImports(
|
|
|
179
195
|
): Promise<ResolveImportsResult> {
|
|
180
196
|
const diagnostics: DgmoError[] = [];
|
|
181
197
|
const result = await resolveFile(content, filePath, readFileFn, diagnostics, new Set([filePath]), 0);
|
|
182
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
content: result.content,
|
|
200
|
+
diagnostics,
|
|
201
|
+
lineMap: result.lineMap,
|
|
202
|
+
importSourceMap: result.importSourceMap,
|
|
203
|
+
};
|
|
183
204
|
}
|
|
184
205
|
|
|
185
206
|
async function resolveFile(
|
|
@@ -189,11 +210,11 @@ async function resolveFile(
|
|
|
189
210
|
diagnostics: DgmoError[],
|
|
190
211
|
ancestorChain: Set<string>,
|
|
191
212
|
depth: number,
|
|
192
|
-
): Promise<string> {
|
|
213
|
+
): Promise<{ content: string; lineMap: (number | null)[]; importSourceMap: (ImportSource | null)[] }> {
|
|
193
214
|
const lines = content.split('\n');
|
|
194
215
|
|
|
195
216
|
// ---- Step 1: Identify header, tags directive, inline tag groups ----
|
|
196
|
-
const headerLines: string[] = [];
|
|
217
|
+
const headerLines: { text: string; originalLine: number }[] = [];
|
|
197
218
|
let tagsDirective: string | null = null;
|
|
198
219
|
const inlineTagGroups = extractTagGroups(lines);
|
|
199
220
|
const bodyStartIndex = findBodyStart(lines);
|
|
@@ -203,7 +224,7 @@ async function resolveFile(
|
|
|
203
224
|
for (let i = 0; i < bodyStartIndex; i++) {
|
|
204
225
|
const trimmed = lines[i].trim();
|
|
205
226
|
if (trimmed === '' || trimmed.startsWith('//')) {
|
|
206
|
-
headerLines.push(lines[i]);
|
|
227
|
+
headerLines.push({ text: lines[i], originalLine: i + 1 });
|
|
207
228
|
continue;
|
|
208
229
|
}
|
|
209
230
|
if (isTagBlockHeading(trimmed)) continue; // skip inline tag group headings
|
|
@@ -216,7 +237,7 @@ async function resolveFile(
|
|
|
216
237
|
continue;
|
|
217
238
|
}
|
|
218
239
|
|
|
219
|
-
headerLines.push(lines[i]);
|
|
240
|
+
headerLines.push({ text: lines[i], originalLine: i + 1 });
|
|
220
241
|
}
|
|
221
242
|
|
|
222
243
|
// ---- Step 2: Resolve tags: directive ----
|
|
@@ -236,7 +257,7 @@ async function resolveFile(
|
|
|
236
257
|
|
|
237
258
|
// ---- Step 3: Resolve import: directives in body ----
|
|
238
259
|
const bodyLines = lines.slice(bodyStartIndex);
|
|
239
|
-
const resolvedBodyLines: string[] = [];
|
|
260
|
+
const resolvedBodyLines: { text: string; originalLine: number | null; importSource: ImportSource | null }[] = [];
|
|
240
261
|
const importedTagGroups: TagGroupBlock[] = [];
|
|
241
262
|
|
|
242
263
|
for (let i = 0; i < bodyLines.length; i++) {
|
|
@@ -250,7 +271,7 @@ async function resolveFile(
|
|
|
250
271
|
if (isTagBlockHeading(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
|
|
251
272
|
continue;
|
|
252
273
|
}
|
|
253
|
-
resolvedBodyLines.push(line);
|
|
274
|
+
resolvedBodyLines.push({ text: line, originalLine: lineNumber, importSource: null });
|
|
254
275
|
continue;
|
|
255
276
|
}
|
|
256
277
|
|
|
@@ -299,7 +320,7 @@ async function resolveFile(
|
|
|
299
320
|
);
|
|
300
321
|
|
|
301
322
|
// Strip header, extract tag groups from resolved content
|
|
302
|
-
const resolvedLines = resolved.split('\n');
|
|
323
|
+
const resolvedLines = resolved.content.split('\n');
|
|
303
324
|
const parsed = parseFileHeader(resolvedLines);
|
|
304
325
|
|
|
305
326
|
// Collect tag groups from imported file (lowest priority)
|
|
@@ -307,23 +328,43 @@ async function resolveFile(
|
|
|
307
328
|
importedTagGroups.push(group);
|
|
308
329
|
}
|
|
309
330
|
|
|
310
|
-
// Re-indent and insert content lines
|
|
311
|
-
const importedContentLines =
|
|
312
|
-
|
|
313
|
-
|
|
331
|
+
// Re-indent and insert content lines, computing import source for each
|
|
332
|
+
const importedContentLines: { text: string; index: number }[] = [];
|
|
333
|
+
for (let j = 0; j < parsed.contentLines.length; j++) {
|
|
334
|
+
if (parsed.contentLines[j].trim() !== '') {
|
|
335
|
+
importedContentLines.push({ text: parsed.contentLines[j], index: parsed.contentLineIndices[j] });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
314
338
|
|
|
315
339
|
// Trim trailing empty lines but keep internal structure
|
|
316
340
|
let lastNonEmpty = importedContentLines.length - 1;
|
|
317
|
-
while (lastNonEmpty >= 0 && importedContentLines[lastNonEmpty].trim() === '') {
|
|
341
|
+
while (lastNonEmpty >= 0 && importedContentLines[lastNonEmpty].text.trim() === '') {
|
|
318
342
|
lastNonEmpty--;
|
|
319
343
|
}
|
|
320
344
|
const trimmedImported = importedContentLines.slice(0, lastNonEmpty + 1);
|
|
321
345
|
|
|
322
|
-
for (const
|
|
323
|
-
|
|
324
|
-
|
|
346
|
+
for (const entry of trimmedImported) {
|
|
347
|
+
// Compute import source: which file and line does this content originate from?
|
|
348
|
+
// entry.index is the 0-based index in resolved.content.split('\n')
|
|
349
|
+
const resolvedLineNum = entry.index + 1; // 1-based line in the resolved imported content
|
|
350
|
+
|
|
351
|
+
// Check if this line itself came from a deeper import
|
|
352
|
+
let importSource: ImportSource | null = null;
|
|
353
|
+
if (resolved.importSourceMap[resolvedLineNum]) {
|
|
354
|
+
// Nested import — use the deepest source
|
|
355
|
+
importSource = resolved.importSourceMap[resolvedLineNum];
|
|
325
356
|
} else {
|
|
326
|
-
|
|
357
|
+
// Direct content from this imported file
|
|
358
|
+
const origLine = resolved.lineMap[resolvedLineNum];
|
|
359
|
+
if (origLine != null) {
|
|
360
|
+
importSource = { filePath: importAbsPath, sourceLine: origLine };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (entry.text.trim() === '') {
|
|
365
|
+
resolvedBodyLines.push({ text: '', originalLine: lineNumber, importSource });
|
|
366
|
+
} else {
|
|
367
|
+
resolvedBodyLines.push({ text: indent + entry.text, originalLine: lineNumber, importSource });
|
|
327
368
|
}
|
|
328
369
|
}
|
|
329
370
|
}
|
|
@@ -334,10 +375,17 @@ async function resolveFile(
|
|
|
334
375
|
|
|
335
376
|
// ---- Step 5: Rebuild output ----
|
|
336
377
|
const outputLines: string[] = [];
|
|
378
|
+
// lineMap[i] maps resolved line i (1-based) to original line (1-based) or null
|
|
379
|
+
// importSourceMap[i] maps resolved line i (1-based) to import source or null
|
|
380
|
+
// Index 0 is unused padding so indices align with 1-based line numbers
|
|
381
|
+
const lineMap: (number | null)[] = [null];
|
|
382
|
+
const importSourceMap: (ImportSource | null)[] = [null];
|
|
337
383
|
|
|
338
384
|
// Header lines (chart:, title:, options — no tags: or tag groups)
|
|
339
|
-
for (const
|
|
340
|
-
outputLines.push(
|
|
385
|
+
for (const entry of headerLines) {
|
|
386
|
+
outputLines.push(entry.text);
|
|
387
|
+
lineMap.push(entry.originalLine);
|
|
388
|
+
importSourceMap.push(null);
|
|
341
389
|
}
|
|
342
390
|
|
|
343
391
|
// Merged tag groups
|
|
@@ -345,12 +393,27 @@ async function resolveFile(
|
|
|
345
393
|
// Ensure blank line before tag groups if header has content
|
|
346
394
|
if (outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
|
|
347
395
|
outputLines.push('');
|
|
396
|
+
lineMap.push(null);
|
|
397
|
+
importSourceMap.push(null);
|
|
348
398
|
}
|
|
349
399
|
for (const group of mergedGroups) {
|
|
400
|
+
// Find original line for inline tag groups, null for external
|
|
401
|
+
const inlineMatch = inlineTagGroups.find((g) => g.name === group.name);
|
|
350
402
|
for (const line of group.lines) {
|
|
351
403
|
outputLines.push(line);
|
|
404
|
+
// Inline tag groups map to their original line, external ones map to null
|
|
405
|
+
if (inlineMatch) {
|
|
406
|
+
// Find the original line index of this tag group in the source
|
|
407
|
+
const srcIdx = lines.indexOf(line);
|
|
408
|
+
lineMap.push(srcIdx >= 0 ? srcIdx + 1 : null);
|
|
409
|
+
} else {
|
|
410
|
+
lineMap.push(null);
|
|
411
|
+
}
|
|
412
|
+
importSourceMap.push(null);
|
|
352
413
|
}
|
|
353
414
|
outputLines.push(''); // blank line between groups
|
|
415
|
+
lineMap.push(null);
|
|
416
|
+
importSourceMap.push(null);
|
|
354
417
|
}
|
|
355
418
|
}
|
|
356
419
|
|
|
@@ -358,12 +421,16 @@ async function resolveFile(
|
|
|
358
421
|
// Ensure blank line separator
|
|
359
422
|
if (resolvedBodyLines.length > 0 && outputLines.length > 0 && outputLines[outputLines.length - 1].trim() !== '') {
|
|
360
423
|
outputLines.push('');
|
|
424
|
+
lineMap.push(null);
|
|
425
|
+
importSourceMap.push(null);
|
|
361
426
|
}
|
|
362
|
-
for (const
|
|
363
|
-
outputLines.push(
|
|
427
|
+
for (const entry of resolvedBodyLines) {
|
|
428
|
+
outputLines.push(entry.text);
|
|
429
|
+
lineMap.push(entry.originalLine);
|
|
430
|
+
importSourceMap.push(entry.importSource);
|
|
364
431
|
}
|
|
365
432
|
|
|
366
|
-
return outputLines.join('\n');
|
|
433
|
+
return { content: outputLines.join('\n'), lineMap, importSourceMap };
|
|
367
434
|
}
|
|
368
435
|
|
|
369
436
|
// ============================================================
|
|
@@ -145,6 +145,36 @@ export function shade(hex: string, base: string, amount: number): string {
|
|
|
145
145
|
return `#${sr.toString(16).padStart(2, '0')}${sg.toString(16).padStart(2, '0')}${sb.toString(16).padStart(2, '0')}`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// ============================================================
|
|
149
|
+
// Color Mixing
|
|
150
|
+
// ============================================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Blend two hex colors by percentage.
|
|
154
|
+
* `pct` = 0 → 100% of `b`, `pct` = 100 → 100% of `a`.
|
|
155
|
+
*
|
|
156
|
+
* Used by all renderers for tinted fills and strokes.
|
|
157
|
+
*/
|
|
158
|
+
export function mix(a: string, b: string, pct: number): string {
|
|
159
|
+
const parse = (h: string) => {
|
|
160
|
+
const r = h.replace('#', '');
|
|
161
|
+
const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
|
|
162
|
+
return [
|
|
163
|
+
parseInt(f.substring(0, 2), 16),
|
|
164
|
+
parseInt(f.substring(2, 4), 16),
|
|
165
|
+
parseInt(f.substring(4, 6), 16),
|
|
166
|
+
];
|
|
167
|
+
};
|
|
168
|
+
const [ar, ag, ab] = parse(a),
|
|
169
|
+
[br, bg, bb] = parse(b),
|
|
170
|
+
t = pct / 100;
|
|
171
|
+
const c = (x: number, y: number) =>
|
|
172
|
+
Math.round(x * t + y * (1 - t))
|
|
173
|
+
.toString(16)
|
|
174
|
+
.padStart(2, '0');
|
|
175
|
+
return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
// ============================================================
|
|
149
179
|
// Contrast / Accessibility
|
|
150
180
|
// ============================================================
|
package/src/render.ts
CHANGED
|
@@ -52,6 +52,7 @@ export async function render(
|
|
|
52
52
|
c4Level?: 'context' | 'containers' | 'components' | 'deployment';
|
|
53
53
|
c4System?: string;
|
|
54
54
|
c4Container?: string;
|
|
55
|
+
scenario?: string;
|
|
55
56
|
},
|
|
56
57
|
): Promise<string> {
|
|
57
58
|
const theme = options?.theme ?? 'light';
|
|
@@ -74,5 +75,6 @@ export async function render(
|
|
|
74
75
|
c4Level: options?.c4Level,
|
|
75
76
|
c4System: options?.c4System,
|
|
76
77
|
c4Container: options?.c4Container,
|
|
78
|
+
scenario: options?.scenario,
|
|
77
79
|
});
|
|
78
80
|
}
|