Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds basic sequence diagrams behind a layout flag #219

Merged
merged 7 commits into from
Nov 28, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/d2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
Expand All @@ -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"
Expand Down Expand Up @@ -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,
})
Expand Down
110 changes: 110 additions & 0 deletions d2layouts/d2sequence/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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))
}
}
}

// addLifelineEdges adds a new edge for each object in the graph that represents the
// edge below he object showing its lifespan
// ┌──────────────┐
// │ object │
// └──────┬───────┘
// │
// │ lifeline
// │
// │
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},
})
}
}
110 changes: 110 additions & 0 deletions d2layouts/d2sequence/layout_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}