diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4e90106c..384cfa3f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/upload-artifact@v3 if: always() with: - name: d2chaos-test + name: d2chaos path: ./d2chaos/out nofixups: runs-on: ubuntu-latest diff --git a/.github/workflows/daily.yml b/.github/workflows/daily.yml index 9fc372532..83cf42d57 100644 --- a/.github/workflows/daily.yml +++ b/.github/workflows/daily.yml @@ -22,3 +22,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + - uses: actions/upload-artifact@v3 + if: always() + with: + name: d2chaos + path: ./d2chaos/out diff --git a/d2layouts/d2sequence/constants.go b/d2layouts/d2sequence/constants.go index bdffd77f8..7dc2e8798 100644 --- a/d2layouts/d2sequence/constants.go +++ b/d2layouts/d2sequence/constants.go @@ -3,7 +3,22 @@ package d2sequence // leaves at least 25 units of space on the left/right when computing the space required between actors const HORIZONTAL_PAD = 50. +// leaves at least 25 units of space on the top/bottom when computing the space required between edges +const VERTICAL_PAD = 50. + const MIN_ACTOR_DISTANCE = 200. // min vertical distance between edges const MIN_EDGE_DISTANCE = 100. + +// default size +const SPAN_WIDTH = 20. + +// small pad so that edges don't touch lifelines and spans +const SPAN_EDGE_PAD = 5. + +// as the spans start getting nested, their size grows +const SPAN_DEPTH_GROW_FACTOR = 10. + +// when a span has a single edge +const MIN_SPAN_HEIGHT = MIN_EDGE_DISTANCE / 2. diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go index 5d1107a39..b77037d60 100644 --- a/d2layouts/d2sequence/layout.go +++ b/d2layouts/d2sequence/layout.go @@ -4,75 +4,120 @@ import ( "context" "fmt" "math" + "sort" "oss.terrastruct.com/d2/d2graph" "oss.terrastruct.com/d2/lib/geo" "oss.terrastruct.com/d2/lib/go2" "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/d2/lib/shape" ) func Layout(ctx context.Context, g *d2graph.Graph) (err error) { - edgeYStep := MIN_EDGE_DISTANCE - actorXStep := MIN_ACTOR_DISTANCE - maxActorHeight := 0. - - actorRank := make(map[*d2graph.Object]int) - for rank, actor := range g.Objects { - actorRank[actor] = rank - } - for _, edge := range g.Edges { - edgeYStep = math.Max(edgeYStep, float64(edge.LabelDimensions.Height)+HORIZONTAL_PAD) - maxActorHeight = math.Max(maxActorHeight, edge.Src.Height+HORIZONTAL_PAD) - maxActorHeight = math.Max(maxActorHeight, edge.Dst.Height+HORIZONTAL_PAD) - // ensures that long labels, spanning over multiple actors, don't make for large gaps between actors - // by distributing the label length across the actors rank difference - rankDiff := math.Abs(float64(actorRank[edge.Src]) - float64(actorRank[edge.Dst])) - distributedLabelWidth := float64(edge.LabelDimensions.Width) / rankDiff - actorXStep = math.Max(actorXStep, distributedLabelWidth+HORIZONTAL_PAD) + sd := &sequenceDiagram{ + graph: g, + objectRank: make(map[*d2graph.Object]int), + minEdgeRank: make(map[*d2graph.Object]int), + maxEdgeRank: make(map[*d2graph.Object]int), + edgeYStep: MIN_EDGE_DISTANCE, + actorXStep: MIN_ACTOR_DISTANCE, + maxActorHeight: 0., } - placeActors(g.Objects, maxActorHeight, actorXStep) - routeEdges(g.Edges, maxActorHeight, edgeYStep) - addLifelineEdges(g, g.Objects, edgeYStep) + sd.init() + sd.placeActors() + sd.placeSpans() + sd.routeEdges() + sd.addLifelineEdges() return nil } +type sequenceDiagram struct { + graph *d2graph.Graph + + edges []*d2graph.Edge + actors []*d2graph.Object + spans []*d2graph.Object + + // can be either actors or spans + // rank: left to right position of actors/spans (spans have the same rank as their parents) + objectRank map[*d2graph.Object]int + + // keep track of the first and last edge of a given actor + // the edge rank is the order in which it appears from top to bottom + minEdgeRank map[*d2graph.Object]int + maxEdgeRank map[*d2graph.Object]int + + edgeYStep float64 + actorXStep float64 + maxActorHeight float64 +} + +func (sd *sequenceDiagram) init() { + sd.edges = make([]*d2graph.Edge, len(sd.graph.Edges)) + copy(sd.edges, sd.graph.Edges) + + queue := make([]*d2graph.Object, len(sd.graph.Root.ChildrenArray)) + copy(queue, sd.graph.Root.ChildrenArray) + for len(queue) > 0 { + obj := queue[0] + queue = queue[1:] + + if sd.isActor(obj) { + sd.actors = append(sd.actors, obj) + sd.objectRank[obj] = len(sd.actors) + sd.maxActorHeight = math.Max(sd.maxActorHeight, obj.Height) + } else { + // spans are always rectangles and have no labels + obj.Attributes.Label = d2graph.Scalar{Value: ""} + obj.Attributes.Shape = d2graph.Scalar{Value: shape.SQUARE_TYPE} + sd.spans = append(sd.spans, obj) + sd.objectRank[obj] = sd.objectRank[obj.Parent] + } + + queue = append(queue, obj.ChildrenArray...) + } + + for rank, edge := range sd.edges { + sd.edgeYStep = math.Max(sd.edgeYStep, float64(edge.LabelDimensions.Height)) + + sd.setMinMaxEdgeRank(edge.Src, rank) + sd.setMinMaxEdgeRank(edge.Dst, rank) + + // ensures that long labels, spanning over multiple actors, don't make for large gaps between actors + // by distributing the label length across the actors rank difference + rankDiff := math.Abs(float64(sd.objectRank[edge.Src]) - float64(sd.objectRank[edge.Dst])) + distributedLabelWidth := float64(edge.LabelDimensions.Width) / rankDiff + sd.actorXStep = math.Max(sd.actorXStep, distributedLabelWidth+HORIZONTAL_PAD) + } + + sd.maxActorHeight += VERTICAL_PAD + sd.edgeYStep += VERTICAL_PAD +} + +func (sd *sequenceDiagram) setMinMaxEdgeRank(actor *d2graph.Object, rank int) { + if minRank, exists := sd.minEdgeRank[actor]; exists { + sd.minEdgeRank[actor] = go2.IntMin(minRank, rank) + } else { + sd.minEdgeRank[actor] = rank + } + + sd.maxEdgeRank[actor] = go2.IntMax(sd.maxEdgeRank[actor], rank) +} + // placeActors places actors bottom aligned, side by side -func placeActors(actors []*d2graph.Object, maxHeight, xStep float64) { +func (sd *sequenceDiagram) placeActors() { x := 0. - for _, actors := range actors { - yOffset := maxHeight - actors.Height + for _, actors := range sd.actors { + yOffset := sd.maxActorHeight - actors.Height actors.TopLeft = geo.NewPoint(x, yOffset) - x += actors.Width + xStep + x += actors.Width + sd.actorXStep 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 +// addLifelineEdges adds a new edge for each actor in the graph that represents the its lifeline // ┌──────────────┐ // │ actor │ // └──────┬───────┘ @@ -80,14 +125,14 @@ func routeEdges(edgesInOrder []*d2graph.Edge, startY, yStep float64) { // │ 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 { +func (sd *sequenceDiagram) addLifelineEdges() { + endY := sd.getEdgeY(len(sd.edges)) + for _, actor := range sd.actors { actorBottom := actor.Center() actorBottom.Y = actor.TopLeft.Y + actor.Height actorLifelineEnd := actor.Center() actorLifelineEnd.Y = endY - g.Edges = append(g.Edges, &d2graph.Edge{ + sd.graph.Edges = append(sd.graph.Edges, &d2graph.Edge{ Attributes: d2graph.Attributes{ Style: d2graph.Style{ StrokeDash: &d2graph.Scalar{Value: "10"}, @@ -105,3 +150,130 @@ func addLifelineEdges(g *d2graph.Graph, actors []*d2graph.Object, yStep float64) }) } } + +// placeSpans places spans over the object lifeline +// ┌──────────┐ +// │ actor │ +// └────┬─────┘ +// ┌─┴──┐ +// │ │ +// |span| +// │ │ +// └─┬──┘ +// │ +// lifeline +// │ +func (sd *sequenceDiagram) placeSpans() { + // quickly find the span center X + rankToX := make(map[int]float64) + for _, actor := range sd.actors { + rankToX[sd.objectRank[actor]] = actor.Center().X + } + + // places spans from most to least nested + // the order is important because the only way a child span exists is if there'e an edge to it + // however, the parent span might not have an edge to it and then its position is based on the child position + // or, there can be edge to it, but it comes after the child one meaning the top left position is still based on the child + // and not on its own edge + spanFromMostNested := make([]*d2graph.Object, len(sd.spans)) + copy(spanFromMostNested, sd.spans) + sort.SliceStable(spanFromMostNested, func(i, j int) bool { + return spanFromMostNested[i].Level() > spanFromMostNested[j].Level() + }) + for _, span := range spanFromMostNested { + // finds the position based on children + minChildY := math.Inf(1) + maxChildY := math.Inf(-1) + for _, child := range span.ChildrenArray { + minChildY = math.Min(minChildY, child.TopLeft.Y) + maxChildY = math.Max(maxChildY, child.TopLeft.Y+child.Height) + } + + // finds the position if there are edges to this span + minEdgeY := math.Inf(1) + if minRank, exists := sd.minEdgeRank[span]; exists { + minEdgeY = sd.getEdgeY(minRank) + } + maxEdgeY := math.Inf(-1) + if maxRank, exists := sd.maxEdgeRank[span]; exists { + maxEdgeY = sd.getEdgeY(maxRank) + } + + // if it is the same as the child top left, add some padding + minY := math.Min(minEdgeY, minChildY) + if minY == minChildY { + minY -= SPAN_DEPTH_GROW_FACTOR + } else { + minY -= SPAN_EDGE_PAD + } + maxY := math.Max(maxEdgeY, maxChildY) + if maxY == maxChildY { + maxY += SPAN_DEPTH_GROW_FACTOR + } else { + maxY += SPAN_EDGE_PAD + } + + height := math.Max(maxY-minY, MIN_SPAN_HEIGHT) + // -2 because the actors count as level 1 making the first level span getting 2*SPAN_DEPTH_GROW_FACTOR + width := SPAN_WIDTH + (float64(span.Level()-2) * SPAN_DEPTH_GROW_FACTOR) + x := rankToX[sd.objectRank[span]] - (width / 2.) + span.Box = geo.NewBox(geo.NewPoint(x, minY), width, height) + } +} + +// routeEdges routes horizontal edges from Src to Dst +func (sd *sequenceDiagram) routeEdges() { + for rank, edge := range sd.edges { + isLeftToRight := edge.Src.TopLeft.X < edge.Dst.TopLeft.X + + // finds the proper anchor point based on the edge direction + var startX, endX float64 + if sd.isActor(edge.Src) { + startX = edge.Src.Center().X + } else if isLeftToRight { + startX = edge.Src.TopLeft.X + edge.Src.Width + } else { + startX = edge.Src.TopLeft.X + } + + if sd.isActor(edge.Dst) { + endX = edge.Dst.Center().X + } else if isLeftToRight { + endX = edge.Dst.TopLeft.X + } else { + endX = edge.Dst.TopLeft.X + edge.Dst.Width + } + + if isLeftToRight { + startX += SPAN_EDGE_PAD + endX -= SPAN_EDGE_PAD + } else { + startX -= SPAN_EDGE_PAD + endX += SPAN_EDGE_PAD + } + + edgeY := sd.getEdgeY(rank) + edge.Route = []*geo.Point{ + geo.NewPoint(startX, edgeY), + geo.NewPoint(endX, edgeY), + } + + if edge.Attributes.Label.Value != "" { + if isLeftToRight { + edge.LabelPosition = go2.Pointer(string(label.OutsideTopCenter)) + } else { + // the label will be placed above the edge because the orientation is based on the edge normal vector + edge.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter)) + } + } + } +} + +func (sd *sequenceDiagram) getEdgeY(rank int) float64 { + // +1 so that the first edge has the top padding for its label + return ((float64(rank) + 1.) * sd.edgeYStep) + sd.maxActorHeight +} + +func (sd *sequenceDiagram) isActor(obj *d2graph.Object) bool { + return obj.Parent == sd.graph.Root +} diff --git a/d2layouts/d2sequence/layout_test.go b/d2layouts/d2sequence/layout_test.go index f01575292..26d88f9fe 100644 --- a/d2layouts/d2sequence/layout_test.go +++ b/d2layouts/d2sequence/layout_test.go @@ -6,38 +6,52 @@ import ( "oss.terrastruct.com/d2/d2graph" "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/label" "oss.terrastruct.com/d2/lib/log" + "oss.terrastruct.com/d2/lib/shape" ) -func TestLayout(t *testing.T) { +func TestBasicSequenceDiagram(t *testing.T) { + // ┌────────┐ ┌────────┐ + // │ n1 │ │ n2 │ + // └────┬───┘ └────┬───┘ + // │ │ + // ├───────────────────────► + // │ │ + // ◄───────────────────────┤ + // │ │ + // ├───────────────────────► + // │ │ + // ◄───────────────────────┤ + // │ │ g := d2graph.NewGraph(nil) - g.Objects = []*d2graph.Object{ - { - ID: "Alice", - Box: geo.NewBox(nil, 100, 100), - }, - { - ID: "Bob", - Box: geo.NewBox(nil, 30, 30), - }, - } + n1 := g.Root.EnsureChild([]string{"n1"}) + n1.Box = geo.NewBox(nil, 100, 100) + n2 := g.Root.EnsureChild([]string{"n2"}) + n2.Box = geo.NewBox(nil, 30, 30) g.Edges = []*d2graph.Edge{ { - Src: g.Objects[0], - Dst: g.Objects[1], + Src: n1, + Dst: n2, + Attributes: d2graph.Attributes{ + Label: d2graph.Scalar{Value: "left to right"}, + }, }, { - Src: g.Objects[1], - Dst: g.Objects[0], + Src: n2, + Dst: n1, + Attributes: d2graph.Attributes{ + Label: d2graph.Scalar{Value: "right to left"}, + }, }, { - Src: g.Objects[0], - Dst: g.Objects[1], + Src: n1, + Dst: n2, }, { - Src: g.Objects[1], - Dst: g.Objects[0], + Src: n2, + Dst: n1, }, } nEdges := len(g.Edges) @@ -76,11 +90,23 @@ func TestLayout(t *testing.T) { 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 edge.Src.TopLeft.X < edge.Dst.TopLeft.X { + // left to right + if edge.Route[0].X != edge.Src.Center().X+SPAN_EDGE_PAD { + t.Fatalf("expected edge[%d] x to be at the actor center", i) + } + + if edge.Route[1].X != edge.Dst.Center().X-SPAN_EDGE_PAD { + t.Fatalf("expected edge[%d] x to be at the actor center", i) + } + } else { + if edge.Route[0].X != edge.Src.Center().X-SPAN_EDGE_PAD { + t.Fatalf("expected edge[%d] x to be at the actor center", i) + } + + if edge.Route[1].X != edge.Dst.Center().X+SPAN_EDGE_PAD { + t.Fatalf("expected edge[%d] x to be at the actor center", i) + } } if i > 0 { prevEdge := g.Edges[i-1] @@ -94,19 +120,131 @@ func TestLayout(t *testing.T) { 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) + t.Fatalf("expected lifeline 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) + t.Fatalf("expected lifeline 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) + t.Fatalf("expected lifeline 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) + t.Fatalf("expected lifeline 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) + t.Fatalf("expected lifeline edge[%d] to end after the last sequence edge", i) } } + + // check label positions + if *g.Edges[0].LabelPosition != string(label.OutsideTopCenter) { + t.Fatalf("expected edge label to be placed on %s, got %s", string(label.OutsideTopCenter), *g.Edges[0].LabelPosition) + } + + if *g.Edges[1].LabelPosition != string(label.OutsideBottomCenter) { + t.Fatalf("expected edge label to be placed on %s, got %s", string(label.OutsideBottomCenter), *g.Edges[0].LabelPosition) + } +} + +func TestSpansSequenceDiagram(t *testing.T) { + // ┌─────┐ ┌─────┐ + // │ a │ │ b │ + // └──┬──┘ └──┬──┘ + // ├┐────────────────────►┌┤ + // t1 ││ ││ t1 + // ├┘◄────────────────────└┤ + // ├┐──────────────────────► + // t2 ││ │ + // ├┘◄─────────────────────┤ + g := d2graph.NewGraph(nil) + a := g.Root.EnsureChild([]string{"a"}) + a.Box = geo.NewBox(nil, 100, 100) + a.Attributes = d2graph.Attributes{ + Shape: d2graph.Scalar{Value: shape.PERSON_TYPE}, + } + a_t1 := a.EnsureChild([]string{"t1"}) + a_t1.Attributes = d2graph.Attributes{ + Shape: d2graph.Scalar{Value: shape.DIAMOND_TYPE}, + Label: d2graph.Scalar{Value: "label"}, + } + a_t2 := a.EnsureChild([]string{"t2"}) + b := g.Root.EnsureChild([]string{"b"}) + b.Box = geo.NewBox(nil, 30, 30) + b_t1 := b.EnsureChild([]string{"t1"}) + + g.Edges = []*d2graph.Edge{ + { + Src: a_t1, + Dst: b_t1, + }, { + Src: b_t1, + Dst: a_t1, + }, { + Src: a_t2, + Dst: b, + }, { + Src: b, + Dst: a_t2, + }, + } + + ctx := log.WithTB(context.Background(), t, nil) + Layout(ctx, g) + + // check properties + if a.Attributes.Shape.Value != shape.PERSON_TYPE { + t.Fatal("actor a shape changed") + } + + if a_t1.Attributes.Label.Value != "" { + t.Fatalf("expected no label for span, got %s", a_t1.Attributes.Label.Value) + } + + if a_t1.Attributes.Shape.Value != shape.SQUARE_TYPE { + t.Fatalf("expected square shape for span, got %s", a_t1.Attributes.Shape.Value) + } + + if a_t1.Height != b_t1.Height { + t.Fatalf("expected a.t1 and b.t1 to have the same height, got %.5f and %.5f", a_t1.Height, b_t1.Height) + } + + // Y diff of the 2 first edges + expectedHeight := g.Edges[1].Route[0].Y - g.Edges[0].Route[0].Y + (2 * SPAN_EDGE_PAD) + if a_t1.Height != expectedHeight { + t.Fatalf("expected a.t1 height to be %.5f, got %.5f", expectedHeight, a_t1.Height) + } + + if a_t1.Width != SPAN_WIDTH { + t.Fatalf("expected span width to be %.5f, got %.5f", SPAN_WIDTH, a_t1.Width) + } + + // check positions + if a.Center().X != a_t1.Center().X { + t.Fatal("expected a_t1.X = a.X") + } + if a.Center().X != a_t2.Center().X { + t.Fatal("expected a_t2.X = a.X") + } + if b.Center().X != b_t1.Center().X { + t.Fatal("expected b_t1.X = b.X") + } + if a_t1.TopLeft.Y != b_t1.TopLeft.Y { + t.Fatal("expected a.t1 and b.t1 to be placed at the same Y") + } + if a_t1.TopLeft.Y != g.Edges[0].Route[0].Y-SPAN_EDGE_PAD { + t.Fatal("expected a.t1 to be placed at the same Y of the first edge") + } + + // check routes + if g.Edges[0].Route[0].X != a_t1.TopLeft.X+a_t1.Width+SPAN_EDGE_PAD { + t.Fatal("expected the first edge to start on a.t1 top right X") + } + + if g.Edges[0].Route[1].X != b_t1.TopLeft.X-SPAN_EDGE_PAD { + t.Fatal("expected the first edge to end on b.t1 top left X") + } + + if g.Edges[2].Route[1].X != b.Center().X-SPAN_EDGE_PAD { + t.Fatal("expected the third edge to end on b.t1 center X") + } }