@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.
@@ -716,6 +716,20 @@ Home
716
716
  -login-> Login
717
717
  ```
718
718
 
719
+ Arrows can target containers using bracket syntax:
720
+
721
+ ```
722
+ Home
723
+ -> [Port Market]
724
+ [Port Market]
725
+ Shop
726
+ -> [Warehouse]
727
+ [Warehouse]
728
+ Storage
729
+ ```
730
+
731
+ All permutations supported: node→group, group→node, group→group. Brackets required to distinguish group targets from page targets.
732
+
719
733
  ### 11.4 Containers
720
734
 
721
735
  ```
@@ -898,14 +912,26 @@ Nested groups (max depth 2):
898
912
 
899
913
  Group metadata cascades to children (node metadata overrides). Nodes already declared above can be referenced inside groups to assign membership.
900
914
 
901
- ### 13.5 Group-to-Group Edges
915
+ ### 13.5 Group-Targeted Edges
916
+
917
+ Node-to-group and group-to-group edges use bracket syntax `[Group Name]`:
902
918
 
903
919
  ```
904
- [Region A] -> [Region B]
920
+ API -> [Backend]
921
+ [Backend] -> [Frontend]
905
922
  [Region A] <-> [Region B]
906
923
  [Region A] -VPN-> [Region B]
907
924
  ```
908
925
 
926
+ Indented shorthand also supports groups (place arrow directly after group header):
927
+
928
+ ```
929
+ [Backend]
930
+ -> [Frontend]
931
+ DB
932
+ Cache
933
+ ```
934
+
909
935
  ### 13.6 Directives
910
936
 
911
937
  - `direction TB` — top-to-bottom layout (default: `LR`)
@@ -19,6 +19,7 @@ Home
19
19
  -search-> Search
20
20
  -sign in-> Login
21
21
  -my tickets-> My Account
22
+ -> [Browse & Discovery]
22
23
 
23
24
  [Browse & Discovery]
24
25
  Game Schedule
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,6 +5,31 @@
5
5
  import dagre from '@dagrejs/dagre';
6
6
  import type { ParsedBoxesAndLines, BLNode } from './types';
7
7
 
8
+ /**
9
+ * Clip a point at (cx, cy) to the border of a rectangle centered at (cx, cy)
10
+ * with given width/height, along the direction toward (tx, ty).
11
+ * Returns the intersection point on the rectangle border.
12
+ */
13
+ function clipToRectBorder(
14
+ cx: number,
15
+ cy: number,
16
+ w: number,
17
+ h: number,
18
+ tx: number,
19
+ ty: number
20
+ ): { x: number; y: number } {
21
+ const dx = tx - cx;
22
+ const dy = ty - cy;
23
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
24
+ const hw = w / 2;
25
+ const hh = h / 2;
26
+ // Scale factor to reach the border along the direction (dx, dy)
27
+ const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
28
+ const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
29
+ const s = Math.min(sx, sy);
30
+ return { x: cx + dx * s, y: cy + dy * s };
31
+ }
32
+
8
33
  // ── Constants ──────────────────────────────────────────────
9
34
  const NODESEP = 60;
10
35
  const RANKSEP = 100;
@@ -38,6 +63,8 @@ export interface BLLayoutEdge {
38
63
  yOffset: number;
39
64
  parallelCount: number;
40
65
  metadata: Record<string, string>;
66
+ /** True for edges deferred from dagre (group endpoints) — use linear curve */
67
+ deferred?: boolean;
41
68
  }
42
69
 
