@diagrammo/dgmo 0.8.19 → 0.8.21

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 (74) hide show
  1. package/dist/cli.cjs +92 -131
  2. package/dist/editor.cjs +13 -1
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.js +13 -1
  5. package/dist/editor.js.map +1 -1
  6. package/dist/highlight.cjs +13 -1
  7. package/dist/highlight.cjs.map +1 -1
  8. package/dist/highlight.js +13 -1
  9. package/dist/highlight.js.map +1 -1
  10. package/dist/index.cjs +4524 -1511
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +427 -186
  13. package/dist/index.d.ts +427 -186
  14. package/dist/index.js +4526 -1503
  15. package/dist/index.js.map +1 -1
  16. package/docs/guide/chart-mindmap.md +198 -0
  17. package/docs/guide/chart-sequence.md +23 -1
  18. package/docs/guide/chart-wireframe.md +100 -0
  19. package/docs/guide/index.md +8 -0
  20. package/docs/language-reference.md +210 -2
  21. package/package.json +22 -9
  22. package/src/boxes-and-lines/collapse.ts +21 -3
  23. package/src/boxes-and-lines/layout.ts +51 -9
  24. package/src/boxes-and-lines/parser.ts +16 -4
  25. package/src/boxes-and-lines/renderer.ts +121 -23
  26. package/src/boxes-and-lines/types.ts +1 -0
  27. package/src/c4/parser.ts +8 -7
  28. package/src/class/parser.ts +6 -0
  29. package/src/cli.ts +1 -9
  30. package/src/completion.ts +26 -0
  31. package/src/d3.ts +169 -266
  32. package/src/dgmo-router.ts +103 -5
  33. package/src/diagnostics.ts +16 -6
  34. package/src/echarts.ts +43 -10
  35. package/src/editor/keywords.ts +12 -0
  36. package/src/er/parser.ts +22 -2
  37. package/src/gantt/renderer.ts +2 -2
  38. package/src/graph/flowchart-parser.ts +89 -52
  39. package/src/graph/layout.ts +73 -9
  40. package/src/graph/state-collapse.ts +78 -0
  41. package/src/graph/state-parser.ts +60 -35
  42. package/src/graph/state-renderer.ts +139 -34
  43. package/src/index.ts +41 -16
  44. package/src/infra/parser.ts +9 -2
  45. package/src/kanban/renderer.ts +305 -59
  46. package/src/mindmap/collapse.ts +88 -0
  47. package/src/mindmap/layout.ts +605 -0
  48. package/src/mindmap/parser.ts +379 -0
  49. package/src/mindmap/renderer.ts +543 -0
  50. package/src/mindmap/text-wrap.ts +207 -0
  51. package/src/mindmap/types.ts +55 -0
  52. package/src/palettes/color-utils.ts +4 -12
  53. package/src/palettes/index.ts +0 -4
  54. package/src/render.ts +31 -20
  55. package/src/sequence/parser.ts +7 -2
  56. package/src/sequence/renderer.ts +141 -21
  57. package/src/sharing.ts +2 -0
  58. package/src/sitemap/layout.ts +35 -12
  59. package/src/sitemap/renderer.ts +1 -6
  60. package/src/utils/arrows.ts +180 -11
  61. package/src/utils/d3-types.ts +4 -0
  62. package/src/utils/export-container.ts +3 -2
  63. package/src/utils/legend-constants.ts +0 -4
  64. package/src/utils/legend-d3.ts +1 -0
  65. package/src/utils/legend-layout.ts +2 -2
  66. package/src/utils/parsing.ts +2 -0
  67. package/src/utils/time-ticks.ts +213 -0
  68. package/src/wireframe/layout.ts +460 -0
  69. package/src/wireframe/parser.ts +956 -0
  70. package/src/wireframe/renderer.ts +1293 -0
  71. package/src/wireframe/types.ts +110 -0
  72. package/src/branding.ts +0 -67
  73. package/src/dgmo-mermaid.ts +0 -262
  74. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -28,12 +28,15 @@ import type {
28
28
  // Public options object
29
29
  // ============================================================
30
30
 
31
- export interface KanbanInteractiveOptions {
31
+ interface KanbanInteractiveOptions {
32
32
  onNavigateToLine?: (line: number) => void;
33
33
  exportDims?: { width: number; height: number };
34
34
  activeTagGroup?: string | null;
35
35
  currentSwimlaneGroup?: string | null;
36
36
  onSwimlaneChange?: (group: string | null) => void;
37
+ collapsedLanes?: Set<string>;
38
+ collapsedColumns?: Set<string>;
39
+ compactMeta?: boolean;
37
40
  }
38
41
 
39
42
  // ============================================================
@@ -61,6 +64,8 @@ const CARD_META_FONT_SIZE = 10;
61
64
  const WIP_FONT_SIZE = 10;
62
65
  const COLUMN_RADIUS = 8;
63
66
  const COLUMN_HEADER_RADIUS = 8;
67
+ const COLLAPSED_COLUMN_WIDTH = 40;
68
+ const COLLAPSED_LANE_HEIGHT = 26;
64
69
 
65
70
  // ============================================================
66
71
  // Tag color resolution
@@ -68,10 +73,12 @@ const COLUMN_HEADER_RADIUS = 8;
68
73
 
69
74
  function resolveCardTagMeta(
70
75
  card: KanbanCard,
71
- tagGroups: KanbanTagGroup[]
76
+ tagGroups: KanbanTagGroup[],
77
+ hiddenMetaGroups?: string[]
72
78
  ): { label: string; value: string; color?: string }[] {
73
79
  const meta: { label: string; value: string; color?: string }[] = [];
74
80
  for (const group of tagGroups) {
81
+ if (hiddenMetaGroups?.includes(group.name.toLowerCase())) continue;
75
82
  const tagValue = card.tags[group.name.toLowerCase()];
76
83
  const value = tagValue ?? group.defaultValue;
77
84
  if (!value) continue;
@@ -125,7 +132,9 @@ interface CardLayout {
125
132
 
126
133
  function computeLayout(
127
134
  parsed: ParsedKanban,
128
- _palette: PaletteColors
135
+ _palette: PaletteColors,
136
+ collapsedColumns?: Set<string>,
137
+ hiddenMetaGroups?: string[]
129
138
  ): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
130
139
  // Title row
131
140
  const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
@@ -141,6 +150,20 @@ function computeLayout(
141
150
  const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
142
151
 
143
152
  for (const col of visibleColumns) {
153
+ const isCollapsed = collapsedColumns?.has(col.id) ?? false;
154
+
155
+ if (isCollapsed) {
156
+ columnLayouts.push({
157
+ x: 0,
158
+ y: startY,
159
+ width: COLLAPSED_COLUMN_WIDTH,
160
+ height: 0, // normalized below
161
+ column: col,
162
+ cardLayouts: [],
163
+ });
164
+ continue;
165
+ }
166
+
144
167
  // Compute card heights and column width
145
168
  let maxCardTextWidth = col.name.length * (COLUMN_HEADER_FONT_SIZE * 0.65);
146
169
 
@@ -155,7 +178,11 @@ function computeLayout(
155
178
  );
156
179
 
157
180
  // Count metadata rows (tag groups + detail lines)
158
- const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
181
+ const tagMeta = resolveCardTagMeta(
182
+ card,
183
+ parsed.tagGroups,
184
+ hiddenMetaGroups
185
+ );
159
186
  const metaCount = tagMeta.length + card.details.length;
160
187
  const metaHeight =
161
188
  metaCount > 0
@@ -236,6 +263,9 @@ export function renderKanban(
236
263
  const exportDims = options?.exportDims;
237
264
  const activeTagGroup = options?.activeTagGroup ?? null;
238
265
  const onSwimlaneChange = options?.onSwimlaneChange;
266
+ const collapsedLanes = options?.collapsedLanes;
267
+ const collapsedColumns = options?.collapsedColumns;
268
+ const compactMeta = options?.compactMeta ?? false;
239
269
  // Resolve current swimlane group: must match an existing tag group, else ignore.
240
270
  const requestedSwimlane = options?.currentSwimlaneGroup ?? null;
241
271
  const swimlaneGroup = requestedSwimlane
@@ -244,7 +274,20 @@ export function renderKanban(
244
274
  ) ?? null)
245
275
  : null;
246
276
 
247
- const layout = computeLayout(parsed, palette);
277
+ // Compute hidden meta groups for compact mode
278
+ const hiddenMetaGroups: string[] = [];
279
+ if (compactMeta) {
280
+ if (activeTagGroup) hiddenMetaGroups.push(activeTagGroup.toLowerCase());
281
+ if (requestedSwimlane)
282
+ hiddenMetaGroups.push(requestedSwimlane.toLowerCase());
283
+ }
284
+
285
+ const layout = computeLayout(
286
+ parsed,
287
+ palette,
288
+ collapsedColumns,
289
+ hiddenMetaGroups
290
+ );
248
291
 
249
292
  const width = exportDims?.width ?? layout.totalWidth;
250
293
  const height = exportDims?.height ?? layout.totalHeight;
@@ -347,7 +390,10 @@ export function renderKanban(
347
390
  swimlaneGroup,
348
391
  palette,
349
392
  isDark,
350
- activeTagGroup
393
+ activeTagGroup,
394
+ collapsedLanes,
395
+ collapsedColumns,
396
+ hiddenMetaGroups
351
397
  );
352
398
  return;
353
399
  }
@@ -364,6 +410,7 @@ export function renderKanban(
364
410
 
365
411
  for (const colLayout of layout.columns) {
366
412
  const col = colLayout.column;
413
+ const isColCollapsed = collapsedColumns?.has(col.id) ?? false;
367
414
  const g = svg
368
415
  .append('g')
369
416
  .attr('class', 'kanban-column')
@@ -377,6 +424,51 @@ export function renderKanban(
377
424
  ? mix(col.color, palette.bg, 25)
378
425
  : defaultColHeaderBg;
379
426
 
427
+ if (isColCollapsed) {
428
+ // Collapsed column: narrow strip with count + vertical name
429
+ g.append('rect')
430
+ .attr('x', colLayout.x)
431
+ .attr('y', colLayout.y)
432
+ .attr('width', COLLAPSED_COLUMN_WIDTH)
433
+ .attr('height', colLayout.height)
434
+ .attr('rx', COLUMN_RADIUS)
435
+ .attr('fill', thisColBg);
436
+
437
+ g.append('rect')
438
+ .attr('x', colLayout.x)
439
+ .attr('y', colLayout.y)
440
+ .attr('width', COLLAPSED_COLUMN_WIDTH)
441
+ .attr('height', COLUMN_HEADER_HEIGHT)
442
+ .attr('rx', COLUMN_HEADER_RADIUS)
443
+ .attr('fill', thisColHeaderBg);
444
+
445
+ // Card count
446
+ g.append('text')
447
+ .attr('x', colLayout.x + COLLAPSED_COLUMN_WIDTH / 2)
448
+ .attr(
449
+ 'y',
450
+ colLayout.y + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
451
+ )
452
+ .attr('font-size', WIP_FONT_SIZE)
453
+ .attr('font-weight', 'bold')
454
+ .attr('fill', palette.textMuted)
455
+ .attr('text-anchor', 'middle')
456
+ .text(String(col.cards.length));
457
+
458
+ // Vertical column name
459
+ g.append('text')
460
+ .attr('x', colLayout.x + COLLAPSED_COLUMN_WIDTH / 2)
461
+ .attr('y', colLayout.y + COLUMN_HEADER_HEIGHT + COLUMN_PADDING)
462
+ .attr('font-size', CARD_TITLE_FONT_SIZE)
463
+ .attr('font-weight', 'bold')
464
+ .attr('fill', palette.text)
465
+ .attr('text-anchor', 'middle')
466
+ .attr('writing-mode', 'tb')
467
+ .text(col.name);
468
+
469
+ continue;
470
+ }
471
+
380
472
  // Column background
381
473
  g.append('rect')
382
474
  .attr('x', colLayout.x)
@@ -432,7 +524,11 @@ export function renderKanban(
432
524
  parsed.tagGroups,
433
525
  activeTagGroup ?? null
434
526
  );
435
- const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
527
+ const tagMeta = resolveCardTagMeta(
528
+ card,
529
+ parsed.tagGroups,
530
+ hiddenMetaGroups
531
+ );
436
532
  const hasMeta = tagMeta.length > 0 || card.details.length > 0;
437
533
 
438
534
  // Org-chart-style fill: 15% blend of color into bg
@@ -608,7 +704,7 @@ interface SwimlaneBucket {
608
704
  cellsByColumn: Record<string, KanbanCard[]>;
609
705
  }
610
706
 
611
- export function bucketCardsBySwimlane(
707
+ function bucketCardsBySwimlane(
612
708
  columns: KanbanColumn[],
613
709
  swimlaneGroup: KanbanTagGroup
614
710
  ): SwimlaneBucket[] {
@@ -674,8 +770,12 @@ interface SwimlaneBoardLayout {
674
770
  startY: number;
675
771
  }
676
772
 
677
- function computeCardHeight(card: KanbanCard, tagGroups: KanbanTagGroup[]) {
678
- const tagMeta = resolveCardTagMeta(card, tagGroups);
773
+ function computeCardHeight(
774
+ card: KanbanCard,
775
+ tagGroups: KanbanTagGroup[],
776
+ hiddenMetaGroups?: string[]
777
+ ) {
778
+ const tagMeta = resolveCardTagMeta(card, tagGroups, hiddenMetaGroups);
679
779
  const metaCount = tagMeta.length + card.details.length;
680
780
  const metaHeight =
681
781
  metaCount > 0
@@ -690,7 +790,10 @@ function computeCardHeight(card: KanbanCard, tagGroups: KanbanTagGroup[]) {
690
790
  function computeSwimlaneLayout(
691
791
  parsed: ParsedKanban,
692
792
  buckets: SwimlaneBucket[],
693
- baseLayout: { columns: ColumnLayout[] }
793
+ baseLayout: { columns: ColumnLayout[] },
794
+ collapsedLanes?: Set<string>,
795
+ collapsedColumns?: Set<string>,
796
+ hiddenMetaGroups?: string[]
694
797
  ): SwimlaneBoardLayout {
695
798
  const headerHeight = parsed.title ? TITLE_HEIGHT + 8 : 0;
696
799
  const startY = DIAGRAM_PADDING + headerHeight;
@@ -699,8 +802,10 @@ function computeSwimlaneLayout(
699
802
  const columnXs: SwimlaneBoardLayout['columnXs'] = [];
700
803
  let currentX = DIAGRAM_PADDING + LANE_HEADER_WIDTH;
701
804
  for (const col of baseLayout.columns) {
702
- columnXs.push({ column: col.column, x: currentX, width: col.width });
703
- currentX += col.width + COLUMN_GAP;
805
+ const isColCollapsed = collapsedColumns?.has(col.column.id) ?? false;
806
+ const w = isColCollapsed ? COLLAPSED_COLUMN_WIDTH : col.width;
807
+ columnXs.push({ column: col.column, x: currentX, width: w });
808
+ currentX += w + COLUMN_GAP;
704
809
  }
705
810
  const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
706
811
 
@@ -710,15 +815,36 @@ function computeSwimlaneLayout(
710
815
  const minCellH = CARD_HEADER_HEIGHT + CARD_PADDING_Y + CARD_GAP;
711
816
 
712
817
  for (const bucket of buckets) {
818
+ const isLaneCollapsed = collapsedLanes?.has(bucket.laneName) ?? false;
819
+
820
+ if (isLaneCollapsed) {
821
+ // Collapsed lane: minimal height, no card layouts
822
+ const cells: SwimlaneCellLayout[] = columnXs.map((colInfo) => ({
823
+ column: colInfo.column,
824
+ cards: bucket.cellsByColumn[colInfo.column.id] ?? [],
825
+ cardLayouts: [],
826
+ }));
827
+ lanes.push({ bucket, y: laneY, height: COLLAPSED_LANE_HEIGHT, cells });
828
+ laneY += COLLAPSED_LANE_HEIGHT + LANE_GAP;
829
+ continue;
830
+ }
831
+
713
832
  let maxCellH = minCellH;
714
833
  const cellsTmp: { column: KanbanColumn; cards: KanbanCard[]; h: number }[] =
715
834
  [];
716
835
 
717
836
  for (const colInfo of columnXs) {
837
+ const isColCollapsed = collapsedColumns?.has(colInfo.column.id) ?? false;
718
838
  const cards = bucket.cellsByColumn[colInfo.column.id] ?? [];
839
+ if (isColCollapsed) {
840
+ // Collapsed column cells get minimal height
841
+ cellsTmp.push({ column: colInfo.column, cards, h: 0 });
842
+ continue;
843
+ }
719
844
  let h = 0;
720
845
  for (const c of cards) {
721
- h += computeCardHeight(c, parsed.tagGroups) + CARD_GAP;
846
+ h +=
847
+ computeCardHeight(c, parsed.tagGroups, hiddenMetaGroups) + CARD_GAP;
722
848
  }
723
849
  h = Math.max(h - (cards.length > 0 ? CARD_GAP : 0), 0);
724
850
  cellsTmp.push({ column: colInfo.column, cards, h });
@@ -730,10 +856,14 @@ function computeSwimlaneLayout(
730
856
  // Build cell layouts with card x/y offsets relative to (cell.x, laneY)
731
857
  const cells: SwimlaneCellLayout[] = cellsTmp.map((tmp, i) => {
732
858
  const colInfo = columnXs[i]!;
859
+ const isColCollapsed = collapsedColumns?.has(colInfo.column.id) ?? false;
860
+ if (isColCollapsed) {
861
+ return { column: tmp.column, cards: tmp.cards, cardLayouts: [] };
862
+ }
733
863
  const cardLayouts: CardLayout[] = [];
734
864
  let cy = 0;
735
865
  for (const card of tmp.cards) {
736
- const ch = computeCardHeight(card, parsed.tagGroups);
866
+ const ch = computeCardHeight(card, parsed.tagGroups, hiddenMetaGroups);
737
867
  cardLayouts.push({
738
868
  x: colInfo.x + COLUMN_PADDING,
739
869
  y: laneY + cy,
@@ -766,11 +896,21 @@ function renderSwimlaneBoard(
766
896
  swimlaneGroup: KanbanTagGroup,
767
897
  palette: PaletteColors,
768
898
  isDark: boolean,
769
- activeTagGroup: string | null
899
+ activeTagGroup: string | null,
900
+ collapsedLanes?: Set<string>,
901
+ collapsedColumns?: Set<string>,
902
+ hiddenMetaGroups?: string[]
770
903
  ): void {
771
904
  const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
772
905
  const buckets = bucketCardsBySwimlane(visibleColumns, swimlaneGroup);
773
- const grid = computeSwimlaneLayout(parsed, buckets, baseLayout);
906
+ const grid = computeSwimlaneLayout(
907
+ parsed,
908
+ buckets,
909
+ baseLayout,
910
+ collapsedLanes,
911
+ collapsedColumns,
912
+ hiddenMetaGroups
913
+ );
774
914
 
775
915
  // Resize the svg to fit the grid (only when not using exportDims).
776
916
  const currentW = parseFloat(svg.attr('width') || '0');
@@ -789,6 +929,7 @@ function renderSwimlaneBoard(
789
929
  // Column header row spanning all lanes
790
930
  for (const colInfo of grid.columnXs) {
791
931
  const col = colInfo.column;
932
+ const isColCollapsed = collapsedColumns?.has(col.id) ?? false;
792
933
  const headerG = svg
793
934
  .append('g')
794
935
  .attr('class', 'kanban-column kanban-column-header')
@@ -808,21 +949,41 @@ function renderSwimlaneBoard(
808
949
  .attr('rx', COLUMN_HEADER_RADIUS)
809
950
  .attr('fill', colHeaderBg);
810
951
 
811
- headerG
812
- .append('text')
813
- .attr('x', colInfo.x + COLUMN_PADDING)
814
- .attr(
815
- 'y',
816
- grid.startY + COLUMN_HEADER_HEIGHT / 2 + COLUMN_HEADER_FONT_SIZE / 2 - 2
817
- )
818
- .attr('font-size', COLUMN_HEADER_FONT_SIZE)
819
- .attr('font-weight', 'bold')
820
- .attr('fill', palette.text)
821
- .text(col.name);
952
+ if (isColCollapsed) {
953
+ // Collapsed: show count + vertical name below header
954
+ headerG
955
+ .append('text')
956
+ .attr('x', colInfo.x + colInfo.width / 2)
957
+ .attr(
958
+ 'y',
959
+ grid.startY + COLUMN_HEADER_HEIGHT / 2 + WIP_FONT_SIZE / 2 - 1
960
+ )
961
+ .attr('font-size', WIP_FONT_SIZE)
962
+ .attr('font-weight', 'bold')
963
+ .attr('fill', palette.textMuted)
964
+ .attr('text-anchor', 'middle')
965
+ .text(String(col.cards.length));
966
+ } else {
967
+ headerG
968
+ .append('text')
969
+ .attr('x', colInfo.x + COLUMN_PADDING)
970
+ .attr(
971
+ 'y',
972
+ grid.startY +
973
+ COLUMN_HEADER_HEIGHT / 2 +
974
+ COLUMN_HEADER_FONT_SIZE / 2 -
975
+ 2
976
+ )
977
+ .attr('font-size', COLUMN_HEADER_FONT_SIZE)
978
+ .attr('font-weight', 'bold')
979
+ .attr('fill', palette.text)
980
+ .text(col.name);
981
+ }
822
982
  }
823
983
 
824
984
  // Lanes
825
985
  for (const lane of grid.lanes) {
986
+ const isLaneCollapsed = collapsedLanes?.has(lane.bucket.laneName) ?? false;
826
987
  const laneG = svg
827
988
  .append('g')
828
989
  .attr('class', 'kanban-lane')
@@ -847,8 +1008,9 @@ function renderSwimlaneBoard(
847
1008
  .attr('rx', COLUMN_RADIUS)
848
1009
  .attr('fill', defaultColBg);
849
1010
 
850
- // Lane label
1011
+ // Lane label + count
851
1012
  let labelX = 10;
1013
+ const totalCards = lane.cells.reduce((s, c) => s + c.cards.length, 0);
852
1014
  if (lane.bucket.laneColor) {
853
1015
  headerG
854
1016
  .append('circle')
@@ -858,24 +1020,74 @@ function renderSwimlaneBoard(
858
1020
  .attr('fill', lane.bucket.laneColor);
859
1021
  labelX += 14;
860
1022
  }
861
- headerG
862
- .append('text')
863
- .attr('x', labelX)
864
- .attr('y', 20)
865
- .attr('font-size', 12)
866
- .attr('font-weight', 'bold')
867
- .attr('fill', lane.bucket.isFallback ? palette.textMuted : palette.text)
868
- .text(lane.bucket.laneName);
869
1023
 
870
- // Card count
871
- const totalCards = lane.cells.reduce((s, c) => s + c.cards.length, 0);
872
- headerG
873
- .append('text')
874
- .attr('x', labelX)
875
- .attr('y', 36)
876
- .attr('font-size', 10)
877
- .attr('fill', palette.textMuted)
878
- .text(`(${totalCards})`);
1024
+ if (isLaneCollapsed) {
1025
+ // Collapsed: single line with name + count
1026
+ headerG
1027
+ .append('text')
1028
+ .attr('x', labelX)
1029
+ .attr('y', 20)
1030
+ .attr('font-size', 10)
1031
+ .attr('fill', palette.textMuted)
1032
+ .text(`${lane.bucket.laneName} (${totalCards})`);
1033
+ } else {
1034
+ // Expanded: name on first line, count on second
1035
+ headerG
1036
+ .append('text')
1037
+ .attr('x', labelX)
1038
+ .attr('y', 20)
1039
+ .attr('font-size', 12)
1040
+ .attr('font-weight', 'bold')
1041
+ .attr('fill', lane.bucket.isFallback ? palette.textMuted : palette.text)
1042
+ .text(lane.bucket.laneName);
1043
+
1044
+ headerG
1045
+ .append('text')
1046
+ .attr('x', labelX)
1047
+ .attr('y', 36)
1048
+ .attr('font-size', 10)
1049
+ .attr('fill', palette.textMuted)
1050
+ .text(`(${totalCards})`);
1051
+ }
1052
+
1053
+ if (isLaneCollapsed) {
1054
+ // Render count placeholders in each cell
1055
+ for (const cell of lane.cells) {
1056
+ const isColCollapsed = collapsedColumns?.has(cell.column.id) ?? false;
1057
+ if (cell.cards.length > 0) {
1058
+ const colInfo = grid.columnXs.find(
1059
+ (c) => c.column.id === cell.column.id
1060
+ );
1061
+ if (!colInfo) continue;
1062
+ const placeholderBg = lane.bucket.laneColor
1063
+ ? mix(lane.bucket.laneColor, palette.bg, isDark ? 40 : 28)
1064
+ : mix(palette.textMuted, palette.bg, isDark ? 28 : 22);
1065
+ const pw = isColCollapsed
1066
+ ? COLLAPSED_COLUMN_WIDTH - 8
1067
+ : colInfo.width - COLUMN_PADDING * 2;
1068
+ laneG
1069
+ .append('rect')
1070
+ .attr('x', colInfo.x + (isColCollapsed ? 4 : COLUMN_PADDING))
1071
+ .attr('y', lane.y)
1072
+ .attr('width', pw)
1073
+ .attr('height', 18)
1074
+ .attr('rx', 4)
1075
+ .attr('fill', placeholderBg);
1076
+ laneG
1077
+ .append('text')
1078
+ .attr(
1079
+ 'x',
1080
+ colInfo.x + (isColCollapsed ? 4 : COLUMN_PADDING) + pw / 2
1081
+ )
1082
+ .attr('y', lane.y + 13)
1083
+ .attr('font-size', WIP_FONT_SIZE)
1084
+ .attr('font-weight', 'bold')
1085
+ .attr('fill', palette.textMuted)
1086
+ .attr('text-anchor', 'middle')
1087
+ .text(String(cell.cards.length));
1088
+ }
1089
+ }
1090
+ }
879
1091
 
880
1092
  // Lane divider line below the row
881
1093
  laneG
@@ -888,17 +1100,50 @@ function renderSwimlaneBoard(
888
1100
  .attr('stroke-opacity', 0.4)
889
1101
  .attr('stroke-width', 1);
890
1102
 
891
- // Cards inside cells
892
- for (const cell of lane.cells) {
893
- for (const cardLayout of cell.cardLayouts) {
894
- renderSwimlaneCard(
895
- laneG,
896
- cardLayout,
897
- parsed.tagGroups,
898
- activeTagGroup,
899
- palette,
900
- cardBaseBg
901
- );
1103
+ // Cards inside cells (skip for collapsed lanes — they show count placeholders instead)
1104
+ if (!isLaneCollapsed) {
1105
+ for (const cell of lane.cells) {
1106
+ const isColCollapsed = collapsedColumns?.has(cell.column.id) ?? false;
1107
+ if (isColCollapsed && cell.cards.length > 0) {
1108
+ // Collapsed column in non-collapsed lane: show count placeholder
1109
+ const colInfo = grid.columnXs.find(
1110
+ (c) => c.column.id === cell.column.id
1111
+ );
1112
+ if (colInfo) {
1113
+ const placeholderBg = lane.bucket.laneColor
1114
+ ? mix(lane.bucket.laneColor, palette.bg, isDark ? 40 : 28)
1115
+ : mix(palette.textMuted, palette.bg, isDark ? 28 : 22);
1116
+ laneG
1117
+ .append('rect')
1118
+ .attr('x', colInfo.x + 4)
1119
+ .attr('y', lane.y)
1120
+ .attr('width', COLLAPSED_COLUMN_WIDTH - 8)
1121
+ .attr('height', 22)
1122
+ .attr('rx', 4)
1123
+ .attr('fill', placeholderBg);
1124
+ laneG
1125
+ .append('text')
1126
+ .attr('x', colInfo.x + COLLAPSED_COLUMN_WIDTH / 2)
1127
+ .attr('y', lane.y + 16)
1128
+ .attr('font-size', WIP_FONT_SIZE)
1129
+ .attr('font-weight', 'bold')
1130
+ .attr('fill', palette.textMuted)
1131
+ .attr('text-anchor', 'middle')
1132
+ .text(String(cell.cards.length));
1133
+ }
1134
+ continue;
1135
+ }
1136
+ for (const cardLayout of cell.cardLayouts) {
1137
+ renderSwimlaneCard(
1138
+ laneG,
1139
+ cardLayout,
1140
+ parsed.tagGroups,
1141
+ activeTagGroup,
1142
+ palette,
1143
+ cardBaseBg,
1144
+ hiddenMetaGroups
1145
+ );
1146
+ }
902
1147
  }
903
1148
  }
904
1149
  }
@@ -910,11 +1155,12 @@ function renderSwimlaneCard(
910
1155
  tagGroups: KanbanTagGroup[],
911
1156
  activeTagGroup: string | null,
912
1157
  palette: PaletteColors,
913
- cardBaseBg: string
1158
+ cardBaseBg: string,
1159
+ hiddenMetaGroups?: string[]
914
1160
  ): void {
915
1161
  const card = cardLayout.card;
916
1162
  const resolvedColor = resolveCardTagColor(card, tagGroups, activeTagGroup);
917
- const tagMeta = resolveCardTagMeta(card, tagGroups);
1163
+ const tagMeta = resolveCardTagMeta(card, tagGroups, hiddenMetaGroups);
918
1164
  const hasMeta = tagMeta.length > 0 || card.details.length > 0;
919
1165
 
920
1166
  const cardFill = resolvedColor
@@ -0,0 +1,88 @@
1
+ // ============================================================
2
+ // Mindmap Collapse/Expand — prune subtrees of collapsed nodes
3
+ // ============================================================
4
+
5
+ import type { MindmapNode } from './types';
6
+
7
+ // ============================================================
8
+ // Types
9
+ // ============================================================
10
+
11
+ export interface CollapsedMindmapResult {
12
+ /** Roots with collapsed subtrees pruned (deep-cloned, never mutates original) */
13
+ roots: MindmapNode[];
14
+ /** nodeId → count of hidden descendants */
15
+ hiddenCounts: Map<string, number>;
16
+ }
17
+
18
+ // ============================================================
19
+ // Helpers
20
+ // ============================================================
21
+
22
+ function cloneNode(node: MindmapNode): MindmapNode {
23
+ return {
24
+ id: node.id,
25
+ label: node.label,
26
+ description: node.description,
27
+ metadata: { ...node.metadata },
28
+ children: node.children.map(cloneNode),
29
+ parentId: node.parentId,
30
+ lineNumber: node.lineNumber,
31
+ color: node.color,
32
+ collapsed: node.collapsed,
33
+ };
34
+ }
35
+
36
+ function countDescendants(node: MindmapNode): number {
37
+ let count = 0;
38
+ for (const child of node.children) {
39
+ count += 1 + countDescendants(child);
40
+ }
41
+ return count;
42
+ }
43
+
44
+ function computeHiddenCounts(
45
+ nodes: MindmapNode[],
46
+ collapsedIds: Set<string>,
47
+ hiddenCounts: Map<string, number>
48
+ ): void {
49
+ for (const node of nodes) {
50
+ if (collapsedIds.has(node.id) && node.children.length > 0) {
51
+ hiddenCounts.set(node.id, countDescendants(node));
52
+ }
53
+ computeHiddenCounts(node.children, collapsedIds, hiddenCounts);
54
+ }
55
+ }
56
+
57
+ function pruneCollapsed(node: MindmapNode, collapsedIds: Set<string>): void {
58
+ for (const child of node.children) {
59
+ pruneCollapsed(child, collapsedIds);
60
+ }
61
+ if (collapsedIds.has(node.id) && node.children.length > 0) {
62
+ node.children = [];
63
+ }
64
+ }
65
+
66
+ // ============================================================
67
+ // Main
68
+ // ============================================================
69
+
70
+ export function collapseMindmapTree(
71
+ roots: MindmapNode[],
72
+ collapsedIds: Set<string>
73
+ ): CollapsedMindmapResult {
74
+ const hiddenCounts = new Map<string, number>();
75
+
76
+ if (collapsedIds.size === 0) {
77
+ return { roots, hiddenCounts };
78
+ }
79
+
80
+ computeHiddenCounts(roots, collapsedIds, hiddenCounts);
81
+
82
+ const clonedRoots = roots.map(cloneNode);
83
+ for (const root of clonedRoots) {
84
+ pruneCollapsed(root, collapsedIds);
85
+ }
86
+
87
+ return { roots: clonedRoots, hiddenCounts };
88
+ }