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 5 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
9 changes: 9 additions & 0 deletions d2layouts/d2sequence/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package d2sequence

// min horizontal pad for actors, or edge labels, to consider the min distance between them
const MIN_HORIZONTAL_PAD = 50.

const MIN_ACTOR_DISTANCE = 200.

// min vertical distance between edges
const MIN_EDGE_DISTANCE = 100.
100 changes: 100 additions & 0 deletions d2layouts/d2sequence/layout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 := MIN_HORIZONTAL_PAD
edgeYStep := MIN_EDGE_DISTANCE
actorXStep := MIN_ACTOR_DISTANCE
maxActorHeight := 0.

for _, edge := range g.Edges {
edgeYStep = math.Max(edgeYStep, float64(edge.LabelDimensions.Height)+pad)
actorXStep = math.Max(actorXStep, float64(edge.LabelDimensions.Width)+pad)
maxActorHeight = math.Max(maxActorHeight, edge.Src.Height+pad)
maxActorHeight = math.Max(maxActorHeight, edge.Dst.Height+pad)
}

placeActors(g.Objects, maxActorHeight, actorXStep)
routeEdges(g.Edges, maxActorHeight, edgeYStep)
addLifelineEdges(g, g.Objects, edgeYStep)

return nil
}

// placeActors places actors bottom aligned, side by side
func placeActors(actors []*d2graph.Object, maxHeight, xStep float64) {
x := 0.
for _, actors := range actors {
yOffset := maxHeight - actors.Height
actors.TopLeft = geo.NewPoint(x, yOffset)
x += actors.Width + xStep
actors.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 != "" {
isLeftToRight := edge.Src.TopLeft.X < edge.Dst.TopLeft.X
if isLeftToRight {
edge.LabelPosition = go2.Pointer(string(label.OutsideTopCenter))
} else {
edge.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
}
}
}
}

// addLifelineEdges adds a new edge for each actor in the graph that represents the
// edge below the actor showing its lifespan
// ┌──────────────┐
// │ actor │
// └──────┬───────┘
// │
// │ lifeline
// │
// │
func addLifelineEdges(g *d2graph.Graph, actors []*d2graph.Object, yStep float64) {
endY := g.Edges[len(g.Edges)-1].Route[0].Y + yStep
for _, actor := range actors {
actorBottom := actor.Center()
actorBottom.Y = actor.TopLeft.Y + actor.Height
actorLifelineEnd := actor.Center()
actorLifelineEnd.Y = endY
g.Edges = append(g.Edges, &d2graph.Edge{
Attributes: d2graph.Attributes{
Style: d2graph.Style{
StrokeDash: &d2graph.Scalar{Value: "10"},
Stroke: actor.Attributes.Style.Stroke,
StrokeWidth: actor.Attributes.Style.StrokeWidth,
},
},
Src: actor,
SrcArrow: false,
Dst: &d2graph.Object{
ID: actor.ID + fmt.Sprintf("-lifeline-end-%d", go2.StringToIntHash(actor.ID+"-lifeline-end")),
},
DstArrow: false,
Route: []*geo.Point{actorBottom, actorLifelineEnd},
})
}
}
112 changes: 112 additions & 0 deletions d2layouts/d2sequence/layout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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 actors were placed in the expected x order and at y=0
actors := []*d2graph.Object{
g.Objects[0],
g.Objects[1],
}
for i := 1; i < len(actors); i++ {
if actors[i].TopLeft.X < actors[i-1].TopLeft.X {
t.Fatalf("expected actor[%d].TopLeft.X > actor[%d].TopLeft.X", i, i-1)
}
actorBottom := actors[i].TopLeft.Y + actors[i].Height
prevActorBottom := actors[i-1].TopLeft.Y + actors[i-1].Height
if actorBottom != prevActorBottom {
t.Fatalf("expected actor[%d] and actor[%d] to be at the same bottom y", i, i-1)
}
}

nExpectedEdges := nEdges + len(actors)
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 actors
// uses `nEdges` because Layout creates some vertical edges to represent the actor 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 actor", 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 actor", 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 actor 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 actor", i)
}
if edge.Route[1].Y < lastSequenceEdge.Route[0].Y {
t.Fatalf("expected edge[%d] to end after the last sequence edge", i)
}
}
}