diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index ffe01dbc1e..d5d5da268d 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -1,5 +1,7 @@
#### Features ๐
+- d2layouts: add `shape: cycle` for circular diagram layouts with border-trimmed circular arc routing. [#1578](https://github.com/terrastruct/d2/issues/1578)
+
#### Improvements ๐งน
- d2svg:
diff --git a/d2graph/cyclediagram.go b/d2graph/cyclediagram.go
new file mode 100644
index 0000000000..65d7903935
--- /dev/null
+++ b/d2graph/cyclediagram.go
@@ -0,0 +1,7 @@
+package d2graph
+
+import "github.com/d2lang/d2/d2target"
+
+func (obj *Object) IsCycleDiagram() bool {
+ return obj != nil && obj.Shape.Value == d2target.ShapeCycleDiagram
+}
diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go
index 92f5c8948a..6c96306c5f 100644
--- a/d2graph/d2graph.go
+++ b/d2graph/d2graph.go
@@ -543,7 +543,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.ShapeCycleDiagram) || strings.EqualFold(shape, d2target.ShapeHierarchy) {
if level == 1 {
if !obj.IsContainer() {
return color.B6
diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go
new file mode 100644
index 0000000000..07c351e999
--- /dev/null
+++ b/d2layouts/d2cycle/layout.go
@@ -0,0 +1,382 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+
+ "github.com/d2lang/d2/d2graph"
+ "github.com/d2lang/d2/lib/geo"
+ "github.com/d2lang/d2/lib/label"
+ "github.com/d2lang/d2/lib/shape"
+ "github.com/d2lang/util-go/go2"
+)
+
+const (
+ minRadius = 200.
+ padding = 24.
+ maxArcSweep = math.Pi / 2
+ epsilon = 1e-6
+)
+
+type anglePoint struct {
+ angle float64
+ point *geo.Point
+}
+
+// Layout arranges the root children around a circle and routes their root-level
+// edges as cubic Bezier arcs trimmed to each shape's visible perimeter.
+func Layout(ctx context.Context, g *d2graph.Graph, coreLayout d2graph.LayoutGraph) error {
+ objects := g.Root.ChildrenArray
+ if len(objects) == 0 {
+ return nil
+ }
+
+ if coreLayout != nil {
+ if err := coreLayout(ctx, g); err != nil {
+ return err
+ }
+ }
+
+ for _, obj := range g.Objects {
+ positionLabelsIcons(obj)
+ }
+
+ radius := calculateRadius(objects)
+ positionObjects(objects, radius)
+
+ center := geo.NewPoint(0, 0)
+ for _, edge := range g.Edges {
+ if isCycleEdge(g, edge) {
+ routeCircularArc(edge, center)
+ }
+ }
+ normalizeGraph(g)
+ return nil
+}
+
+func calculateRadius(objects []*d2graph.Object) float64 {
+ if len(objects) < 2 {
+ return 0
+ }
+
+ maxHalfDiagonal := 0.
+ for _, obj := range objects {
+ maxHalfDiagonal = math.Max(maxHalfDiagonal, math.Hypot(obj.Width/2, obj.Height/2))
+ }
+
+ minimum := (maxHalfDiagonal + padding) / math.Sin(math.Pi/float64(len(objects)))
+ return math.Max(minimum, minRadius)
+}
+
+func positionObjects(objects []*d2graph.Object, radius float64) {
+ for i, obj := range objects {
+ angle := -math.Pi/2 + 2*math.Pi*float64(i)/float64(len(objects))
+ center := pointOnCircle(geo.NewPoint(0, 0), radius, angle)
+ moveObjectAndInternalEdges(obj, center.X-obj.Width/2, center.Y-obj.Height/2)
+ }
+}
+
+func moveObjectAndInternalEdges(obj *d2graph.Object, x, y float64) {
+ if obj.TopLeft == nil {
+ obj.TopLeft = geo.NewPoint(0, 0)
+ }
+
+ dx := x - obj.TopLeft.X
+ dy := y - obj.TopLeft.Y
+ obj.MoveWithDescendants(dx, dy)
+
+ for _, edge := range obj.Graph.Edges {
+ if edge.Src == nil || edge.Dst == nil || len(edge.Route) == 0 {
+ continue
+ }
+
+ srcMoved := edge.Src.IsDescendantOf(obj)
+ dstMoved := edge.Dst.IsDescendantOf(obj)
+ switch {
+ case srcMoved && dstMoved:
+ edge.Move(dx, dy)
+ case srcMoved:
+ edge.Route[0].X += dx
+ edge.Route[0].Y += dy
+ case dstMoved:
+ i := len(edge.Route) - 1
+ edge.Route[i].X += dx
+ edge.Route[i].Y += dy
+ }
+ }
+}
+
+func isCycleEdge(g *d2graph.Graph, edge *d2graph.Edge) bool {
+ if edge.Src == nil || edge.Dst == nil {
+ return false
+ }
+ return edge.Src.Parent == g.Root && edge.Dst.Parent == g.Root
+}
+
+func normalizeGraph(g *d2graph.Graph) {
+ tl := geo.NewPoint(math.Inf(1), math.Inf(1))
+ br := geo.NewPoint(math.Inf(-1), math.Inf(-1))
+
+ for _, obj := range g.Objects {
+ if obj.TopLeft == nil {
+ continue
+ }
+ tl.X = math.Min(tl.X, obj.TopLeft.X)
+ tl.Y = math.Min(tl.Y, obj.TopLeft.Y)
+ br.X = math.Max(br.X, obj.TopLeft.X+obj.Width)
+ br.Y = math.Max(br.Y, obj.TopLeft.Y+obj.Height)
+ }
+ for _, edge := range g.Edges {
+ for _, point := range edge.Route {
+ tl.X = math.Min(tl.X, point.X)
+ tl.Y = math.Min(tl.Y, point.Y)
+ br.X = math.Max(br.X, point.X)
+ br.Y = math.Max(br.Y, point.Y)
+ }
+ }
+
+ if math.IsInf(tl.X, 0) || math.IsInf(tl.Y, 0) {
+ return
+ }
+
+ dx := -tl.X
+ dy := -tl.Y
+ for _, obj := range g.Objects {
+ if obj.TopLeft != nil {
+ obj.TopLeft.X += dx
+ obj.TopLeft.Y += dy
+ }
+ }
+ for _, edge := range g.Edges {
+ edge.Move(dx, dy)
+ }
+ g.Root.Box = geo.NewBox(geo.NewPoint(0, 0), br.X-tl.X, br.Y-tl.Y)
+}
+
+func routeCircularArc(edge *d2graph.Edge, center *geo.Point) {
+ if edge.Src == nil || edge.Dst == nil {
+ return
+ }
+
+ srcCenter := edge.Src.Center()
+ dstCenter := edge.Dst.Center()
+ radius := (distance(center, srcCenter) + distance(center, dstCenter)) / 2
+ if radius == 0 {
+ return
+ }
+
+ startAngle := angleOf(center, srcCenter)
+ endAngle := advanceAngle(startAngle, angleOf(center, dstCenter))
+ if edge.Src == edge.Dst || endAngle-startAngle < epsilon {
+ endAngle = startAngle + 2*math.Pi
+ }
+
+ start := findBoundaryAngle(edge.Src, center, radius, startAngle, endAngle, true)
+ end := findBoundaryAngle(edge.Dst, center, radius, startAngle, endAngle, false)
+ if start == nil || end == nil || end.angle-start.angle < epsilon {
+ routeStraight(edge)
+ return
+ }
+
+ edge.Route = buildArcRoute(center, radius, start.angle, end.angle)
+ if len(edge.Route) >= 4 {
+ edge.Route[0] = start.point
+ edge.Route[len(edge.Route)-1] = end.point
+ edge.IsCurve = true
+ return
+ }
+
+ routeStraight(edge)
+}
+
+func routeStraight(edge *d2graph.Edge) {
+ edge.Route = []*geo.Point{edge.Src.Center(), edge.Dst.Center()}
+ edge.TraceToShape(edge.Route, 0, 1)
+ edge.IsCurve = false
+}
+
+func findBoundaryAngle(obj *d2graph.Object, center *geo.Point, radius, startAngle, endAngle float64, fromStart bool) *anglePoint {
+ var selected *anglePoint
+ for _, point := range circleShapeIntersections(obj.ToShape(), center, radius, startAngle, endAngle) {
+ angle := advanceAngle(startAngle, angleOf(center, point))
+ if angle <= startAngle+epsilon || angle >= endAngle-epsilon {
+ continue
+ }
+
+ candidate := &anglePoint{
+ angle: angle,
+ point: truncatePoint(point),
+ }
+ if selected == nil ||
+ (fromStart && candidate.angle < selected.angle) ||
+ (!fromStart && candidate.angle > selected.angle) {
+ selected = candidate
+ }
+ }
+
+ if selected != nil {
+ return selected
+ }
+ return fallbackBoundaryAngle(obj, center, radius, startAngle, endAngle, fromStart)
+}
+
+func fallbackBoundaryAngle(obj *d2graph.Object, center *geo.Point, radius, startAngle, endAngle float64, fromStart bool) *anglePoint {
+ box := obj.Box
+ if box == nil {
+ return nil
+ }
+
+ low := startAngle
+ high := endAngle
+ if fromStart {
+ for i := 0; i < 48; i++ {
+ mid := (low + high) / 2
+ if box.Contains(pointOnCircle(center, radius, mid)) {
+ low = mid
+ } else {
+ high = mid
+ }
+ }
+ angle := high
+ point := shape.TraceToShapeBorder(obj.ToShape(), pointOnCircle(center, radius, angle), pointOnCircle(center, radius, angle+0.001))
+ return &anglePoint{angle: angle, point: truncatePoint(point)}
+ }
+
+ for i := 0; i < 48; i++ {
+ mid := (low + high) / 2
+ if box.Contains(pointOnCircle(center, radius, mid)) {
+ high = mid
+ } else {
+ low = mid
+ }
+ }
+ angle := low
+ point := shape.TraceToShapeBorder(obj.ToShape(), pointOnCircle(center, radius, angle), pointOnCircle(center, radius, angle-0.001))
+ return &anglePoint{angle: angle, point: truncatePoint(point)}
+}
+
+func buildArcRoute(center *geo.Point, radius, startAngle, endAngle float64) []*geo.Point {
+ sweep := endAngle - startAngle
+ segments := int(math.Ceil(sweep / maxArcSweep))
+ if segments < 1 {
+ segments = 1
+ }
+
+ route := []*geo.Point{truncatePoint(pointOnCircle(center, radius, startAngle))}
+ for i := 0; i < segments; i++ {
+ a0 := startAngle + sweep*float64(i)/float64(segments)
+ a1 := startAngle + sweep*float64(i+1)/float64(segments)
+ p0 := pointOnCircle(center, radius, a0)
+ p3 := pointOnCircle(center, radius, a1)
+ k := 4. / 3. * math.Tan((a1-a0)/4.)
+
+ c1 := geo.NewPoint(
+ p0.X+k*radius*-math.Sin(a0),
+ p0.Y+k*radius*math.Cos(a0),
+ )
+ c2 := geo.NewPoint(
+ p3.X-k*radius*-math.Sin(a1),
+ p3.Y-k*radius*math.Cos(a1),
+ )
+
+ route = append(route, truncatePoint(c1), truncatePoint(c2), truncatePoint(p3))
+ }
+ return route
+}
+
+func circleShapeIntersections(s shape.Shape, center *geo.Point, radius, startAngle, endAngle float64) []*geo.Point {
+ sweep := endAngle - startAngle
+ steps := int(math.Ceil(sweep / (math.Pi / 180)))
+ if steps < 24 {
+ steps = 24
+ }
+
+ points := make([]*geo.Point, 0)
+ for i := 0; i < steps; i++ {
+ a0 := startAngle + sweep*float64(i)/float64(steps)
+ a1 := startAngle + sweep*float64(i+1)/float64(steps)
+ circleSegment := geo.Segment{
+ Start: pointOnCircle(center, radius, a0),
+ End: pointOnCircle(center, radius, a1),
+ }
+ for _, perimeter := range s.Perimeter() {
+ for _, point := range perimeter.Intersections(circleSegment) {
+ if !hasNearbyPoint(points, point) {
+ points = append(points, point)
+ }
+ }
+ }
+ }
+ return points
+}
+
+func hasNearbyPoint(points []*geo.Point, point *geo.Point) bool {
+ for _, existing := range points {
+ if distance(existing, point) < 0.001 {
+ return true
+ }
+ }
+ return false
+}
+
+func pointOnCircle(center *geo.Point, radius, angle float64) *geo.Point {
+ return geo.NewPoint(center.X+radius*math.Cos(angle), center.Y+radius*math.Sin(angle))
+}
+
+func angleOf(center, point *geo.Point) float64 {
+ return math.Atan2(point.Y-center.Y, point.X-center.X)
+}
+
+func advanceAngle(reference, angle float64) float64 {
+ for angle <= reference {
+ angle += 2 * math.Pi
+ }
+ return angle
+}
+
+func distance(a, b *geo.Point) float64 {
+ return math.Hypot(a.X-b.X, a.Y-b.Y)
+}
+
+func truncatePoint(point *geo.Point) *geo.Point {
+ point = point.Copy()
+ point.TruncateDecimals()
+ return point
+}
+
+func positionLabelsIcons(obj *d2graph.Object) {
+ if obj.Icon != nil && obj.IconPosition == nil {
+ if len(obj.ChildrenArray) > 0 {
+ obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
+ if obj.LabelPosition == nil {
+ obj.LabelPosition = go2.Pointer(label.OutsideTopRight.String())
+ return
+ }
+ } else if obj.SQLTable != nil || obj.Class != nil || obj.Language != "" {
+ obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
+ } else {
+ obj.IconPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+ }
+
+ if obj.HasLabel() && obj.LabelPosition == nil {
+ if len(obj.ChildrenArray) > 0 {
+ obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
+ } else if obj.HasOutsideBottomLabel() {
+ obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
+ } else if obj.Icon != nil {
+ obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
+ } else {
+ obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+
+ if float64(obj.LabelDimensions.Width) > obj.Width ||
+ float64(obj.LabelDimensions.Height) > obj.Height {
+ if len(obj.ChildrenArray) > 0 {
+ obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
+ } else {
+ obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
+ }
+ }
+ }
+}
diff --git a/d2layouts/d2cycle/layout_test.go b/d2layouts/d2cycle/layout_test.go
new file mode 100644
index 0000000000..cdc78d5198
--- /dev/null
+++ b/d2layouts/d2cycle/layout_test.go
@@ -0,0 +1,221 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/d2lang/d2/d2compiler"
+ "github.com/d2lang/d2/d2graph"
+ "github.com/d2lang/d2/lib/geo"
+)
+
+func TestCalculateRadiusKeepsSingleObjectAtCenter(t *testing.T) {
+ g, _, err := d2compiler.Compile("", strings.NewReader(`
+shape: cycle
+a
+`), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setObjectBoxes(g, 100, 100)
+
+ if got := calculateRadius(g.Root.ChildrenArray); got != 0 {
+ t.Fatalf("expected single-object cycle radius 0, got %.2f", got)
+ }
+}
+
+func TestCycleLayoutPreservesNestedCoreLayout(t *testing.T) {
+ g, _, err := d2compiler.Compile("", strings.NewReader(`
+shape: cycle
+a: {
+ x
+ y
+ x -> y
+}
+b
+a -> b
+`), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setObjectBoxes(g, 100, 100)
+
+ layoutCalled := false
+ coreLayout := func(_ context.Context, g *d2graph.Graph) error {
+ layoutCalled = true
+ for _, obj := range g.Objects {
+ switch obj.ID {
+ case "a":
+ obj.TopLeft = geo.NewPoint(0, 0)
+ case "b":
+ obj.TopLeft = geo.NewPoint(300, 0)
+ case "x":
+ obj.TopLeft = geo.NewPoint(20, 30)
+ case "y":
+ obj.TopLeft = geo.NewPoint(160, 30)
+ }
+ }
+ for _, edge := range g.Edges {
+ edge.Route = []*geo.Point{edge.Src.Center(), edge.Dst.Center()}
+ }
+ return nil
+ }
+
+ if err := Layout(context.Background(), g, coreLayout); err != nil {
+ t.Fatal(err)
+ }
+ if !layoutCalled {
+ t.Fatal("expected cycle layout to call the provided core layout")
+ }
+
+ var x, y *d2graph.Object
+ var internalEdge, cycleEdge *d2graph.Edge
+ for _, obj := range g.Objects {
+ switch obj.ID {
+ case "x":
+ x = obj
+ case "y":
+ y = obj
+ }
+ }
+ for _, edge := range g.Edges {
+ switch {
+ case edge.Src == x && edge.Dst == y:
+ internalEdge = edge
+ case edge.Src.Parent == g.Root && edge.Dst.Parent == g.Root:
+ cycleEdge = edge
+ }
+ }
+
+ if x == nil || y == nil || internalEdge == nil || cycleEdge == nil {
+ t.Fatal("expected nested objects, nested edge, and root-level cycle edge")
+ }
+ if internalEdge.IsCurve {
+ t.Fatal("expected nested internal edge to keep the core layout route")
+ }
+ if got := len(internalEdge.Route); got != 2 {
+ t.Fatalf("expected nested internal edge route to keep 2 points, got %d", got)
+ }
+ if !samePoint(internalEdge.Route[0], x.Center()) {
+ t.Fatalf("expected nested edge start to move with nested source, got %v want %v", internalEdge.Route[0], x.Center())
+ }
+ if !samePoint(internalEdge.Route[1], y.Center()) {
+ t.Fatalf("expected nested edge end to move with nested destination, got %v want %v", internalEdge.Route[1], y.Center())
+ }
+ if !cycleEdge.IsCurve {
+ t.Fatal("expected root-level cycle edge to be routed as a curve")
+ }
+}
+
+func TestCycleLayoutMovesCrossChildDescendantEdges(t *testing.T) {
+ g, _, err := d2compiler.Compile("", strings.NewReader(`
+shape: cycle
+a: { x }
+b: { y }
+a.x -> b.y
+`), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setObjectBoxes(g, 100, 100)
+
+ coreLayout := func(_ context.Context, g *d2graph.Graph) error {
+ for _, obj := range g.Objects {
+ switch obj.ID {
+ case "a":
+ obj.TopLeft = geo.NewPoint(0, 0)
+ case "b":
+ obj.TopLeft = geo.NewPoint(300, 0)
+ case "x", "y":
+ obj.TopLeft = geo.NewPoint(20, 30)
+ }
+ }
+ for _, edge := range g.Edges {
+ edge.Route = []*geo.Point{edge.Src.Center(), edge.Dst.Center()}
+ }
+ return nil
+ }
+
+ if err := Layout(context.Background(), g, coreLayout); err != nil {
+ t.Fatal(err)
+ }
+
+ var x, y *d2graph.Object
+ for _, obj := range g.Objects {
+ switch obj.ID {
+ case "x":
+ x = obj
+ case "y":
+ y = obj
+ }
+ }
+ var edge *d2graph.Edge
+ for _, e := range g.Edges {
+ if e.Src == x && e.Dst == y {
+ edge = e
+ }
+ }
+
+ if edge == nil {
+ t.Fatal("expected cross-child descendant edge")
+ }
+ if len(edge.Route) != 2 {
+ t.Fatalf("expected 2-point route, got %d", len(edge.Route))
+ }
+ if !samePoint(edge.Route[0], x.Center()) {
+ t.Fatalf("expected edge start at %v, got %v", x.Center(), edge.Route[0])
+ }
+ if !samePoint(edge.Route[1], y.Center()) {
+ t.Fatalf("expected edge end at %v, got %v", y.Center(), edge.Route[1])
+ }
+}
+
+func setObjectBoxes(g *d2graph.Graph, width, height float64) {
+ for _, obj := range g.Objects {
+ obj.Box = geo.NewBox(geo.NewPoint(0, 0), width, height)
+ }
+}
+
+func samePoint(a, b *geo.Point) bool {
+ return math.Abs(a.X-b.X) < 0.001 && math.Abs(a.Y-b.Y) < 0.001
+}
+
+func TestCycleLayoutSupportsVariousShapes(t *testing.T) {
+ g, _, err := d2compiler.Compile("", strings.NewReader(`
+shape: cycle
+a: { shape: circle }
+b: { shape: rectangle }
+c: { shape: cylinder }
+d: { shape: hexagon }
+a -> b -> c -> d -> a
+`), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setObjectBoxes(g, 100, 100)
+
+ coreLayout := func(_ context.Context, g *d2graph.Graph) error {
+ for _, obj := range g.Objects {
+ obj.TopLeft = geo.NewPoint(0, 0)
+ }
+ return nil
+ }
+
+ if err := Layout(context.Background(), g, coreLayout); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ if !edge.IsCurve {
+ t.Fatalf("expected edge %v -> %v to be curve", edge.Src.ID, edge.Dst.ID)
+ }
+ if len(edge.Route) < 4 {
+ t.Fatalf("expected edge %v -> %v route to have at least 4 points, got %d", edge.Src.ID, edge.Dst.ID, len(edge.Route))
+ }
+ if (len(edge.Route)-1)%3 != 0 {
+ t.Fatalf("expected edge %v -> %v route length 1+3n, got %d", edge.Src.ID, edge.Dst.ID, len(edge.Route))
+ }
+ }
+}
diff --git a/d2layouts/d2layouts.go b/d2layouts/d2layouts.go
index c9c45479df..30f5ca0f84 100644
--- a/d2layouts/d2layouts.go
+++ b/d2layouts/d2layouts.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/d2lang/d2/d2graph"
+ "github.com/d2lang/d2/d2layouts/d2cycle"
"github.com/d2lang/d2/d2layouts/d2grid"
"github.com/d2lang/d2/d2layouts/d2near"
"github.com/d2lang/d2/d2layouts/d2sequence"
@@ -20,12 +21,12 @@ import (
type DiagramType string
-// a grid diagram at a constant near is
const (
DefaultGraphType DiagramType = ""
ConstantNearGraph DiagramType = "constant-near"
GridDiagram DiagramType = "grid-diagram"
SequenceDiagram DiagramType = "sequence-diagram"
+ CycleDiagram DiagramType = "cycle-diagram"
)
type GraphInfo struct {
@@ -289,6 +290,12 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
if err != nil {
return err
}
+ case CycleDiagram:
+ log.Debug(ctx, "layout cycle", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
+ err = d2cycle.Layout(ctx, g, coreLayout)
+ if err != nil {
+ return err
+ }
default:
log.Debug(ctx, "default layout", slog.Any("rootlevel", g.RootLevel), slog.Any("shapes", g.PrintString()))
err := coreLayout(ctx, g)
@@ -393,6 +400,8 @@ func NestedGraphInfo(obj *d2graph.Object) (gi GraphInfo) {
gi.DiagramType = SequenceDiagram
} else if obj.IsGridDiagram() {
gi.DiagramType = GridDiagram
+ } else if obj.IsCycleDiagram() {
+ gi.DiagramType = CycleDiagram
}
return gi
}
diff --git a/d2renderers/d2raster/target_pixel_coverage_test.go b/d2renderers/d2raster/target_pixel_coverage_test.go
index 5146d4ad3c..efc2b01751 100644
--- a/d2renderers/d2raster/target_pixel_coverage_test.go
+++ b/d2renderers/d2raster/target_pixel_coverage_test.go
@@ -56,6 +56,7 @@ func TestTargetShapePixels(t *testing.T) {
d2target.ShapeSQLTable,
d2target.ShapeImage,
d2target.ShapeSequenceDiagram,
+ d2target.ShapeCycleDiagram,
d2target.ShapeHierarchy,
}
if !reflect.DeepEqual(shapeValues, d2target.Shapes) {
diff --git a/d2renderers/d2raster/testdata/target-shapes.exp.png b/d2renderers/d2raster/testdata/target-shapes.exp.png
index c7126219f9..85f894820e 100644
Binary files a/d2renderers/d2raster/testdata/target-shapes.exp.png and b/d2renderers/d2raster/testdata/target-shapes.exp.png differ
diff --git a/d2renderers/d2scenebuild/shape_effects.go b/d2renderers/d2scenebuild/shape_effects.go
index 8d241ec1a1..ec30c3a7d8 100644
--- a/d2renderers/d2scenebuild/shape_effects.go
+++ b/d2renderers/d2scenebuild/shape_effects.go
@@ -295,7 +295,7 @@ func (b *builder) buildOrdinaryShapeOutline(targetShape d2target.Shape, fill d2s
Fill: fill,
Stroke: stroke,
}))
- case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "":
+ case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy, "":
radius := clampBorderRadius(float64(targetShape.BorderRadius), box.Width, box.Height)
nodes = append(nodes, d2scene.NewNode(d2scene.Rect{
Box: box,
diff --git a/d2renderers/d2scenebuild/sketch_shapes.go b/d2renderers/d2scenebuild/sketch_shapes.go
index 9f1b1f15fe..56accc2ff1 100644
--- a/d2renderers/d2scenebuild/sketch_shapes.go
+++ b/d2renderers/d2scenebuild/sketch_shapes.go
@@ -127,7 +127,7 @@ func (b *builder) buildSketchOrdinaryShape(targetShape d2target.Shape, idPrefix
object, idPrefix, fillRaw, strokeRaw, strokeWidth, dashes, dashOffset,
drawable, d2scene.Translate(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)), targetShape,
)
- case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "":
+ case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy, "":
drawable := newSketchGenerator().Rectangle(0, 0, float64(targetShape.Width), float64(targetShape.Height), options)
nodes, err = b.compileSketchShapeDrawable(
object, idPrefix, fillRaw, strokeRaw, strokeWidth, dashes, dashOffset,
@@ -156,7 +156,7 @@ func (b *builder) buildSketchOrdinaryShape(targetShape d2target.Shape, idPrefix
RadiusX: float64(targetShape.Width) / 2,
RadiusY: float64(targetShape.Height) / 2,
})
- case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "":
+ case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy, "":
overlayPrimitives = append(overlayPrimitives, d2scene.Rect{Box: sketchShapeBox(targetShape)})
default:
for _, node := range roughNodes {
@@ -578,7 +578,7 @@ func (b *builder) buildSketchOutlineOnly(targetShape d2target.Shape, idPrefix st
),
d2scene.Translate(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)), targetShape,
)
- case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "":
+ case d2target.ShapeRectangle, d2target.ShapeSquare, d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy, "":
return b.compileSketchShapeDrawable(
object, idPrefix, fillRaw, strokeRaw, strokeWidth, dashes, dashOffset,
newSketchGenerator().Rectangle(0, 0, float64(targetShape.Width), float64(targetShape.Height), options),
diff --git a/d2renderers/d2scenebuild/sketch_shapes_test.go b/d2renderers/d2scenebuild/sketch_shapes_test.go
index 18040f1263..8792075369 100644
--- a/d2renderers/d2scenebuild/sketch_shapes_test.go
+++ b/d2renderers/d2scenebuild/sketch_shapes_test.go
@@ -28,7 +28,7 @@ func TestSketchOrdinaryShapeFamiliesUseTypedRoughPathsDeterministically(t *testi
d2target.ShapeStep, d2target.ShapeCallout, d2target.ShapeStoredData,
d2target.ShapePerson, d2target.ShapeC4Person, d2target.ShapeDiamond,
d2target.ShapeHexagon, d2target.ShapeCloud,
- d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy,
+ d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy,
}
for _, shapeType := range shapeTypes {
name := shapeType
diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go
index 2d5fbfc156..f49b42486f 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -1829,7 +1829,7 @@ func drawShape(writer, appendixWriter io.Writer, diagramHash string, targetShape
fmt.Fprint(writer, el.Render())
// TODO should standardize "" to rectangle
- case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "":
+ case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, d2target.ShapeCycleDiagram, d2target.ShapeHierarchy, "":
borderRadius := math.MaxFloat64
if targetShape.BorderRadius != 0 {
borderRadius = float64(targetShape.BorderRadius)
diff --git a/d2target/d2target.go b/d2target/d2target.go
index 432745bece..0d274a469c 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -1266,6 +1266,7 @@ const (
ShapeSQLTable = "sql_table"
ShapeImage = "image"
ShapeSequenceDiagram = "sequence_diagram"
+ ShapeCycleDiagram = "cycle"
ShapeHierarchy = "hierarchy"
)
@@ -1294,6 +1295,7 @@ var Shapes = []string{
ShapeSQLTable,
ShapeImage,
ShapeSequenceDiagram,
+ ShapeCycleDiagram,
ShapeHierarchy,
}
@@ -1364,6 +1366,7 @@ var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{
ShapeSQLTable: shape.TABLE_TYPE,
ShapeImage: shape.IMAGE_TYPE,
ShapeSequenceDiagram: shape.SQUARE_TYPE,
+ ShapeCycleDiagram: shape.SQUARE_TYPE,
ShapeHierarchy: shape.SQUARE_TYPE,
}
diff --git a/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json b/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json
new file mode 100644
index 0000000000..00791c9689
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json
@@ -0,0 +1,884 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "1",
+ "type": "cycle",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 453,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "1.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 400,
+ "y": 200
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 400
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.d",
+ "type": "rectangle",
+ "pos": {
+ "x": 0,
+ "y": 200
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2",
+ "type": "cycle",
+ "pos": {
+ "x": 513,
+ "y": 17
+ },
+ "width": 408,
+ "height": 433,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "2.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 686,
+ "y": 17
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 859,
+ "y": 317
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 513,
+ "y": 317
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3",
+ "type": "cycle",
+ "pos": {
+ "x": 981,
+ "y": 0
+ },
+ "width": 226,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "3.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 981,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 981,
+ "y": 400
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ }
+ ],
+ "connections": [
+ {
+ "id": "1.(a -> b)[0]",
+ "src": "1.a",
+ "srcArrow": "none",
+ "dst": "1.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 253.5,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 46.39500045776367
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 113.41400146484375
+ },
+ {
+ "x": 424.25799560546875,
+ "y": 200
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(b -> c)[0]",
+ "src": "1.b",
+ "srcArrow": "none",
+ "dst": "1.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 424.25799560546875,
+ "y": 266
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 352.58599853515625
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 419.60400390625
+ },
+ {
+ "x": 253.5,
+ "y": 431.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(c -> d)[0]",
+ "src": "1.c",
+ "srcArrow": "none",
+ "dst": "1.d",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 200.5,
+ "y": 431.2359924316406
+ },
+ {
+ "x": 113.48500061035156,
+ "y": 419.60400390625
+ },
+ {
+ "x": 44.22700119018555,
+ "y": 352.58599853515625
+ },
+ {
+ "x": 29.740999221801758,
+ "y": 266
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(a -> b)[0]",
+ "src": "2.a",
+ "srcArrow": "none",
+ "dst": "2.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 739.2050170898438,
+ "y": 51.763999938964844
+ },
+ {
+ "x": 798.9879760742188,
+ "y": 59.75600051879883
+ },
+ {
+ "x": 851.9719848632812,
+ "y": 94.30000305175781
+ },
+ {
+ "x": 883.4019775390625,
+ "y": 145.7790069580078
+ },
+ {
+ "x": 914.8330078125,
+ "y": 197.2570037841797
+ },
+ {
+ "x": 921.35400390625,
+ "y": 260.16900634765625
+ },
+ {
+ "x": 901.1480102539062,
+ "y": 316.9989929199219
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(b -> c)[0]",
+ "src": "2.b",
+ "srcArrow": "none",
+ "dst": "2.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 862.072998046875,
+ "y": 383
+ },
+ {
+ "x": 824.1229858398438,
+ "y": 425.6210021972656
+ },
+ {
+ "x": 769.7730102539062,
+ "y": 450
+ },
+ {
+ "x": 712.7050170898438,
+ "y": 450
+ },
+ {
+ "x": 655.6370239257812,
+ "y": 450
+ },
+ {
+ "x": 601.2869873046875,
+ "y": 425.6210021972656
+ },
+ {
+ "x": 563.3369750976562,
+ "y": 383
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(a -> b)[0]",
+ "src": "3.a",
+ "srcArrow": "none",
+ "dst": "3.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1034,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 1133.3310546875,
+ "y": 48.04199981689453
+ },
+ {
+ "x": 1207.5,
+ "y": 132.78599548339844
+ },
+ {
+ "x": 1207.5,
+ "y": 233
+ },
+ {
+ "x": 1207.5,
+ "y": 333.2139892578125
+ },
+ {
+ "x": 1133.3310546875,
+ "y": 417.9570007324219
+ },
+ {
+ "x": 1034,
+ "y": 431.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg b/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg
new file mode 100644
index 0000000000..1b29c5db5d
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg
@@ -0,0 +1,30 @@
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json b/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json
new file mode 100644
index 0000000000..850c67df62
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json
@@ -0,0 +1,884 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "1",
+ "type": "cycle",
+ "pos": {
+ "x": 12,
+ "y": 12
+ },
+ "width": 454,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "1.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 212,
+ "y": 12
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 412,
+ "y": 212
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 212,
+ "y": 412
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.d",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 212
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2",
+ "type": "cycle",
+ "pos": {
+ "x": 485,
+ "y": 28
+ },
+ "width": 409,
+ "height": 433,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "2.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 658,
+ "y": 28
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 831,
+ "y": 328
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 485,
+ "y": 328
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3",
+ "type": "cycle",
+ "pos": {
+ "x": 913,
+ "y": 12
+ },
+ "width": 227,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "3.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 913,
+ "y": 12
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 913,
+ "y": 412
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ }
+ ],
+ "connections": [
+ {
+ "id": "1.(a -> b)[0]",
+ "src": "1.a",
+ "srcArrow": "none",
+ "dst": "1.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 265.5,
+ "y": 46.763999938964844
+ },
+ {
+ "x": 352.5150146484375,
+ "y": 58.39500045776367
+ },
+ {
+ "x": 421.77301025390625,
+ "y": 125.41400146484375
+ },
+ {
+ "x": 436.25799560546875,
+ "y": 212
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(b -> c)[0]",
+ "src": "1.b",
+ "srcArrow": "none",
+ "dst": "1.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 436.25799560546875,
+ "y": 278
+ },
+ {
+ "x": 421.77301025390625,
+ "y": 364.58599853515625
+ },
+ {
+ "x": 352.5150146484375,
+ "y": 431.60400390625
+ },
+ {
+ "x": 265.5,
+ "y": 443.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(c -> d)[0]",
+ "src": "1.c",
+ "srcArrow": "none",
+ "dst": "1.d",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 212.5,
+ "y": 443.2359924316406
+ },
+ {
+ "x": 125.48500061035156,
+ "y": 431.60400390625
+ },
+ {
+ "x": 56.22700119018555,
+ "y": 364.58599853515625
+ },
+ {
+ "x": 41.74100112915039,
+ "y": 278
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(a -> b)[0]",
+ "src": "2.a",
+ "srcArrow": "none",
+ "dst": "2.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 711.7050170898438,
+ "y": 63.263999938964844
+ },
+ {
+ "x": 771.4879760742188,
+ "y": 71.25599670410156
+ },
+ {
+ "x": 824.4719848632812,
+ "y": 105.80000305175781
+ },
+ {
+ "x": 855.9019775390625,
+ "y": 157.2790069580078
+ },
+ {
+ "x": 887.3330078125,
+ "y": 208.7570037841797
+ },
+ {
+ "x": 893.85400390625,
+ "y": 271.66900634765625
+ },
+ {
+ "x": 873.6480102539062,
+ "y": 328.4989929199219
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(b -> c)[0]",
+ "src": "2.b",
+ "srcArrow": "none",
+ "dst": "2.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 834.572998046875,
+ "y": 394.5
+ },
+ {
+ "x": 796.6229858398438,
+ "y": 437.1210021972656
+ },
+ {
+ "x": 742.2730102539062,
+ "y": 461.5
+ },
+ {
+ "x": 685.2050170898438,
+ "y": 461.5
+ },
+ {
+ "x": 628.1370239257812,
+ "y": 461.5
+ },
+ {
+ "x": 573.7869873046875,
+ "y": 437.1210021972656
+ },
+ {
+ "x": 535.8369750976562,
+ "y": 394.5
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(a -> b)[0]",
+ "src": "3.a",
+ "srcArrow": "none",
+ "dst": "3.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 966.85400390625,
+ "y": 46.763999938964844
+ },
+ {
+ "x": 1066.18505859375,
+ "y": 60.04199981689453
+ },
+ {
+ "x": 1140.35400390625,
+ "y": 144.78599548339844
+ },
+ {
+ "x": 1140.35400390625,
+ "y": 245
+ },
+ {
+ "x": 1140.35400390625,
+ "y": 345.2139892578125
+ },
+ {
+ "x": 1066.18505859375,
+ "y": 429.9570007324219
+ },
+ {
+ "x": 966.85400390625,
+ "y": 443.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg b/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg
new file mode 100644
index 0000000000..9dcf3d7aaa
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg
@@ -0,0 +1,30 @@
+abcdabcab
+
+
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/cycle-diagram/tala/board.exp.json b/e2etests/testdata/txtar/cycle-diagram/tala/board.exp.json
new file mode 100644
index 0000000000..70207139e5
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/tala/board.exp.json
@@ -0,0 +1,884 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "1",
+ "type": "cycle",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 453,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "1.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 400,
+ "y": 200
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 400
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.d",
+ "type": "rectangle",
+ "pos": {
+ "x": 0,
+ "y": 200
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2",
+ "type": "cycle",
+ "pos": {
+ "x": 473,
+ "y": 0
+ },
+ "width": 408,
+ "height": 433,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "2.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 646,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 819,
+ "y": 300
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 473,
+ "y": 300
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3",
+ "type": "cycle",
+ "pos": {
+ "x": 901,
+ "y": 0
+ },
+ "width": 226,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B4",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "3.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 901,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 901,
+ "y": 400
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ }
+ ],
+ "connections": [
+ {
+ "id": "1.(a -> b)[0]",
+ "src": "1.a",
+ "srcArrow": "none",
+ "dst": "1.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 253.5,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 46.39500045776367
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 113.41400146484375
+ },
+ {
+ "x": 424.25799560546875,
+ "y": 200
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(b -> c)[0]",
+ "src": "1.b",
+ "srcArrow": "none",
+ "dst": "1.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 424.25799560546875,
+ "y": 266
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 352.58599853515625
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 419.60400390625
+ },
+ {
+ "x": 253.5,
+ "y": 431.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(c -> d)[0]",
+ "src": "1.c",
+ "srcArrow": "none",
+ "dst": "1.d",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 200.5,
+ "y": 431.2359924316406
+ },
+ {
+ "x": 113.48500061035156,
+ "y": 419.60400390625
+ },
+ {
+ "x": 44.22700119018555,
+ "y": 352.58599853515625
+ },
+ {
+ "x": 29.740999221801758,
+ "y": 266
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(a -> b)[0]",
+ "src": "2.a",
+ "srcArrow": "none",
+ "dst": "2.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 699.7050170898438,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 759.4879760742188,
+ "y": 42.75600051879883
+ },
+ {
+ "x": 812.4719848632812,
+ "y": 77.30000305175781
+ },
+ {
+ "x": 843.9019775390625,
+ "y": 128.7790069580078
+ },
+ {
+ "x": 875.3330078125,
+ "y": 180.2570037841797
+ },
+ {
+ "x": 881.85400390625,
+ "y": 243.1699981689453
+ },
+ {
+ "x": 861.6480102539062,
+ "y": 299.9989929199219
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(b -> c)[0]",
+ "src": "2.b",
+ "srcArrow": "none",
+ "dst": "2.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 822.572998046875,
+ "y": 366
+ },
+ {
+ "x": 784.6229858398438,
+ "y": 408.6210021972656
+ },
+ {
+ "x": 730.2730102539062,
+ "y": 433
+ },
+ {
+ "x": 673.2050170898438,
+ "y": 433
+ },
+ {
+ "x": 616.1370239257812,
+ "y": 433
+ },
+ {
+ "x": 561.7869873046875,
+ "y": 408.6210021972656
+ },
+ {
+ "x": 523.8369750976562,
+ "y": 366
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(a -> b)[0]",
+ "src": "3.a",
+ "srcArrow": "none",
+ "dst": "3.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 954.85400390625,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 1054.18505859375,
+ "y": 48.04199981689453
+ },
+ {
+ "x": 1128.35400390625,
+ "y": 132.78599548339844
+ },
+ {
+ "x": 1128.35400390625,
+ "y": 233
+ },
+ {
+ "x": 1128.35400390625,
+ "y": 333.2139892578125
+ },
+ {
+ "x": 1054.18505859375,
+ "y": 417.9570007324219
+ },
+ {
+ "x": 954.85400390625,
+ "y": 431.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/cycle-diagram/tala/sketch.exp.svg b/e2etests/testdata/txtar/cycle-diagram/tala/sketch.exp.svg
new file mode 100644
index 0000000000..96b9c9a22a
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/tala/sketch.exp.svg
@@ -0,0 +1,30 @@
+abcdabcab
+
+
+
\ No newline at end of file
diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt
index 7585d91b3e..be247de7f7 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -1773,3 +1773,17 @@ style: {fill-pattern: dots; fill:"radial-gradient(#fbfbf8, #e3e3f0)"; stroke: "#
a->b
+
+-- cycle-diagram --
+1: "" {
+ shape: cycle
+ a -> b -> c -> d
+}
+2: "" {
+ shape: cycle
+ a -> b -> c
+}
+3: "" {
+ shape: cycle
+ a -> b
+}