@diagrammo/dgmo 0.6.1 → 0.6.2

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.
@@ -13,12 +13,21 @@ import {
13
13
  LEGEND_HEIGHT,
14
14
  LEGEND_PILL_PAD,
15
15
  LEGEND_PILL_FONT_SIZE,
16
+ LEGEND_PILL_FONT_W,
17
+ LEGEND_CAPSULE_PAD,
18
+ LEGEND_DOT_R,
19
+ LEGEND_ENTRY_FONT_SIZE,
20
+ LEGEND_ENTRY_FONT_W,
21
+ LEGEND_ENTRY_DOT_GAP,
22
+ LEGEND_ENTRY_TRAIL,
16
23
  LEGEND_GROUP_GAP,
17
24
  } from '../utils/legend-constants';
18
25
  import type { ParsedERDiagram, ERConstraint } from './types';
19
26
  import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
20
27
  import { parseERDiagram } from './parser';
21
28
  import { layoutERDiagram } from './layout';
29
+ import { classifyEREntities, ROLE_COLORS, ROLE_LABELS, ROLE_ORDER } from './classify';
30
+ import type { EntityRole } from './classify';
22
31
 
23
32
  // ============================================================
24
33
  // Constants
@@ -208,32 +217,62 @@ export function renderERDiagram(
208
217
  isDark: boolean,
209
218
  onClickItem?: (lineNumber: number) => void,
210
219
  exportDims?: { width?: number; height?: number },
211
- activeTagGroup?: string | null
220
+ activeTagGroup?: string | null,
221
+ /** When false, semantic role colors are suppressed and entities use a neutral color. */
222
+ semanticColorsActive?: boolean
212
223
  ): void {
213
224
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
214
225
 
215
- const width = exportDims?.width ?? container.clientWidth;
216
- const height = exportDims?.height ?? container.clientHeight;
217
- if (width <= 0 || height <= 0) return;
226
+ const useSemanticColors =
227
+ parsed.tagGroups.length === 0 && layout.nodes.every((n) => !n.color);
228
+ const legendReserveH = useSemanticColors ? LEGEND_HEIGHT + DIAGRAM_PADDING : 0;
218
229
 
219
230
  const titleHeight = parsed.title ? 40 : 0;
220
231
  const diagramW = layout.width;
221
232
  const diagramH = layout.height;
222
- const availH = height - titleHeight;
223
- const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
224
- const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
225
- const scale = Math.min(MAX_SCALE, scaleX, scaleY);
226
233
 
227
- const scaledW = diagramW * scale;
228
- const scaledH = diagramH * scale;
229
- const offsetX = (width - scaledW) / 2;
230
- const offsetY = titleHeight + DIAGRAM_PADDING;
234
+ // Natural dimensions derived purely from the layout — no dependency on container
235
+ // size at render time, which eliminates the stagger caused by reading clientWidth/
236
+ // clientHeight before the container has stabilized.
237
+ const naturalW = diagramW + DIAGRAM_PADDING * 2;
238
+ const naturalH = diagramH + titleHeight + legendReserveH + DIAGRAM_PADDING * 2;
239
+
240
+ // For export: scale the natural layout to fit the requested pixel dimensions.
241
+ // For live preview: render at natural scale (scale=1) and let the SVG viewBox
242
+ // handle fitting to the container via CSS.
243
+ let viewW: number;
244
+ let viewH: number;
245
+ let scale: number;
246
+ let offsetX: number;
247
+ let offsetY: number;
248
+
249
+ if (exportDims) {
250
+ viewW = exportDims.width ?? naturalW;
251
+ viewH = exportDims.height ?? naturalH;
252
+ const availH = viewH - titleHeight - legendReserveH;
253
+ const scaleX = (viewW - DIAGRAM_PADDING * 2) / diagramW;
254
+ const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
255
+ scale = Math.min(MAX_SCALE, scaleX, scaleY);
256
+ const scaledW = diagramW * scale;
257
+ offsetX = (viewW - scaledW) / 2;
258
+ offsetY = titleHeight + DIAGRAM_PADDING;
259
+ } else {
260
+ viewW = naturalW;
261
+ viewH = naturalH;
262
+ scale = 1;
263
+ offsetX = DIAGRAM_PADDING;
264
+ offsetY = titleHeight + DIAGRAM_PADDING;
265
+ }
266
+
267
+ if (viewW <= 0 || viewH <= 0) return;
231
268
 
232
269
  const svg = d3Selection
233
270
  .select(container)
234
271
  .append('svg')
235
- .attr('width', width)
236
- .attr('height', height)
272
+ .attr('width', exportDims ? viewW : '100%')
273
+ .attr('height', exportDims ? viewH : '100%')
274
+ .attr('viewBox', `0 0 ${viewW} ${viewH}`)
275
+ .attr('preserveAspectRatio', 'xMidYMid meet')
237
276
  .style('font-family', FONT_FAMILY);
238
277
 
239
278
  // ── Title ──
@@ -241,7 +280,7 @@ export function renderERDiagram(
241
280
  const titleEl = svg
242
281
  .append('text')
243
282
  .attr('class', 'chart-title')
244
- .attr('x', width / 2)
283
+ .attr('x', viewW / 2)
245
284
  .attr('y', 30)
246
285
  .attr('text-anchor', 'middle')
247
286
  .attr('fill', palette.text)
@@ -269,6 +308,15 @@ export function renderERDiagram(
269
308
  // ── Auto-assign colors ──
270
309
  const seriesColors = getSeriesColors(palette);
271
310
 
311
+ // ── Semantic coloring gate ──
312
+ // Classify entities whenever conditions allow; suppress colors when user collapses the legend.
313
+ // (useSemanticColors was computed above for legend reserve height)
314
+ const semanticRoles: Map<string, EntityRole> | null = useSemanticColors
315
+ ? classifyEREntities(parsed.tables, parsed.relationships)
316
+ : null;
317
+ // semanticColorsActive defaults to true; false = legend collapsed, neutral color applied
318
+ const semanticActive = semanticRoles !== null && (semanticColorsActive ?? true);
319
+
272
320
  // ── Edges (behind nodes) ──
273
321
  const useLabels = parsed.options.notation === 'labels';
274
322
 
@@ -347,7 +395,12 @@ export function renderERDiagram(
347
395
  for (let ni = 0; ni < layout.nodes.length; ni++) {
348
396
  const node = layout.nodes[ni];
349
397
  const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
350
- const nodeColor = node.color ?? tagColor ?? seriesColors[ni % seriesColors.length];
398
+ const semanticColor = semanticActive
399
+ ? palette.colors[ROLE_COLORS[semanticRoles!.get(node.id) ?? 'unclassified']]
400
+ : semanticRoles
401
+ ? palette.primary // neutral color when legend is collapsed
402
+ : undefined;
403
+ const nodeColor = node.color ?? tagColor ?? semanticColor ?? seriesColors[ni % seriesColors.length];
351
404
 
352
405
  const nodeG = contentG
353
406
  .append('g')
@@ -365,6 +418,12 @@ export function renderERDiagram(
365
418
  }
366
419
  }
367
420
 
421
+ // Set data-er-role for semantic coloring (CSS targeting + test assertions)
422
+ if (semanticRoles) {
423
+ const role = semanticRoles.get(node.id);
424
+ if (role) nodeG.attr('data-er-role', role);
425
+ }
426
+
368
427
  if (onClickItem) {
369
428
  nodeG.style('cursor', 'pointer').on('click', () => {
370
429
  onClickItem(node.lineNumber);
@@ -463,7 +522,7 @@ export function renderERDiagram(
463
522
  }
464
523
 
465
524
  let legendX = DIAGRAM_PADDING;
466
- let legendY = height - DIAGRAM_PADDING;
525
+ let legendY = viewH - DIAGRAM_PADDING;
467
526
 
468
527
  for (const group of parsed.tagGroups) {
469
528
  const groupG = legendG.append('g')
@@ -525,6 +584,161 @@ export function renderERDiagram(
525
584
  legendX += LEGEND_GROUP_GAP;
526
585
  }
527
586
  }
587
+
588
+ // ── Semantic Legend ──
589
+ // Rendered when semantic role detection is enabled (no tag groups, no explicit colors).
590
+ // Follows the sequence-legend pattern: one clickable "Role" group pill that expands
591
+ // to show colored-dot entries. Clicking toggles semanticColorsActive on/off.
592
+ if (semanticRoles) {
593
+ const presentRoles = ROLE_ORDER.filter((role) => {
594
+ for (const r of semanticRoles.values()) {
595
+ if (r === role) return true;
596
+ }
597
+ return false;
598
+ });
599
+
600
+ if (presentRoles.length > 0) {
601
+ // Measure actual text widths for consistent spacing regardless of character mix.
602
+ // Falls back to a character-count estimate in jsdom/test environments.
603
+ const measureLabelW = (text: string, fontSize: number): number => {
604
+ const dummy = svg.append('text')
605
+ .attr('font-size', fontSize)
606
+ .attr('font-family', FONT_FAMILY)
607
+ .attr('visibility', 'hidden')
608
+ .text(text);
609
+ const measured = (dummy.node() as SVGTextElement | null)?.getComputedTextLength?.() ?? 0;
610
+ dummy.remove();
611
+ return measured > 0 ? measured : text.length * fontSize * 0.6;
612
+ };
613
+
614
+ const labelWidths = new Map<EntityRole, number>();
615
+ for (const role of presentRoles) {
616
+ labelWidths.set(role, measureLabelW(ROLE_LABELS[role], LEGEND_ENTRY_FONT_SIZE));
617
+ }
618
+
619
+ const groupBg = isDark
620
+ ? mix(palette.surface, palette.bg, 50)
621
+ : mix(palette.surface, palette.bg, 30);
622
+
623
+ const groupName = 'Role';
624
+ const pillWidth = groupName.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
625
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
626
+
627
+ let totalWidth: number;
628
+ let entriesWidth = 0;
629
+ if (semanticActive) {
630
+ for (const role of presentRoles) {
631
+ entriesWidth +=
632
+ LEGEND_DOT_R * 2 +
633
+ LEGEND_ENTRY_DOT_GAP +
634
+ labelWidths.get(role)! +
635
+ LEGEND_ENTRY_TRAIL;
636
+ }
637
+ totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + LEGEND_ENTRY_TRAIL + entriesWidth;
638
+ } else {
639
+ totalWidth = pillWidth;
640
+ }
641
+
642
+ const legendX = (viewW - totalWidth) / 2;
643
+ const legendY = viewH - DIAGRAM_PADDING - LEGEND_HEIGHT;
644
+
645
+ const semanticLegendG = svg
646
+ .append('g')
647
+ .attr('class', 'er-semantic-legend')
648
+ .attr('data-legend-group', 'role')
649
+ .attr('transform', `translate(${legendX}, ${legendY})`)
650
+ .style('cursor', 'pointer');
651
+
652
+ if (semanticActive) {
653
+ // ── Expanded: outer capsule + inner pill + dot entries ──
654
+ semanticLegendG
655
+ .append('rect')
656
+ .attr('width', totalWidth)
657
+ .attr('height', LEGEND_HEIGHT)
658
+ .attr('rx', LEGEND_HEIGHT / 2)
659
+ .attr('fill', groupBg);
660
+
661
+ semanticLegendG
662
+ .append('rect')
663
+ .attr('x', LEGEND_CAPSULE_PAD)
664
+ .attr('y', LEGEND_CAPSULE_PAD)
665
+ .attr('width', pillWidth)
666
+ .attr('height', pillH)
667
+ .attr('rx', pillH / 2)
668
+ .attr('fill', palette.bg);
669
+
670
+ semanticLegendG
671
+ .append('rect')
672
+ .attr('x', LEGEND_CAPSULE_PAD)
673
+ .attr('y', LEGEND_CAPSULE_PAD)
674
+ .attr('width', pillWidth)
675
+ .attr('height', pillH)
676
+ .attr('rx', pillH / 2)
677
+ .attr('fill', 'none')
678
+ .attr('stroke', mix(palette.textMuted, palette.bg, 50))
679
+ .attr('stroke-width', 0.75);
680
+
681
+ semanticLegendG
682
+ .append('text')
683
+ .attr('x', LEGEND_CAPSULE_PAD + pillWidth / 2)
684
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
685
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
686
+ .attr('font-weight', '500')
687
+ .attr('fill', palette.text)
688
+ .attr('text-anchor', 'middle')
689
+ .attr('font-family', FONT_FAMILY)
690
+ .text(groupName);
691
+
692
+ let entryX = LEGEND_CAPSULE_PAD + pillWidth + LEGEND_ENTRY_TRAIL;
693
+ for (const role of presentRoles) {
694
+ const label = ROLE_LABELS[role];
695
+ const roleColor = palette.colors[ROLE_COLORS[role]];
696
+
697
+ const entryG = semanticLegendG
698
+ .append('g')
699
+ .attr('data-legend-entry', role);
700
+
701
+ entryG
702
+ .append('circle')
703
+ .attr('cx', entryX + LEGEND_DOT_R)
704
+ .attr('cy', LEGEND_HEIGHT / 2)
705
+ .attr('r', LEGEND_DOT_R)
706
+ .attr('fill', roleColor);
707
+
708
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
709
+ entryG
710
+ .append('text')
711
+ .attr('x', textX)
712
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
713
+ .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
714
+ .attr('fill', palette.textMuted)
715
+ .attr('font-family', FONT_FAMILY)
716
+ .text(label);
717
+
718
+ entryX = textX + labelWidths.get(role)! + LEGEND_ENTRY_TRAIL;
719
+ }
720
+ } else {
721
+ // ── Collapsed: single muted pill, no entries ──
722
+ semanticLegendG
723
+ .append('rect')
724
+ .attr('width', pillWidth)
725
+ .attr('height', LEGEND_HEIGHT)
726
+ .attr('rx', LEGEND_HEIGHT / 2)
727
+ .attr('fill', groupBg);
728
+
729
+ semanticLegendG
730
+ .append('text')
731
+ .attr('x', pillWidth / 2)
732
+ .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
733
+ .attr('font-size', LEGEND_PILL_FONT_SIZE)
734
+ .attr('font-weight', '500')
735
+ .attr('fill', palette.textMuted)
736
+ .attr('text-anchor', 'middle')
737
+ .attr('font-family', FONT_FAMILY)
738
+ .text(groupName);
739
+ }
740
+ }
741
+ }
528
742
  }
529
743
 
530
744
  // ============================================================
@@ -73,6 +73,7 @@ export interface InfraLayoutResult {
73
73
  groups: InfraLayoutGroup[];
74
74
  /** Diagram-level options (e.g., default-latency-ms, default-uptime). */
75
75
  options: Record<string, string>;
76
+ direction: 'LR' | 'TB';
76
77
  width: number;
77
78
  height: number;
78
79
  }
@@ -110,12 +111,12 @@ const DISPLAY_KEYS = new Set([
110
111
 
111
112
  /** Display names for width estimation. */
112
113
  const DISPLAY_NAMES: Record<string, string> = {
113
- 'cache-hit': 'cache hit', 'firewall-block': 'fw block',
114
+ 'cache-hit': 'cache hit', 'firewall-block': 'firewall block',
114
115
  'ratelimit-rps': 'rate limit RPS', 'latency-ms': 'latency', 'uptime': 'uptime',
115
116
  'instances': 'instances', 'max-rps': 'max RPS',
116
- 'cb-error-threshold': 'CB error', 'cb-latency-threshold-ms': 'CB latency',
117
+ 'cb-error-threshold': 'CB error threshold', 'cb-latency-threshold-ms': 'CB latency threshold',
117
118
  'concurrency': 'concurrency', 'duration-ms': 'duration', 'cold-start-ms': 'cold start',
118
- 'buffer': 'buffer', 'drain-rate': 'drain', 'retention-hours': 'retention', 'partitions': 'partitions',
119
+ 'buffer': 'buffer', 'drain-rate': 'drain rate', 'retention-hours': 'retention', 'partitions': 'partitions',
119
120
  };
120
121
 
121
122
  function countDisplayProps(node: ComputedInfraNode, expanded: boolean, options?: Record<string, string>): number {
@@ -356,18 +357,20 @@ function formatUptime(fraction: number): string {
356
357
  // Group separation pass
357
358
  // ============================================================
358
359
 
359
- const GROUP_GAP = 24; // min clear gap between group boxes — matches GROUP_HEADER_HEIGHT
360
+ const GROUP_GAP = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT; // min clear gap between group boxes
360
361
 
361
362
  export function separateGroups(
362
363
  groups: InfraLayoutGroup[],
363
364
  nodes: InfraLayoutNode[],
364
365
  isLR: boolean,
365
366
  maxIterations = 20,
366
- ): void {
367
+ ): Map<string, { dx: number; dy: number }> {
367
368
  // Symmetric 2D rectangle intersection — no sorting needed, handles all
368
369
  // relative positions correctly, stable after mid-pass shifts.
369
370
  // Endpoint edge routing is not affected: renderer.ts recomputes border
370
371
  // connection points from node x/y at render time via nodeBorderPoint().
372
+ const groupDeltas = new Map<string, { dx: number; dy: number }>();
373
+ let converged = false;
371
374
  for (let iter = 0; iter < maxIterations; iter++) {
372
375
  let anyOverlap = false;
373
376
  for (let i = 0; i < groups.length; i++) {
@@ -398,6 +401,11 @@ export function separateGroups(
398
401
  if (isLR) groupToShift.y += shift;
399
402
  else groupToShift.x += shift;
400
403
 
404
+ // Accumulate the total delta for this group (used by fixEdgeWaypoints)
405
+ const prev = groupDeltas.get(groupToShift.id) ?? { dx: 0, dy: 0 };
406
+ if (isLR) groupDeltas.set(groupToShift.id, { dx: prev.dx, dy: prev.dy + shift });
407
+ else groupDeltas.set(groupToShift.id, { dx: prev.dx + shift, dy: prev.dy });
408
+
401
409
  for (const node of nodes) {
402
410
  if (node.groupId === groupToShift.id) {
403
411
  if (isLR) node.y += shift;
@@ -406,7 +414,44 @@ export function separateGroups(
406
414
  }
407
415
  }
408
416
  }
409
- if (!anyOverlap) break;
417
+ if (!anyOverlap) { converged = true; break; }
418
+ }
419
+ if (!converged && maxIterations > 0) {
420
+ console.warn(`separateGroups: hit maxIterations (${maxIterations}) without fully resolving all group overlaps`);
421
+ }
422
+ return groupDeltas;
423
+ }
424
+
425
+ export function fixEdgeWaypoints(
426
+ edges: InfraLayoutEdge[],
427
+ nodes: InfraLayoutNode[],
428
+ groupDeltas: Map<string, { dx: number; dy: number }>,
429
+ ): void {
430
+ if (groupDeltas.size === 0) return;
431
+ const nodeToGroup = new Map<string, string | null>();
432
+ for (const node of nodes) nodeToGroup.set(node.id, node.groupId);
433
+
434
+ for (const edge of edges) {
435
+ const srcGroup = nodeToGroup.get(edge.sourceId) ?? null;
436
+ // Group-targeting edges (targetId is a group ID, not a node) return undefined from the map → null →
437
+ // treated as "ungrouped target", which is the correct approximation.
438
+ const tgtGroup = nodeToGroup.get(edge.targetId) ?? null;
439
+ const srcDelta = srcGroup ? groupDeltas.get(srcGroup) : undefined;
440
+ const tgtDelta = tgtGroup ? groupDeltas.get(tgtGroup) : undefined;
441
+
442
+ if (!srcDelta && !tgtDelta) continue; // neither side shifted
443
+
444
+ if (srcDelta && tgtDelta && srcGroup !== tgtGroup) {
445
+ // both sides in different shifted groups — discard, renderer draws a straight line
446
+ edge.points = [];
447
+ continue;
448
+ }
449
+
450
+ const delta = srcDelta ?? tgtDelta!;
451
+ for (const pt of edge.points) {
452
+ pt.x += delta.dx;
453
+ pt.y += delta.dy;
454
+ }
410
455
  }
411
456
  }
412
457
 
@@ -416,15 +461,16 @@ export function separateGroups(
416
461
 
417
462
  export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<string> | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
418
463
  if (computed.nodes.length === 0) {
419
- return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
464
+ return { nodes: [], edges: [], groups: [], options: {}, direction: computed.direction, width: 0, height: 0 };
420
465
  }
421
466
 
467
+ const isLR = computed.direction !== 'TB';
422
468
  const g = new dagre.graphlib.Graph();
423
469
  g.setGraph({
424
470
  rankdir: computed.direction === 'TB' ? 'TB' : 'LR',
425
- nodesep: 50,
426
- ranksep: 100,
427
- edgesep: 20,
471
+ nodesep: isLR ? 70 : 60,
472
+ ranksep: isLR ? 150 : 120,
473
+ edgesep: 30,
428
474
  });
429
475
  g.setDefaultEdgeLabel(() => ({}));
430
476
 
@@ -436,7 +482,6 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
436
482
 
437
483
  // Extra space dagre must reserve for the group bounding box
438
484
  const GROUP_INFLATE = GROUP_PADDING * 2 + GROUP_HEADER_HEIGHT;
439
- const isLR = computed.direction !== 'TB';
440
485
 
441
486
  // Add nodes — inflate grouped nodes so dagre accounts for group boxes
442
487
  const widthMap = new Map<string, number>();
@@ -591,8 +636,9 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
591
636
  };
592
637
  });
593
638
 
594
- // Separate overlapping groups (post-layout pass)
595
- separateGroups(layoutGroups, layoutNodes, isLR);
639
+ // Separate overlapping groups (post-layout pass) and fix stale edge waypoints
640
+ const groupDeltas = separateGroups(layoutGroups, layoutNodes, isLR);
641
+ fixEdgeWaypoints(layoutEdges, layoutNodes, groupDeltas);
596
642
 
597
643
  // Compute total dimensions
598
644
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -657,6 +703,7 @@ export function layoutInfra(computed: ComputedInfraModel, expandedNodeIds?: Set<
657
703
  edges: layoutEdges,
658
704
  groups: layoutGroups,
659
705
  options: computed.options,
706
+ direction: computed.direction,
660
707
  width: totalWidth,
661
708
  height: totalHeight,
662
709
  };