43
70
  export interface BLLayoutGroup {
@@ -257,17 +284,29 @@ export function layoutBoxesAndLines(
257
284
  let points: { x: number; y: number }[];
258
285
 
259
286
  if (deferredSet.has(i)) {
260
- // Deferred edge (compound parent endpoint) — compute points from node positions
287
+ // Deferred edge (compound parent endpoint) — compute points clipped to border
261
288
  const srcNode = g.node(edge.source);
262
289
  const tgtNode = g.node(edge.target);
263
290
  if (!srcNode || !tgtNode) continue;
264
- const midX = (srcNode.x + tgtNode.x) / 2;
265
- const midY = (srcNode.y + tgtNode.y) / 2;
266
- points = [
267
- { x: srcNode.x, y: srcNode.y },
268
- { x: midX, y: midY },
269
- { x: tgtNode.x, y: tgtNode.y },
270
- ];
291
+ const srcPt = clipToRectBorder(
292
+ srcNode.x,
293
+ srcNode.y,
294
+ srcNode.width,
295
+ srcNode.height,
296
+ tgtNode.x,
297
+ tgtNode.y
298
+ );
299
+ const tgtPt = clipToRectBorder(
300
+ tgtNode.x,
301
+ tgtNode.y,
302
+ tgtNode.width,
303
+ tgtNode.height,
304
+ srcNode.x,
305
+ srcNode.y
306
+ );
307
+ const midX = (srcPt.x + tgtPt.x) / 2;
308
+ const midY = (srcPt.y + tgtPt.y) / 2;
309
+ points = [srcPt, { x: midX, y: midY }, tgtPt];
271
310
  } else {
272
311
  const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
273
312
  points = dagreEdge?.points ?? [];
@@ -294,6 +333,7 @@ export function layoutBoxesAndLines(
294
333
  yOffset: edgeYOffsets[i],
295
334
  parallelCount: edgeParallelCounts[i],
296
335
  metadata: edge.metadata,
336
+ deferred: deferredSet.has(i) || undefined,
297
337
  });
298
338
  }
299
339
 
@@ -94,6 +94,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
94
94
  const nodeLabels = new Set<string>();
95
95
  const groupLabels = new Set<string>();
96
96
  let lastNodeLabel: string | null = null;
97
+ let lastSourceIsGroup = false;
97
98
 
98
99
  // Group stack for nesting
99
100
  interface GroupState {
@@ -393,6 +394,8 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
393
394
 
394
395
  groupLabels.add(label);
395
396
  groupStack.push({ group, indent, depth: currentDepth });
397
+ lastNodeLabel = label;
398
+ lastSourceIsGroup = true;
396
399
  continue;
397
400
  }
398
401
 
@@ -414,7 +417,10 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
414
417
  );
415
418
  continue;
416
419
  }
417
- edgeText = `${lastNodeLabel} ${trimmed}`;
420
+ const sourcePrefix = lastSourceIsGroup
421
+ ? `[${lastNodeLabel}]`
422
+ : lastNodeLabel;
423
+ edgeText = `${sourcePrefix} ${trimmed}`;
418
424
  }
419
425
 
420
426
  const edge = parseEdgeLine(
@@ -442,6 +448,7 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
442
448
  continue;
443
449
  }
444
450
  lastNodeLabel = node.label;
451
+ lastSourceIsGroup = false;
445
452
 
446
453
  const gs = currentGroupState();
447
454
  const isGroupChild = gs && indent > gs.indent;
@@ -478,16 +485,47 @@ export function parseBoxesAndLines(content: string): ParsedBoxesAndLines {
478
485
  result.groups.push(gs.group);
479
486
  }
480
487
 
