diff --git a/d2compiler/compile.go b/d2compiler/compile.go index 66f6497a54..fdef7deb28 100644 --- a/d2compiler/compile.go +++ b/d2compiler/compile.go @@ -1254,6 +1254,9 @@ func (c *compiler) validatePositionsCompatibility(g *d2graph.Graph) { if o.Parent.GridColumns != nil || o.Parent.GridRows != nil { c.errorf(pos.MapKey, `position keywords cannot be used with grids`) } + if o.Parent.IsCycleDiagram() { + c.errorf(pos.MapKey, `position keywords cannot be used inside shape "cycle"`) + } } } } @@ -1282,6 +1285,22 @@ func (c *compiler) validateEdges(g *d2graph.Graph) { c.errorf(edge.GetAstEdge(), "edge from grid cell %#v cannot enter itself", edge.Dst.AbsID()) continue } + if edge.Src.IsCycleDiagram() && edge.Dst.IsDescendantOf(edge.Src) { + c.errorf(edge.GetAstEdge(), "edge from cycle diagram %#v cannot enter itself", edge.Src.AbsID()) + continue + } + if edge.Dst.IsCycleDiagram() && edge.Src.IsDescendantOf(edge.Dst) { + c.errorf(edge.GetAstEdge(), "edge from cycle diagram %#v cannot enter itself", edge.Dst.AbsID()) + continue + } + if edge.Src.Parent.IsCycleDiagram() && edge.Dst.IsDescendantOf(edge.Src) { + c.errorf(edge.GetAstEdge(), "edge from cycle node %#v cannot enter itself", edge.Src.AbsID()) + continue + } + if edge.Dst.Parent.IsCycleDiagram() && edge.Src.IsDescendantOf(edge.Dst) { + c.errorf(edge.GetAstEdge(), "edge from cycle node %#v cannot enter itself", edge.Dst.AbsID()) + continue + } if edge.Src.IsSequenceDiagram() && edge.Dst.IsDescendantOf(edge.Src) { c.errorf(edge.GetAstEdge(), "edge from sequence diagram %#v cannot enter itself", edge.Src.AbsID()) continue diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go index aacae33711..dc9a36026b 100644 --- a/d2compiler/compile_test.go +++ b/d2compiler/compile_test.go @@ -4091,6 +4091,28 @@ svc_1.t2 -> b: do with B } } +func TestCompileCycleShape(t *testing.T) { + g, _, err := d2compiler.Compile("d2/testdata/d2compiler/TestCompileCycleShape.d2", strings.NewReader(` +x: { + shape: cycle + a + b +} +`), nil) + if err != nil { + t.Fatal(err) + } + if len(g.Objects) != 3 { + t.Fatalf("expected 3 objects: %#v", g.Objects) + } + if g.Objects[0].Shape.Value != d2target.ShapeCycle { + t.Fatalf("expected g.Objects[0].Shape.Value to be cycle: %#v", g.Objects[0].Shape.Value) + } + if !g.Objects[0].IsCycleDiagram() { + t.Fatalf("expected x to be a cycle diagram") + } +} + func TestCompile2(t *testing.T) { t.Parallel() diff --git a/d2graph/cycle_diagram.go b/d2graph/cycle_diagram.go new file mode 100644 index 0000000000..828b56e048 --- /dev/null +++ b/d2graph/cycle_diagram.go @@ -0,0 +1,7 @@ +package d2graph + +import "oss.terrastruct.com/d2/d2target" + +func (obj *Object) IsCycleDiagram() bool { + return obj != nil && obj.Shape.Value == d2target.ShapeCycle +} diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index dbd935eb8e..b1258cc699 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -545,7 +545,7 @@ func (obj *Object) GetFill() string { return color.N7 } - if shape == "" || strings.EqualFold(shape, d2target.ShapeSquare) || strings.EqualFold(shape, d2target.ShapeCircle) || strings.EqualFold(shape, d2target.ShapeOval) || strings.EqualFold(shape, d2target.ShapeRectangle) || strings.EqualFold(shape, d2target.ShapeHierarchy) { + if shape == "" || strings.EqualFold(shape, d2target.ShapeSquare) || strings.EqualFold(shape, d2target.ShapeCircle) || strings.EqualFold(shape, d2target.ShapeOval) || strings.EqualFold(shape, d2target.ShapeRectangle) || strings.EqualFold(shape, d2target.ShapeCycle) || strings.EqualFold(shape, d2target.ShapeHierarchy) { if level == 1 { if !obj.IsContainer() { return color.B6 @@ -675,7 +675,7 @@ func (obj *Object) Text() *d2target.MText { if obj.OuterSequenceDiagram() == nil { // Note: during grid layout when children are temporarily removed `IsContainer` is false - if (obj.IsContainer() || obj.IsGridDiagram()) && obj.Shape.Value != "text" { + if (obj.IsContainer() || obj.IsGridDiagram() || obj.IsCycleDiagram()) && obj.Shape.Value != "text" { fontSize = obj.Level().LabelSize() } } else { diff --git a/d2layouts/d2cycle/cycle_diagram.go b/d2layouts/d2cycle/cycle_diagram.go new file mode 100644 index 0000000000..c743737d18 --- /dev/null +++ b/d2layouts/d2cycle/cycle_diagram.go @@ -0,0 +1,35 @@ +package d2cycle + +import ( + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" +) + +type cycleDiagram struct { + objects []*d2graph.Object + edges []*d2graph.Edge + + width float64 + height float64 +} + +func newCycleDiagram(root *d2graph.Object) *cycleDiagram { + cd := &cycleDiagram{ + objects: root.ChildrenArray, + } + + for _, o := range cd.objects { + o.TopLeft = geo.NewPoint(0, 0) + } + + return cd +} + +func (cd *cycleDiagram) shift(dx, dy float64) { + for _, obj := range cd.objects { + obj.MoveWithDescendants(dx, dy) + } + for _, e := range cd.edges { + e.Move(dx, dy) + } +} diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go new file mode 100644 index 0000000000..b0f3e2cbdf --- /dev/null +++ b/d2layouts/d2cycle/layout.go @@ -0,0 +1,276 @@ +package d2cycle + +import ( + "context" + "math" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/util-go/go2" +) + +const ( + CONTAINER_PADDING = 60 + DEFAULT_GAP = 40 +) + +func Layout(ctx context.Context, g *d2graph.Graph) error { + obj := g.Root + + cd := layoutCycle(obj) + + if obj.HasLabel() && obj.LabelPosition == nil { + obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String()) + } + if obj.Icon != nil && obj.IconPosition == nil { + obj.IconPosition = go2.Pointer(label.InsideTopLeft.String()) + } + + if obj.Box != nil { + sizeCycleContainer(obj, cd) + } + + for _, e := range g.Edges { + if !e.Src.Parent.IsDescendantOf(obj) && !e.Dst.Parent.IsDescendantOf(obj) { + continue + } + + cd.edges = append(cd.edges, e) + + if e.Src.Parent != obj || e.Dst.Parent != obj { + continue + } + + e.Route = []*geo.Point{e.Src.Center(), e.Dst.Center()} + e.TraceToShape(e.Route, 0, 1) + if e.Label.Value != "" { + e.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String()) + } + } + + if g.Root.IsCycleDiagram() && len(g.Root.ChildrenArray) != 0 { + g.Root.TopLeft = geo.NewPoint(0, 0) + } + + if g.RootLevel > 0 { + cd.shift( + obj.TopLeft.X+CONTAINER_PADDING, + obj.TopLeft.Y+CONTAINER_PADDING, + ) + } + return nil +} + +func layoutCycle(root *d2graph.Object) *cycleDiagram { + cd := newCycleDiagram(root) + + for _, o := range cd.objects { + positionedLabel := false + if o.Icon != nil && o.IconPosition == nil { + if len(o.ChildrenArray) > 0 { + o.IconPosition = go2.Pointer(label.OutsideTopLeft.String()) + if o.LabelPosition == nil { + o.LabelPosition = go2.Pointer(label.OutsideTopRight.String()) + positionedLabel = true + } + } else { + o.IconPosition = go2.Pointer(label.InsideMiddleCenter.String()) + } + } + if !positionedLabel && o.HasLabel() && o.LabelPosition == nil { + if len(o.ChildrenArray) > 0 { + o.LabelPosition = go2.Pointer(label.OutsideTopCenter.String()) + } else if o.HasOutsideBottomLabel() { + o.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String()) + } else if o.Icon != nil { + o.LabelPosition = go2.Pointer(label.InsideTopCenter.String()) + } else { + o.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String()) + } + } + } + + revertAdjustments := cd.sizeForOutsideLabels() + cd.layout() + revertAdjustments() + + return cd +} + +func (cd *cycleDiagram) layout() { + count := len(cd.objects) + if count == 0 { + return + } + + if count == 1 { + obj := cd.objects[0] + obj.MoveWithDescendantsTo(0, 0) + cd.width = obj.Width + cd.height = obj.Height + return + } + + var maxWidth, maxHeight float64 + for _, obj := range cd.objects { + maxWidth = math.Max(maxWidth, obj.Width) + maxHeight = math.Max(maxHeight, obj.Height) + } + + maxDimension := math.Max(maxWidth, maxHeight) + radius := (maxDimension + DEFAULT_GAP) / (2 * math.Sin(math.Pi/float64(count))) + radius = math.Max(radius, maxDimension) + center := geo.NewPoint(radius+maxWidth/2, radius+maxHeight/2) + + minX, minY := math.Inf(1), math.Inf(1) + maxX, maxY := math.Inf(-1), math.Inf(-1) + + for i, obj := range cd.objects { + angle := -math.Pi/2 + 2*math.Pi*float64(i)/float64(count) + x := center.X + radius*math.Cos(angle) - obj.Width/2 + y := center.Y + radius*math.Sin(angle) - obj.Height/2 + + obj.MoveWithDescendantsTo(x, y) + + minX = math.Min(minX, obj.TopLeft.X) + minY = math.Min(minY, obj.TopLeft.Y) + maxX = math.Max(maxX, obj.TopLeft.X+obj.Width) + maxY = math.Max(maxY, obj.TopLeft.Y+obj.Height) + } + + if minX != 0 || minY != 0 { + cd.shift(-minX, -minY) + maxX -= minX + maxY -= minY + } + + cd.width = maxX + cd.height = maxY +} + +func sizeCycleContainer(obj *d2graph.Object, cd *cycleDiagram) { + contentWidth, contentHeight := cd.width, cd.height + + var labelPosition, iconPosition label.Position + if obj.LabelPosition != nil { + labelPosition = label.FromString(*obj.LabelPosition) + } + if obj.IconPosition != nil { + iconPosition = label.FromString(*obj.IconPosition) + } + + _, padding := obj.Spacing() + + var labelWidth, labelHeight float64 + if obj.LabelDimensions.Width > 0 { + labelWidth = float64(obj.LabelDimensions.Width) + 2*label.PADDING + } + if obj.LabelDimensions.Height > 0 { + labelHeight = float64(obj.LabelDimensions.Height) + 2*label.PADDING + } + + if labelWidth > 0 { + switch labelPosition { + case label.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight, + label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight, + label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight, + label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight: + overflow := labelWidth - contentWidth + if overflow > 0 { + padding.Left += overflow / 2 + padding.Right += overflow / 2 + } + } + } + if labelHeight > 0 { + switch labelPosition { + case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom, + label.InsideMiddleLeft, label.InsideMiddleCenter, label.InsideMiddleRight, + label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom: + overflow := labelHeight - contentHeight + if overflow > 0 { + padding.Top += overflow / 2 + padding.Bottom += overflow / 2 + } + } + } + + if iconPosition == label.InsideTopLeft && labelPosition == label.InsideTopCenter { + iconSize := float64(d2target.MAX_ICON_SIZE) + 2*label.PADDING + padding.Left = math.Max(padding.Left, iconSize) + padding.Right = math.Max(padding.Right, iconSize) + minWidth := 2*iconSize + float64(obj.LabelDimensions.Width) + 2*label.PADDING + overflow := minWidth - contentWidth + if overflow > 0 { + padding.Left = math.Max(padding.Left, overflow/2) + padding.Right = math.Max(padding.Right, overflow/2) + } + } + + padding.Top = math.Max(padding.Top, CONTAINER_PADDING) + padding.Bottom = math.Max(padding.Bottom, CONTAINER_PADDING) + padding.Left = math.Max(padding.Left, CONTAINER_PADDING) + padding.Right = math.Max(padding.Right, CONTAINER_PADDING) + + totalWidth := padding.Left + contentWidth + padding.Right + totalHeight := padding.Top + contentHeight + padding.Bottom + obj.SizeToContent(totalWidth, totalHeight, 0, 0) + + s := obj.ToShape() + innerTL := s.GetInsidePlacement(totalWidth, totalHeight, 0, 0) + innerBox := s.GetInnerBox() + var resizeDx, resizeDy float64 + if innerBox.Width > totalWidth { + resizeDx = (innerBox.Width - totalWidth) / 2 + } + if innerBox.Height > totalHeight { + resizeDy = (innerBox.Height - totalHeight) / 2 + } + + dx := -CONTAINER_PADDING + innerTL.X + padding.Left + resizeDx + dy := -CONTAINER_PADDING + innerTL.Y + padding.Top + resizeDy + if dx != 0 || dy != 0 { + cd.shift(dx, dy) + } +} + +func (cd *cycleDiagram) sizeForOutsideLabels() (revert func()) { + margins := make(map[*d2graph.Object]geo.Spacing) + + for _, o := range cd.objects { + margin := o.GetMargin() + margins[o] = margin + + o.Height += margin.Top + margin.Bottom + o.Width += margin.Left + margin.Right + } + + return func() { + for _, o := range cd.objects { + m, has := margins[o] + if !has { + continue + } + dy := m.Top + m.Bottom + dx := m.Left + m.Right + o.Height -= dy + o.Width -= dx + + margin := o.GetMargin() + marginX := margin.Left + margin.Right + marginY := margin.Top + margin.Bottom + if marginX < dx { + o.Width += dx - marginX + } + if marginY < dy { + o.Height += dy - marginY + } + + if margin.Left > 0 || margin.Top > 0 { + o.MoveWithDescendants(margin.Left, margin.Top) + } + } + } +} diff --git a/d2layouts/d2cycle/layout_test.go b/d2layouts/d2cycle/layout_test.go new file mode 100644 index 0000000000..f7f5e47529 --- /dev/null +++ b/d2layouts/d2cycle/layout_test.go @@ -0,0 +1,89 @@ +package d2cycle_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "oss.terrastruct.com/d2/d2ast" + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2layouts" + "oss.terrastruct.com/d2/d2layouts/d2cycle" + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/log" +) + +func TestLayoutOrdersObjectsAroundCycle(t *testing.T) { + g := d2graph.NewGraph() + g.Root.Shape = d2graph.Scalar{Value: d2target.ShapeCycle} + g.Root.Box = &geo.Box{} + + a := addCycleNode(g, "a") + b := addCycleNode(g, "b") + c := addCycleNode(g, "c") + d := addCycleNode(g, "d") + g.Edges = []*d2graph.Edge{{Src: a, Dst: c}} + + ctx := log.WithTB(context.Background(), t) + require.NoError(t, d2cycle.Layout(ctx, g)) + + assert.Equal(t, geo.NewPoint(100, 0), a.TopLeft) + assert.Equal(t, geo.NewPoint(200, 100), b.TopLeft) + assert.Equal(t, geo.NewPoint(100, 200), c.TopLeft) + assert.Equal(t, geo.NewPoint(0, 100), d.TopLeft) + assert.Equal(t, 420., g.Root.Width) + assert.Equal(t, 420., g.Root.Height) + require.Len(t, g.Edges[0].Route, 2) +} + +func TestLayoutNestedCycle(t *testing.T) { + g := d2graph.NewGraph() + + cycle := g.Root.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString("cluster")}) + cycle.Shape = d2graph.Scalar{Value: d2target.ShapeCycle} + cycle.Box = &geo.Box{} + + a := addCycleChild(g, cycle, "a") + b := addCycleChild(g, cycle, "b") + c := addCycleChild(g, cycle, "c") + d := addCycleChild(g, cycle, "d") + g.Edges = []*d2graph.Edge{{Src: a, Dst: c}} + + ctx := log.WithTB(context.Background(), t) + err := d2layouts.LayoutNested(ctx, g, d2layouts.NestedGraphInfo(g.Root), func(ctx context.Context, g *d2graph.Graph) error { + for _, obj := range g.Objects { + if obj.TopLeft == nil { + obj.TopLeft = geo.NewPoint(0, 0) + } + } + return nil + }, d2layouts.DefaultRouter) + require.NoError(t, err) + + assert.Equal(t, 420., cycle.Width) + assert.Equal(t, 420., cycle.Height) + assert.Equal(t, geo.NewPoint(160, 60), a.TopLeft) + assert.Equal(t, geo.NewPoint(260, 160), b.TopLeft) + assert.Equal(t, geo.NewPoint(160, 260), c.TopLeft) + assert.Equal(t, geo.NewPoint(60, 160), d.TopLeft) + require.Len(t, g.Edges[0].Route, 2) +} + +func addCycleNode(g *d2graph.Graph, id string) *d2graph.Object { + obj := g.Root.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString(id)}) + obj.Box = geo.NewBox(nil, 100, 100) + obj.Width = 100 + obj.Height = 100 + return obj +} + +func addCycleChild(g *d2graph.Graph, parent *d2graph.Object, id string) *d2graph.Object { + obj := parent.EnsureChild([]d2ast.String{d2ast.FlatUnquotedString(id)}) + obj.Box = geo.NewBox(nil, 100, 100) + obj.Width = 100 + obj.Height = 100 + return obj +} diff --git a/d2layouts/d2layouts.go b/d2layouts/d2layouts.go index c0d41e3973..3b0c5c87bc 100644 --- a/d2layouts/d2layouts.go +++ b/d2layouts/d2layouts.go @@ -9,6 +9,7 @@ import ( "strings" "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2layouts/d2cycle" "oss.terrastruct.com/d2/d2layouts/d2grid" "oss.terrastruct.com/d2/d2layouts/d2near" "oss.terrastruct.com/d2/d2layouts/d2sequence" @@ -24,6 +25,7 @@ type DiagramType string const ( DefaultGraphType DiagramType = "" ConstantNearGraph DiagramType = "constant-near" + CycleDiagram DiagramType = "cycle-diagram" GridDiagram DiagramType = "grid-diagram" SequenceDiagram DiagramType = "sequence-diagram" ) @@ -96,12 +98,12 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co curr := queue[0] queue = queue[1:] - isGridCellContainer := graphInfo.DiagramType == GridDiagram && + isCycleOrGridCellContainer := (graphInfo.DiagramType == CycleDiagram || graphInfo.DiagramType == GridDiagram) && curr.IsContainer() && curr.Parent == g.Root gi := NestedGraphInfo(curr) - if isGridCellContainer && gi.isDefault() { - // if we are in a grid diagram, and our children have descendants + if isCycleOrGridCellContainer && gi.isDefault() { + // if we are in a grid/cycle diagram, and our children have descendants // we need to run layout on them first, even if they are not special diagram types // First we extract the grid cell container as a nested graph with includeSelf=true @@ -254,6 +256,12 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co return err } + case CycleDiagram: + log.Debug(ctx, "layout cycle", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString())) + if err = d2cycle.Layout(ctx, g); err != nil { + return err + } + case SequenceDiagram: log.Debug(ctx, "layout sequence", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString())) err = d2sequence.Layout(ctx, g, coreLayout) @@ -362,6 +370,8 @@ func NestedGraphInfo(obj *d2graph.Object) (gi GraphInfo) { } if obj.IsSequenceDiagram() { gi.DiagramType = SequenceDiagram + } else if obj.IsCycleDiagram() { + gi.DiagramType = CycleDiagram } else if obj.IsGridDiagram() { gi.DiagramType = GridDiagram } diff --git a/d2target/d2target.go b/d2target/d2target.go index 63fcfacbf3..eeb364899d 100644 --- a/d2target/d2target.go +++ b/d2target/d2target.go @@ -1072,6 +1072,7 @@ const ( ShapeSQLTable = "sql_table" ShapeImage = "image" ShapeSequenceDiagram = "sequence_diagram" + ShapeCycle = "cycle" ShapeHierarchy = "hierarchy" ) @@ -1100,6 +1101,7 @@ var Shapes = []string{ ShapeSQLTable, ShapeImage, ShapeSequenceDiagram, + ShapeCycle, ShapeHierarchy, } @@ -1170,6 +1172,7 @@ var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{ ShapeSQLTable: shape.TABLE_TYPE, ShapeImage: shape.IMAGE_TYPE, ShapeSequenceDiagram: shape.SQUARE_TYPE, + ShapeCycle: shape.SQUARE_TYPE, ShapeHierarchy: shape.SQUARE_TYPE, }