@diagrammo/dgmo 0.8.8 → 0.8.10

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.
@@ -6,7 +6,11 @@ import dagre from '@dagrejs/dagre';
6
6
  import type { ParsedSitemap, SitemapNode } from './types';
7
7
  import type { TagGroup } from '../utils/tag-groups';
8
8
  import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
9
- import { LEGEND_PILL_FONT_SIZE, LEGEND_ENTRY_FONT_SIZE, measureLegendText } from '../utils/legend-constants';
9
+ import {
10
+ LEGEND_PILL_FONT_SIZE,
11
+ LEGEND_ENTRY_FONT_SIZE,
12
+ measureLegendText,
13
+ } from '../utils/legend-constants';
10
14
 
11
15
  // ============================================================
12
16
  // Types
@@ -38,6 +42,8 @@ export interface SitemapLayoutEdge {
38
42
  label?: string;
39
43
  color?: string;
40
44
  lineNumber: number;
45
+ /** True for edges deferred from dagre (container endpoints) — use linear curve */
46
+ deferred?: boolean;
41
47
  }
42
48
 
43
49
  export interface SitemapContainerBounds {
@@ -85,6 +91,29 @@ export interface SitemapLayoutResult {
85
91
  height: number;
86
92
  }
87
93
 
94
+ /**
95
+ * Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
96
+ * with given width/height, along the direction toward (tx, ty).
97
+ */
98
+ function clipToRectBorder(
99
+ cx: number,
100
+ cy: number,
101
+ w: number,
102
+ h: number,
103
+ tx: number,
104
+ ty: number
105
+ ): { x: number; y: number } {
106
+ const dx = tx - cx;
107
+ const dy = ty - cy;
108
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
109
+ const hw = w / 2;
110
+ const hh = h / 2;
111
+ const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
112
+ const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
113
+ const s = Math.min(sx, sy);
114
+ return { x: cx + dx * s, y: cy + dy * s };
115
+ }
116
+
88
117
  // ============================================================
89
118
  // Constants
90
119
  // ============================================================
@@ -120,7 +149,7 @@ const LEGEND_EYE_GAP = 6;
120
149
 
121
150
  function filterMetadata(
122
151
  metadata: Record<string, string>,
123
- hiddenAttributes?: Set<string>,
152
+ hiddenAttributes?: Set<string>
124
153
  ): Record<string, string> {
125
154
  if (!hiddenAttributes || hiddenAttributes.size === 0) return metadata;
126
155
  const filtered: Record<string, string> = {};
@@ -138,22 +167,32 @@ function computeCardWidth(label: string, meta: Record<string, string>): number {
138
167
  const lineChars = key.length + 2 + value.length;
139
168
  if (lineChars > maxChars) maxChars = lineChars;
140
169
  }
141
- return Math.max(MIN_CARD_WIDTH, Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2);
170
+ return Math.max(
171
+ MIN_CARD_WIDTH,
172
+ Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
173
+ );
142
174
  }
143
175
 
144
176
  function computeCardHeight(meta: Record<string, string>): number {
145
177
  const metaCount = Object.keys(meta).length;
146
178
  if (metaCount === 0) return HEADER_HEIGHT + CARD_V_PAD;
147
- return HEADER_HEIGHT + SEPARATOR_GAP + metaCount * META_LINE_HEIGHT + CARD_V_PAD;
179
+ return (
180
+ HEADER_HEIGHT + SEPARATOR_GAP + metaCount * META_LINE_HEIGHT + CARD_V_PAD
181
+ );
148
182
  }
149
183
 
150
184
  function resolveNodeColor(
151
185
  node: SitemapNode,
152
186
  tagGroups: TagGroup[],
153
- activeGroupName: string | null,
187
+ activeGroupName: string | null
154
188
  ): string | undefined {
155
189
  if (node.color) return node.color;
156
- return resolveTagColor(node.metadata, tagGroups, activeGroupName, node.isContainer);
190
+ return resolveTagColor(
191
+ node.metadata,
192
+ tagGroups,
193
+ activeGroupName,
194
+ node.isContainer
195
+ );
157
196
  }
158
197
 
159
198
  const OVERLAP_GAP = 20;
@@ -164,7 +203,7 @@ const OVERLAP_GAP = 20;
164
203
 
165
204
  function computeLegendGroups(
166
205
  tagGroups: TagGroup[],
167
- usedValuesByGroup?: Map<string, Set<string>>,
206
+ usedValuesByGroup?: Map<string, Set<string>>
168
207
  ): SitemapLegendGroup[] {
169
208
  const groups: SitemapLegendGroup[] = [];
170
209
 
@@ -177,7 +216,8 @@ function computeLegendGroups(
177
216
  : group.entries;
178
217
  if (visibleEntries.length === 0) continue;
179
218
 
180
- const pillWidth = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
219
+ const pillWidth =
220
+ measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
181
221
  const minPillWidth = pillWidth;
182
222
 
183
223
  let entriesWidth = 0;
@@ -189,7 +229,8 @@ function computeLegendGroups(
189
229
  LEGEND_ENTRY_TRAIL;
190
230
  }
191
231
  const eyeSpace = LEGEND_EYE_SIZE + LEGEND_EYE_GAP;
192
- const capsuleWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + eyeSpace + entriesWidth;
232
+ const capsuleWidth =
233
+ LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + eyeSpace + entriesWidth;
193
234
 
194
235
  groups.push({
195
236
  name: group.name,
@@ -229,25 +270,36 @@ function flattenNodes(
229
270
  parentPageId: string | null,
230
271
  hiddenCounts: Map<string, number> | undefined,
231
272
  hiddenAttributes: Set<string> | undefined,
232
- result: FlatNode[],
273
+ result: FlatNode[]
233
274
  ): void {
234
275
  for (const node of nodes) {
235
276
  const meta = filterMetadata(node.metadata, hiddenAttributes);
236
277
  if (node.isContainer) {
237
278
  // Container gets added as a flat entry (not added to dagre — bounds computed post-hoc)
238
279
  const metaCount = Object.keys(meta).length;
239
- const labelHeight = CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
280
+ const labelHeight =
281
+ CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
240
282
  result.push({
241
283
  sitemapNode: node,
242
284
  parentContainerId,
243
285
  parentPageId,
244
286
  meta,
245
287
  fullMeta: { ...node.metadata },
246
- width: Math.max(MIN_CARD_WIDTH, node.label.length * CHAR_WIDTH + CARD_H_PAD * 2),
288
+ width: Math.max(
289
+ MIN_CARD_WIDTH,
290
+ node.label.length * CHAR_WIDTH + CARD_H_PAD * 2
291
+ ),
247
292
  height: labelHeight + CONTAINER_PAD_BOTTOM,
248
293
  });
249
294
  // Recurse into children — container becomes parent container, parentPageId stays the same
250
- flattenNodes(node.children, node.id, parentPageId, hiddenCounts, hiddenAttributes, result);
295
+ flattenNodes(
296
+ node.children,
297
+ node.id,
298
+ parentPageId,
299
+ hiddenCounts,
300
+ hiddenAttributes,
301
+ result
302
+ );
251
303
  } else {
252
304
  result.push({
253
305
  sitemapNode: node,
@@ -260,7 +312,14 @@ function flattenNodes(
260
312
  });
261
313
  // Pages can have children too (nested pages) — this page becomes the parentPageId
262
314
  if (node.children.length > 0) {
263
- flattenNodes(node.children, parentContainerId, node.id, hiddenCounts, hiddenAttributes, result);
315
+ flattenNodes(
316
+ node.children,
317
+ parentContainerId,
318
+ node.id,
319
+ hiddenCounts,
320
+ hiddenAttributes,
321
+ result
322
+ );
264
323
  }
265
324
  }
266
325
  }
@@ -275,10 +334,17 @@ export function layoutSitemap(
275
334
  hiddenCounts?: Map<string, number>,
276
335
  activeTagGroup?: string | null,
277
336
  hiddenAttributes?: Set<string>,
278
- expandAllLegend?: boolean,
337
+ expandAllLegend?: boolean
279
338
  ): SitemapLayoutResult {
280
339
  if (parsed.roots.length === 0) {
281
- return { nodes: [], edges: [], containers: [], legend: [], width: 0, height: 0 };
340
+ return {
341
+ nodes: [],
342
+ edges: [],
343
+ containers: [],
344
+ legend: [],
345
+ width: 0,
346
+ height: 0,
347
+ };
282
348
  }
283
349
 
284
350
  // Inject default tag metadata
@@ -288,11 +354,22 @@ export function layoutSitemap(
288
354
  for (const child of node.children) collect(child);
289
355
  };
290
356
  for (const root of parsed.roots) collect(root);
291
- injectDefaultTagMetadata(allNodes, parsed.tagGroups, (e) => (e as SitemapNode).isContainer);
357
+ injectDefaultTagMetadata(
358
+ allNodes,
359
+ parsed.tagGroups,
360
+ (e) => (e as SitemapNode).isContainer
361
+ );
292
362
 
293
363
  // Flatten hierarchy
294
364
  const flatNodes: FlatNode[] = [];
295
- flattenNodes(parsed.roots, null, null, hiddenCounts, hiddenAttributes, flatNodes);
365
+ flattenNodes(
366
+ parsed.roots,
367
+ null,
368
+ null,
369
+ hiddenCounts,
370
+ hiddenAttributes,
371
+ flatNodes
372
+ );
296
373
 
297
374
  // Build nodeMap for lookups
298
375
  const nodeMap = new Map<string, FlatNode>();
@@ -325,7 +402,7 @@ export function layoutSitemap(
325
402
  containerIds.add(flat.sitemapNode.id);
326
403
  // A container is "collapsed" if it has no children at all in the flat list
327
404
  const hasAnyChild = flatNodes.some(
328
- (f) => f.parentContainerId === flat.sitemapNode.id,
405
+ (f) => f.parentContainerId === flat.sitemapNode.id
329
406
  );
330
407
  if (!hasAnyChild) {
331
408
  collapsedContainerIds.add(flat.sitemapNode.id);
@@ -367,20 +444,44 @@ export function layoutSitemap(
367
444
 
368
445
  // Set parent relationships — dagre compound nesting keeps nodes grouped
369
446
  for (const flat of flatNodes) {
370
- if (flat.parentContainerId && !collapsedContainerIds.has(flat.parentContainerId)) {
447
+ if (
448
+ flat.parentContainerId &&
449
+ !collapsedContainerIds.has(flat.parentContainerId)
450
+ ) {
371
451
  g.setParent(flat.sitemapNode.id, flat.parentContainerId);
372
452
  }
373
453
  }
374
454
 
375
- // Add user edges (named for multigrapheach edge gets unique routing)
455
+ // Build set of expanded (non-collapsed) container IDsdagre can't route
456
+ // edges to compound parents (they have no rank of their own)
457
+ const expandedContainerIds = new Set<string>();
458
+ for (const cid of containerIds) {
459
+ if (!collapsedContainerIds.has(cid)) {
460
+ expandedContainerIds.add(cid);
461
+ }
462
+ }
463
+
464
+ // Add user edges — defer edges touching expanded containers
465
+ const deferredEdgeIndices: number[] = [];
376
466
  for (let i = 0; i < parsed.edges.length; i++) {
377
467
  const edge = parsed.edges[i];
378
- if (g.hasNode(edge.sourceId) && g.hasNode(edge.targetId)) {
379
- g.setEdge(edge.sourceId, edge.targetId, {
468
+ if (!g.hasNode(edge.sourceId) || !g.hasNode(edge.targetId)) continue;
469
+ if (
470
+ expandedContainerIds.has(edge.sourceId) ||
471
+ expandedContainerIds.has(edge.targetId)
472
+ ) {
473
+ deferredEdgeIndices.push(i);
474
+ continue;
475
+ }
476
+ g.setEdge(
477
+ edge.sourceId,
478
+ edge.targetId,
479
+ {
380
480
  label: edge.label ?? '',
381
481
  minlen: 1,
382
- }, `e${i}`);
383
- }
482
+ },
483
+ `e${i}`
484
+ );
384
485
  }
385
486
 
386
487
  // Run dagre layout
@@ -412,7 +513,7 @@ export function layoutSitemap(
412
513
  height: pos.height,
413
514
  hiddenCount: hc,
414
515
  hasChildren:
415
- (node.children.length > 0 || (hc != null && hc > 0)) || undefined,
516
+ node.children.length > 0 || (hc != null && hc > 0) || undefined,
416
517
  });
417
518
  }
418
519
 
@@ -424,7 +525,8 @@ export function layoutSitemap(
424
525
  const pos = g.node(node.id);
425
526
  const hc = hiddenCounts?.get(node.id);
426
527
  const metaCount = Object.keys(flat.meta).length;
427
- const labelHeight = CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
528
+ const labelHeight =
529
+ CONTAINER_LABEL_HEIGHT + metaCount * CONTAINER_META_LINE_HEIGHT;
428
530
 
429
531
  if (pos) {
430
532
  layoutContainers.push({
@@ -441,7 +543,7 @@ export function layoutSitemap(
441
543
  labelHeight,
442
544
  hiddenCount: hc,
443
545
  hasChildren:
444
- (node.children.length > 0 || (hc != null && hc > 0)) || undefined,
546
+ node.children.length > 0 || (hc != null && hc > 0) || undefined,
445
547
  });
446
548
  } else {
447
549
  // Fallback
@@ -459,26 +561,62 @@ export function layoutSitemap(
459
561
  labelHeight,
460
562
  hiddenCount: hc,
461
563
  hasChildren:
462
- (node.children.length > 0 || (hc != null && hc > 0)) || undefined,
564
+ node.children.length > 0 || (hc != null && hc > 0) || undefined,
463
565
  });
464
566
  }
465
567
  }
466
568
 
467
- // Edge waypoints from dagre (named edges for multigraph)
569
+ // Edge waypoints from dagre (named edges for multigraph) + deferred edges
570
+ const deferredSet = new Set(deferredEdgeIndices);
468
571
  const layoutEdges: SitemapLayoutEdge[] = [];
469
572
  for (let i = 0; i < parsed.edges.length; i++) {
470
573
  const edge = parsed.edges[i];
471
574
  if (!g.hasNode(edge.sourceId) || !g.hasNode(edge.targetId)) continue;
472
- const edgeData = g.edge({ v: edge.sourceId, w: edge.targetId, name: `e${i}` });
473
- if (!edgeData) continue;
575
+
576
+ let points: { x: number; y: number }[];
577
+
578
+ if (deferredSet.has(i)) {
579
+ // Deferred edge (expanded container endpoint) — clip to border
580
+ const srcNode = g.node(edge.sourceId);
581
+ const tgtNode = g.node(edge.targetId);
582
+ if (!srcNode || !tgtNode) continue;
583
+ const srcPt = clipToRectBorder(
584
+ srcNode.x,
585
+ srcNode.y,
586
+ srcNode.width,
587
+ srcNode.height,
588
+ tgtNode.x,
589
+ tgtNode.y
590
+ );
591
+ const tgtPt = clipToRectBorder(
592
+ tgtNode.x,
593
+ tgtNode.y,
594
+ tgtNode.width,
595
+ tgtNode.height,
596
+ srcNode.x,
597
+ srcNode.y
598
+ );
599
+ const midX = (srcPt.x + tgtPt.x) / 2;
600
+ const midY = (srcPt.y + tgtPt.y) / 2;
601
+ points = [srcPt, { x: midX, y: midY }, tgtPt];
602
+ } else {
603
+ const edgeData = g.edge({
604
+ v: edge.sourceId,
605
+ w: edge.targetId,
606
+ name: `e${i}`,
607
+ });
608
+ if (!edgeData) continue;
609
+ points = edgeData.points ?? [];
610
+ }
474
611
 
475
612
  layoutEdges.push({
476
613
  sourceId: edge.sourceId,
477
614
  targetId: edge.targetId,
478
- points: edgeData.points ?? [],
615
+ points,
479
616
  label: edge.label,
480
617
  color: edge.color,
481
618
  lineNumber: edge.lineNumber,
619
+ deferred: deferredSet.has(i) || undefined,
482
620
  });
483
621
  }
484
622
 
@@ -509,7 +647,8 @@ export function layoutSitemap(
509
647
  }
510
648
 
511
649
  // Main component = component containing the first root page
512
- const firstRootPage = flatNodes.find((f) => !f.sitemapNode.isContainer)?.sitemapNode.id;
650
+ const firstRootPage = flatNodes.find((f) => !f.sitemapNode.isContainer)
651
+ ?.sitemapNode.id;
513
652
  const mainRoot = firstRootPage ? ufFind(firstRootPage) : null;
514
653
 
515
654
  // Collect isolated node IDs (not in main component)
@@ -528,7 +667,7 @@ export function layoutSitemap(
528
667
  continue;
529
668
  }
530
669
  const members = flatNodes.filter(
531
- (f) => !f.sitemapNode.isContainer && f.parentContainerId === cid,
670
+ (f) => !f.sitemapNode.isContainer && f.parentContainerId === cid
532
671
  );
533
672
  if (
534
673
  members.length > 0 &&
@@ -677,9 +816,12 @@ export function layoutSitemap(
677
816
  // Legend
678
817
  const legendGroups = computeLegendGroups(parsed.tagGroups, usedValuesByGroup);
679
818
 
680
- const visibleGroups = activeTagGroup != null
681
- ? legendGroups.filter((g) => g.name.toLowerCase() === activeTagGroup.toLowerCase())
682
- : legendGroups;
819
+ const visibleGroups =
820
+ activeTagGroup != null
821
+ ? legendGroups.filter(
822
+ (g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
823
+ )
824
+ : legendGroups;
683
825
  const allExpanded = expandAllLegend && activeTagGroup == null;
684
826
  const effectiveW = (g: SitemapLegendGroup) =>
685
827
  activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
@@ -46,11 +46,21 @@ const BARE_ARROW_RE = /^->\s*(.+)$/;
46
46
  function parseArrowLine(
47
47
  trimmed: string,
48
48
  palette?: PaletteColors
49
- ): { label?: string; color?: string; target: string } | null {
49
+ ): {
50
+ label?: string;
51
+ color?: string;
52
+ target: string;
53
+ targetIsGroup: boolean;
54
+ } | null {
50
55
  // Bare arrow: -> Target
51
56
  const bareMatch = trimmed.match(BARE_ARROW_RE);
52
57
  if (bareMatch) {
53
- return { target: bareMatch[1].trim() };
58
+ const rawTarget = bareMatch[1].trim();
59
+ const groupMatch = rawTarget.match(/^\[(.+)\]$/);
60
+ return {
61
+ target: groupMatch ? groupMatch[1].trim() : rawTarget,
62
+ targetIsGroup: !!groupMatch,
63
+ };
54
64
  }
55
65
 
56
66
  // Labeled/colored arrow: -label(color)-> Target
@@ -63,8 +73,14 @@ function parseArrowLine(
63
73
  if (label && !color) {
64
74
  color = inferArrowColor(label);
65
75
  }
66
- const target = arrowMatch[3].trim();
67
- return { label, color, target };
76
+ const rawTarget = arrowMatch[3].trim();
77
+ const groupMatch = rawTarget.match(/^\[(.+)\]$/);
78
+ return {
79
+ label,
80
+ color,
81
+ target: groupMatch ? groupMatch[1].trim() : rawTarget,
82
+ targetIsGroup: !!groupMatch,
83
+ };
68
84
  }
69
85
 
70
86
  return null;
@@ -169,10 +185,14 @@ export function parseSitemap(
169
185
  // Map label (lowercased) -> node for arrow target resolution
170
186
  const labelToNode = new Map<string, SitemapNode>();
171
187
 
188
+ // Map label (lowercased) -> container for group-targeted arrow resolution
189
+ const labelToContainer = new Map<string, SitemapNode>();
190
+
172
191
  // Deferred arrows: { sourceNode, arrow info, lineNumber }
173
192
  const deferredArrows: {
174
193
  sourceNode: SitemapNode;
175
194
  targetLabel: string;
195
+ targetIsGroup: boolean;
176
196
  label?: string;
177
197
  color?: string;
178
198
  lineNumber: number;
@@ -311,6 +331,7 @@ export function parseSitemap(
311
331
  deferredArrows.push({
312
332
  sourceNode: source,
313
333
  targetLabel: arrowInfo.target,
334
+ targetIsGroup: arrowInfo.targetIsGroup,
314
335
  label: arrowInfo.label,
315
336
  color: arrowInfo.color,
316
337
  lineNumber,
@@ -356,7 +377,8 @@ export function parseSitemap(
356
377
  };
357
378
 
358
379
  attachNode(node, indent, indentStack, result);
359
- // Don't register containers in labelToNode arrows target pages, not containers
380
+ // Register in labelToContainer for group-targeted arrows (-> [Group])
381
+ labelToContainer.set(label.toLowerCase(), node);
360
382
  } else if (metadataMatch && indentStack.length > 0) {
361
383
  // Metadata line — attach to parent
362
384
  const rawKey = metadataMatch[1].trim().toLowerCase();
@@ -403,25 +425,44 @@ export function parseSitemap(
403
425
  // --- Post-parse: resolve arrow targets ---
404
426
  for (const arrow of deferredArrows) {
405
427
  const targetKey = arrow.targetLabel.toLowerCase();
406
- const targetNode = labelToNode.get(targetKey);
407
-
408
- if (!targetNode) {
409
- // Try suggestion
410
- const allLabels = Array.from(labelToNode.keys());
411
- let msg = `Arrow target "${arrow.targetLabel}" not found`;
412
- const hint = suggest(targetKey, allLabels);
413
- if (hint) msg += `. ${hint}`;
414
- pushError(arrow.lineNumber, msg);
415
- continue;
416
- }
417
428
 
418
- result.edges.push({
419
- sourceId: arrow.sourceNode.id,
420
- targetId: targetNode.id,
421
- label: arrow.label,
422
- color: arrow.color,
423
- lineNumber: arrow.lineNumber,
424
- });
429
+ if (arrow.targetIsGroup) {
430
+ // Group target: look up in labelToContainer
431
+ const targetContainer = labelToContainer.get(targetKey);
432
+ if (!targetContainer) {
433
+ const allLabels = Array.from(labelToContainer.keys());
434
+ let msg = `Group '[${arrow.targetLabel}]' not found`;
435
+ const hint = suggest(targetKey, allLabels);
436
+ if (hint) msg += `. ${hint}`;
437
+ pushError(arrow.lineNumber, msg);
438
+ continue;
439
+ }
440
+ result.edges.push({
441
+ sourceId: arrow.sourceNode.id,
442
+ targetId: targetContainer.id,
443
+ label: arrow.label,
444
+ color: arrow.color,
445
+ lineNumber: arrow.lineNumber,
446
+ });
447
+ } else {
448
+ // Node target: look up in labelToNode (existing behavior)
449
+ const targetNode = labelToNode.get(targetKey);
450
+ if (!targetNode) {
451
+ const allLabels = Array.from(labelToNode.keys());
452
+ let msg = `Arrow target "${arrow.targetLabel}" not found`;
453
+ const hint = suggest(targetKey, allLabels);
454
+ if (hint) msg += `. ${hint}`;
455
+ pushError(arrow.lineNumber, msg);
456
+ continue;
457
+ }
458
+ result.edges.push({
459
+ sourceId: arrow.sourceNode.id,
460
+ targetId: targetNode.id,
461
+ label: arrow.label,
462
+ color: arrow.color,
463
+ lineNumber: arrow.lineNumber,
464
+ });
465
+ }
425
466
  }
426
467
 
427
468
  // Validate tag group values on all nodes