diff --git a/cmd/d2/main.go b/cmd/d2/main.go index a5e5c39e2..b6b4d38ea 100644 --- a/cmd/d2/main.go +++ b/cmd/d2/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "os/exec" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( "github.com/spf13/pflag" "oss.terrastruct.com/d2" + "oss.terrastruct.com/d2/d2layouts/d2sequence" "oss.terrastruct.com/d2/d2plugin" "oss.terrastruct.com/d2/d2renderers/d2svg" "oss.terrastruct.com/d2/d2renderers/textmeasure" @@ -187,8 +189,13 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, theme return nil, err } + layout := plugin.Layout + // TODO: remove, this is just a feature flag to test sequence diagrams as we work on them + if os.Getenv("D2_SEQUENCE") == "1" { + layout = d2sequence.Layout + } d, err := d2.Compile(ctx, string(input), &d2.CompileOptions{ - Layout: plugin.Layout, + Layout: layout, Ruler: ruler, ThemeID: themeID, }) diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go new file mode 100644 index 000000000..2c182d3fe --- /dev/null +++ b/d2layouts/d2sequence/layout.go @@ -0,0 +1,101 @@ +package d2sequence + +import ( + "context" + "fmt" + "math" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/go2" + "oss.terrastruct.com/d2/lib/label" +) + +func Layout(ctx context.Context, g *d2graph.Graph) (err error) { + pad := 50. // 2 * 25 + edgeYStep := 100. + objectXStep := 200. + maxObjectHeight := 0. + + var objectsInOrder []*d2graph.Object + seen := make(map[*d2graph.Object]struct{}) + for _, edge := range g.Edges { + if _, exists := seen[edge.Src]; !exists { + seen[edge.Src] = struct{}{} + objectsInOrder = append(objectsInOrder, edge.Src) + } + if _, exists := seen[edge.Dst]; !exists { + seen[edge.Dst] = struct{}{} + objectsInOrder = append(objectsInOrder, edge.Dst) + } + + edgeYStep = math.Max(edgeYStep, float64(edge.LabelDimensions.Height)+pad) + objectXStep = math.Max(objectXStep, float64(edge.LabelDimensions.Width)+pad) + maxObjectHeight = math.Max(maxObjectHeight, edge.Src.Height+pad) + maxObjectHeight = math.Max(maxObjectHeight, edge.Dst.Height+pad) + } + + placeObjects(objectsInOrder, maxObjectHeight, objectXStep) + // edges are placed in the order users define them + routeEdges(g.Edges, maxObjectHeight, edgeYStep) + addLifelineEdges(g, objectsInOrder, edgeYStep) + + return nil +} + +// placeObjects places objects side by side +func placeObjects(objectsInOrder []*d2graph.Object, maxHeight, xStep float64) { + x := 0. + for _, obj := range objectsInOrder { + yDiff := maxHeight - obj.Height + obj.TopLeft = geo.NewPoint(x, yDiff/2.) + x += obj.Width + xStep + obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter)) + } +} + +// routeEdges routes horizontal edges from Src to Dst +func routeEdges(edgesInOrder []*d2graph.Edge, startY, yStep float64) { + edgeY := startY + yStep // in case the first edge has a tall label + for _, edge := range edgesInOrder { + start := edge.Src.Center() + start.Y = edgeY + end := edge.Dst.Center() + end.Y = edgeY + edge.Route = []*geo.Point{start, end} + edgeY += yStep + + if edge.Attributes.Label.Value != "" { + // TODO: consider label right-to-left + edge.LabelPosition = go2.Pointer(string(label.OutsideTopCenter)) + } + } +} + +func addLifelineEdges(g *d2graph.Graph, objectsInOrder []*d2graph.Object, yStep float64) { + endY := g.Edges[len(g.Edges)-1].Route[0].Y + yStep + for _, obj := range objectsInOrder { + objBottom := obj.Center() + objBottom.Y = obj.TopLeft.Y + obj.Height + objLifelineEnd := obj.Center() + objLifelineEnd.Y = endY + g.Edges = append(g.Edges, &d2graph.Edge{ + Attributes: d2graph.Attributes{ + Style: d2graph.Style{ + StrokeDash: &d2graph.Scalar{ + Value: "10", + }, + Stroke: obj.Attributes.Style.Stroke, + StrokeWidth: obj.Attributes.Style.StrokeWidth, + }, + }, + Src: obj, + SrcArrow: false, + Dst: &d2graph.Object{ + ID: obj.ID + fmt.Sprintf("-lifeline-end-%d", go2.StringToIntHash(obj.ID+"-lifeline-end")), + }, + DstArrow: false, + Route: []*geo.Point{objBottom, objLifelineEnd}, + }) + } +} diff --git a/d2layouts/d2sequence/layout_test.go b/d2layouts/d2sequence/layout_test.go new file mode 100644 index 000000000..4ffb0237f --- /dev/null +++ b/d2layouts/d2sequence/layout_test.go @@ -0,0 +1,110 @@ +package d2sequence + +import ( + "context" + "testing" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/log" +) + +func TestLayout(t *testing.T) { + g := d2graph.NewGraph(nil) + g.Objects = []*d2graph.Object{ + { + ID: "Alice", + Box: geo.NewBox(nil, 100, 100), + }, + { + ID: "Bob", + Box: geo.NewBox(nil, 30, 30), + }, + } + + g.Edges = []*d2graph.Edge{ + { + Src: g.Objects[0], + Dst: g.Objects[1], + }, + { + Src: g.Objects[1], + Dst: g.Objects[0], + }, + { + Src: g.Objects[0], + Dst: g.Objects[1], + }, + { + Src: g.Objects[1], + Dst: g.Objects[0], + }, + } + nEdges := len(g.Edges) + + ctx := log.WithTB(context.Background(), t, nil) + Layout(ctx, g) + + // asserts that objects were placed in the expected x order and at y=0 + objectsOrder := []*d2graph.Object{ + g.Objects[0], + g.Objects[1], + } + for i := 1; i < len(objectsOrder); i++ { + if objectsOrder[i].TopLeft.X < objectsOrder[i-1].TopLeft.X { + t.Fatalf("expected object[%d].TopLeft.X > object[%d].TopLeft.X", i, i-1) + } + if objectsOrder[i].Center().Y != objectsOrder[i-1].Center().Y { + t.Fatalf("expected object[%d] and object[%d] to be at the same center y", i, i-1) + } + } + + nExpectedEdges := nEdges + len(objectsOrder) + if len(g.Edges) != nExpectedEdges { + t.Fatalf("expected %d edges, got %d", nExpectedEdges, len(g.Edges)) + } + + // assert that edges were placed in y order and have the endpoints at their objects + // uses `nEdges` because Layout creates some vertical edges to represent the object lifeline + for i := 0; i < nEdges; i++ { + edge := g.Edges[i] + if len(edge.Route) != 2 { + t.Fatalf("expected edge[%d] to have only 2 points", i) + } + if edge.Route[0].Y != edge.Route[1].Y { + t.Fatalf("expected edge[%d] to be a horizontal line", i) + } + if edge.Route[0].X != edge.Src.Center().X { + t.Fatalf("expected edge[%d] source endpoint to be at the middle of the source object", i) + } + if edge.Route[1].X != edge.Dst.Center().X { + t.Fatalf("expected edge[%d] target endpoint to be at the middle of the target object", i) + } + if i > 0 { + prevEdge := g.Edges[i-1] + if edge.Route[0].Y < prevEdge.Route[0].Y { + t.Fatalf("expected edge[%d].TopLeft.Y > edge[%d].TopLeft.Y", i, i-1) + } + } + } + + lastSequenceEdge := g.Edges[nEdges-1] + for i := nEdges; i < nExpectedEdges; i++ { + edge := g.Edges[i] + if len(edge.Route) != 2 { + t.Fatalf("expected edge[%d] to have only 2 points", i) + } + if edge.Route[0].X != edge.Route[1].X { + t.Fatalf("expected edge[%d] to be a vertical line", i) + } + if edge.Route[0].X != edge.Src.Center().X { + t.Fatalf("expected edge[%d] x to be at the object center", i) + } + if edge.Route[0].Y != edge.Src.Height+edge.Src.TopLeft.Y { + t.Fatalf("expected edge[%d] to start at the bottom of the source object", i) + } + if edge.Route[1].Y < lastSequenceEdge.Route[0].Y { + t.Fatalf("expected edge[%d] to end after the last sequence edge", i) + } + } +}