481
- // Implicit node creation for edge endpoints
488
+ // Validate group references and implicitly create node endpoints
489
+ const validEdges: BLEdge[] = [];
482
490
  for (const edge of result.edges) {
483
- // Skip group references
484
- if (!edge.source.startsWith('__group_')) {
491
+ let valid = true;
492
+
493
+ // Check group references exist
494
+ if (edge.source.startsWith('__group_')) {
495
+ const label = edge.source.slice('__group_'.length);
496
+ const found = [...groupLabels].some(
497
+ (g) => g.toLowerCase() === label.toLowerCase()
498
+ );
499
+ if (!found) {
500
+ result.diagnostics.push(
501
+ makeDgmoError(edge.lineNumber, `Group '[${label}]' not found`)
502
+ );
503
+ valid = false;
504
+ }
505
+ } else {
485
506
  ensureNode(edge.source, edge.lineNumber);
486
507
  }
487
- if (!edge.target.startsWith('__group_')) {
508
+
509
+ if (edge.target.startsWith('__group_')) {
510
+ const label = edge.target.slice('__group_'.length);
511
+ const found = [...groupLabels].some(
512
+ (g) => g.toLowerCase() === label.toLowerCase()
513
+ );
514
+ if (!found) {
515
+ result.diagnostics.push(
516
+ makeDgmoError(edge.lineNumber, `Group '[${label}]' not found`)
517
+ );
518
+ valid = false;
519
+ }
520
+ } else {
488
521
  ensureNode(edge.target, edge.lineNumber);
489
522
  }
523
+
524
+ if (valid) {
525
+ validEdges.push(edge);
526
+ }
490
527
  }
528
+ result.edges = validEdges;
491
529
 
492
530
  // Post-parse: inject default tag metadata and validate tag values
493
531
  if (result.tagGroups.length > 0) {
@@ -540,6 +578,12 @@ function parseNodeLine(
540
578
  };
541
579
  }
542
580
 
581
+ /** Convert `[Group Name]` to `__group_Group Name`, or return as-is for plain nodes */
582
+ function resolveEndpoint(name: string): string {
583
+ const m = name.match(/^\[(.+)\]$/);
584
+ return m ? groupId(m[1].trim()) : name;
585
+ }
586
+
543
587
  /**
544
588
  * Parse an edge line. Supports:
545
589
  * - `Source -> Target`
@@ -548,6 +592,8 @@ function parseNodeLine(
548
592
  * - `Source <-> Target`
549
593
  * - `Source <-label-> Target`
550
594
  * - `Source -label-> Target | key: value`
595
+ *
596
+ * `[Group Name]` in source or target position is resolved to `__group_Group Name`.
551
597
  */
552
598
  function parseEdgeLine(
553
599
  trimmed: string,
@@ -558,7 +604,7 @@ function parseEdgeLine(
558
604
  // Check for bidirectional labeled: `Source <-label-> Target`
559
605
  const biLabeledMatch = trimmed.match(/^(.+?)\s*<-(.+)->\s*(.+)$/);
560
606
  if (biLabeledMatch) {
561
- const source = biLabeledMatch[1].trim();
607
+ const source = resolveEndpoint(biLabeledMatch[1].trim());
562
608
  const label = biLabeledMatch[2].trim();
563
609
  let rest = biLabeledMatch[3].trim();
564
610
 
@@ -582,7 +628,7 @@ function parseEdgeLine(
582
628
 
583
629
  return {
584
630
  source,
585
- target: rest,
631
+ target: resolveEndpoint(rest),
586
632
  label: label || undefined,
587
633
  bidirectional: true,
588
634
  lineNumber: lineNum,
@@ -593,7 +639,7 @@ function parseEdgeLine(
593
639
  // Check for bidirectional plain: `Source <-> Target`
594
640
  const biIdx = trimmed.indexOf('<->');
595
641
  if (biIdx >= 0) {
596
- const source = trimmed.slice(0, biIdx).trim();
642
+ const source = resolveEndpoint(trimmed.slice(0, biIdx).trim());
597
643
  let rest = trimmed.slice(biIdx + 3).trim();
598
644
 
599
645
  let metadata: Record<string, string> = {};
@@ -616,7 +662,7 @@ function parseEdgeLine(
616
662
 
617
663
  return {
618
664
  source,
619
- target: rest,
665
+ target: resolveEndpoint(rest),
620
666
  bidirectional: true,
621
667
  lineNumber: lineNum,
622
668
  metadata,
@@ -626,7 +672,7 @@ function parseEdgeLine(
626
672
  // Check for labeled arrow: `Source -label-> Target`
627
673
  const labeledMatch = trimmed.match(/^(.+?)\s+-(.+)->\s*(.+)$/);
628
674
  if (labeledMatch) {
629
- const source = labeledMatch[1].trim();
675
+ const source = resolveEndpoint(labeledMatch[1].trim());
630
676
  const label = labeledMatch[2].trim();
631
677
  let rest = labeledMatch[3].trim();
632
678
 
@@ -651,7 +697,7 @@ function parseEdgeLine(
651
697
 
652
698
  return {
653
699
  source,
654
- target: rest,
700
+ target: resolveEndpoint(rest),
655
701
  label,
656
702
  bidirectional: false,
657
703
  lineNumber: lineNum,
@@ -664,7 +710,7 @@ function parseEdgeLine(
664
710
  const arrowIdx = trimmed.indexOf('->');
665
711
  if (arrowIdx < 0) return null;
666
712
 
667
- const source = trimmed.slice(0, arrowIdx).trim();
713
+ const source = resolveEndpoint(trimmed.slice(0, arrowIdx).trim());
668
714
  let rest = trimmed.slice(arrowIdx + 2).trim();
669
715
 
670
716
  if (!source || !rest) {
@@ -689,7 +735,7 @@ function parseEdgeLine(
689
735
 
690
736
  return {
691
737
  source,
692
- target: rest,
738
+ target: resolveEndpoint(rest),
693
739
  bidirectional: false,
694
740
  lineNumber: lineNum,
695
741
  metadata,
@@ -5,18 +5,9 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
- import {
9
- LEGEND_HEIGHT,
10
- LEGEND_PILL_PAD,
11
- LEGEND_PILL_FONT_SIZE,
12
- LEGEND_CAPSULE_PAD,
13
- LEGEND_DOT_R,
14
- LEGEND_ENTRY_FONT_SIZE,
15
- LEGEND_ENTRY_DOT_GAP,
16
- LEGEND_ENTRY_TRAIL,
17
- LEGEND_GROUP_GAP,
18
- measureLegendText,
19
- } from '../utils/legend-constants';
8
+ import { LEGEND_HEIGHT } from '../utils/legend-constants';
9
+ import { renderLegendD3 } from '../utils/legend-d3';
10
+ import type { LegendConfig, LegendState } from '../utils/legend-types';
20
11
  import {
21
12
  TITLE_FONT_SIZE,
22
13
  TITLE_FONT_WEIGHT,
@@ -62,6 +53,12 @@ const lineGeneratorTB = d3Shape
62
53
  .y((d) => d.y)
63
54
  .curve(d3Shape.curveMonotoneY);
64
55
 
56
+ const lineGeneratorLinear = d3Shape
57
+ .line<{ x: number; y: number }>()
58
+ .x((d) => d.x)
59
+ .y((d) => d.y)
60
+ .curve(d3Shape.curveLinear);
61
+
65
62
  // ── Text fitting ───────────────────────────────────────────
66
63
 
67
64
  function splitCamelCase(word: string): string[] {
@@ -526,15 +523,15 @@ export function renderBoxesAndLines(
526
523
  edgeGroups.set(i, edgeG as unknown as D3G);
527
524
 
528
525
  const markerId = `bl-arrow-${color.replace('#', '')}`;
526
+ const gen = le.deferred
527
+ ? lineGeneratorLinear
528
+ : parsed.direction === 'TB'
529
+ ? lineGeneratorTB
530
+ : lineGeneratorLR;
529
531
  const path = edgeG
530
532
  .append('path')
531
533
  .attr('class', 'bl-edge')
532
- .attr(
533
- 'd',
534
- (parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR)(
535
- points
536
- ) ?? ''
537
- )
534
+ .attr('d', gen(points) ?? '')
538
535
  .attr('fill', 'none')
539
536
  .attr('stroke', color)
540
537
  .attr('stroke-width', EDGE_STROKE_WIDTH)
@@ -709,126 +706,25 @@ export function renderBoxesAndLines(
709
706
 
710
707
  // ── Render legend ──────────────────────────────────────
711
708
  if (parsed.tagGroups.length > 0) {
712
- renderLegend(svg, parsed, palette, isDark, activeGroup, width, titleOffset);
713
- }
714
- }
715
-
716
- // ── Legend ──────────────────────────────────────────────────
717
-
718
- function renderLegend(
719
- svg: D3Svg,
720
- parsed: ParsedBoxesAndLines,
721
- palette: PaletteColors,
722
- isDark: boolean,
723
- activeGroup: string | null,
724
- svgWidth: number,
725
- titleOffset: number
726
- ): void {
727
- const groupBg = isDark
728
- ? mix(palette.surface, palette.bg, 50)
729
- : mix(palette.surface, palette.bg, 30);
730
- const pillBorder = mix(palette.textMuted, palette.bg, 50);
731
-
732
- // ── Pre-compute total legend width for centering ──
733
- let totalW = 0;
734
- for (const tg of parsed.tagGroups) {
735
- const isActive = activeGroup?.toLowerCase() === tg.name.toLowerCase();
736
- totalW +=
737
- measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
738
- if (isActive) {
739
- totalW += 6;
740
- for (const entry of tg.entries) {
741
- totalW +=
742
- LEGEND_DOT_R * 2 +
743
- LEGEND_ENTRY_DOT_GAP +
744
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
745
- LEGEND_ENTRY_TRAIL;
746
- }
747
- }
748
- totalW += LEGEND_GROUP_GAP;
749
- }
750
-
751
- const legendX = Math.max(LEGEND_CAPSULE_PAD, (svgWidth - totalW) / 2);
752
- const legendY = titleOffset + 4;
753
- const legendG = svg
754
- .append('g')
755
- .attr('transform', `translate(${legendX},${legendY})`);
756
-
757
- let x = 0;
758
-
759
- // ── Tag group pills (collapsed when inactive, expanded when active) ──
760
- for (const tg of parsed.tagGroups) {
761
- const isActiveGroup = activeGroup?.toLowerCase() === tg.name.toLowerCase();
762
-
763
- const groupG = legendG
709
+ const legendConfig: LegendConfig = {
710
+ groups: parsed.tagGroups,
711
+ position: { placement: 'top-center', titleRelation: 'below-title' },
712
+ mode: 'fixed',
713
+ };
714
+ const legendState: LegendState = { activeGroup };
715
+ const legendG = svg
764
716
  .append('g')
765
- .attr('class', 'bl-legend-group')
766
- .attr('data-legend-group', tg.name.toLowerCase())
767
- .style('cursor', 'pointer');
768
-
769
- // Group name pill
770
- const nameW =
771
- measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
772
- const tagPill = groupG
773
- .append('rect')
774
- .attr('x', x)
775
- .attr('y', 0)
776
- .attr('width', nameW)
777
- .attr('height', LEGEND_HEIGHT)
778
- .attr('rx', LEGEND_HEIGHT / 2)
779
- .attr('fill', groupBg);
780
-
781
- if (isActiveGroup) {
782
- tagPill.attr('stroke', pillBorder).attr('stroke-width', 0.75);
783
- }
784
-
785
- groupG
786
- .append('text')
787
- .attr('x', x + nameW / 2)
788
- .attr('y', LEGEND_HEIGHT / 2)
789
- .attr('text-anchor', 'middle')
790
- .attr('dominant-baseline', 'central')
791
- .attr('font-size', LEGEND_PILL_FONT_SIZE)
792
- .attr('font-weight', 500)
793
- .attr('fill', isActiveGroup ? palette.text : palette.textMuted)
794
- .attr('pointer-events', 'none')
795
- .text(tg.name);
796
-
797
- x += nameW;
798
-
799
- // Entries — only rendered when this group is active
800
- if (isActiveGroup) {
801
- x += 6;
802
- for (const entry of tg.entries) {
803
- const entryColor = entry.color || palette.textMuted;
804
- const ew = measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE);
805
-
806
- const entryG = groupG
807
- .append('g')
808
- .attr('data-legend-entry', entry.value.toLowerCase())
809
- .style('cursor', 'pointer');
810
-
811
- entryG
812
- .append('circle')
813
- .attr('cx', x + LEGEND_DOT_R)
814
- .attr('cy', LEGEND_HEIGHT / 2)
815
- .attr('r', LEGEND_DOT_R)
816
- .attr('fill', entryColor);
817
-
818
- entryG
819
- .append('text')
820
- .attr('x', x + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
821
- .attr('y', LEGEND_HEIGHT / 2)
822
- .attr('dominant-baseline', 'central')
823
- .attr('font-size', LEGEND_ENTRY_FONT_SIZE)
824
- .attr('fill', palette.textMuted)
825
- .text(entry.value);
826
-
827
- x += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + ew + LEGEND_ENTRY_TRAIL;
828
- }
829
- }
830
-
831
- x += LEGEND_GROUP_GAP;
717
+ .attr('transform', `translate(0,${titleOffset + 4})`);
718
+ renderLegendD3(
719
+ legendG,
720
+ legendConfig,
721
+ legendState,
722
+ palette,
723
+ isDark,
724
+ undefined,
725
+ width
726
+ );
727
+ legendG.selectAll('[data-legend-group]').classed('bl-legend-group', true);
832
728
  }
833
729
  }
834
730