@diagrammo/dgmo 0.4.2 → 0.4.4

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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -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 (inline to avoid cross-module import issues)
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
- const diagramH = layout.height + titleOffset;
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 = (height - DIAGRAM_PADDING * 2) / diagramH;
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 = DIAGRAM_PADDING;
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
- // Skip in export mode (unless legend-only chart).
466
- // Legend-only (no nodes): all groups rendered as expanded capsules.
467
- // Active group: only that group rendered as capsule (pill + entries).
468
- // No active group: all groups rendered as standalone pills.
469
- const legendOnly = layout.nodes.length === 0;
470
- if (!exportDims || legendOnly) for (const group of layout.legend) {
471
- const isActive =
472
- legendOnly ||
473
- (activeTagGroup != null &&
474
- group.name.toLowerCase() === activeTagGroup.toLowerCase());
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
- // Outer capsule background (active only)
496
- if (isActive) {
497
- gEl
498
- .append('rect')
499
- .attr('width', group.width)
500
- .attr('height', LEGEND_HEIGHT)
501
- .attr('rx', LEGEND_HEIGHT / 2)
502
- .attr('fill', groupBg);
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
- const pillX = isActive ? LEGEND_CAPSULE_PAD : 0;
506
- const pillY = isActive ? LEGEND_CAPSULE_PAD : 0;
507
- const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
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
- // Pill background
510
- gEl
511
- .append('rect')
512
- .attr('x', pillX)
513
- .attr('y', pillY)
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', pillX)
524
- .attr('y', pillY)
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', 'none')
529
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
530
- .attr('stroke-width', 0.75);
531
- }
532
-
533
- // Pill text
534
- gEl
535
- .append('text')
536
- .attr('x', pillX + pillWidth / 2)
537
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
538
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
539
- .attr('font-weight', '500')
540
- .attr('fill', isActive ? palette.text : palette.textMuted)
541
- .attr('text-anchor', 'middle')
542
- .text(pillLabel);
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
- entryX = textX + entryLabel.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
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
  }
@@ -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 { content: result, diagnostics };
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 = parsed.contentLines.filter(
312
- (l) => l.trim() !== '' // skip trailing blank lines
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 importedLine of trimmedImported) {
323
- if (importedLine.trim() === '') {
324
- resolvedBodyLines.push('');
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
- resolvedBodyLines.push(indent + importedLine);
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 line of headerLines) {
340
- outputLines.push(line);
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 line of resolvedBodyLines) {
363
- outputLines.push(line);
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
  }