diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 9f57e22b89..4afba7e6c8 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -1,6 +1,7 @@
#### Features 🚀
- exports: gif exports work with `animate: true` keyword [#2663](https://github.com/d2lang/d2/pull/2663)
+- d2layouts: `shape: cycle` arranges objects in a circle and routes edges as circular arcs that start and end at shape borders [#1578](https://github.com/d2lang/d2/issues/1578)
#### Improvements 🧹
diff --git a/d2graph/cyclediagram.go b/d2graph/cyclediagram.go
new file mode 100644
index 0000000000..a8f8e0b1ad
--- /dev/null
+++ b/d2graph/cyclediagram.go
@@ -0,0 +1,7 @@
+package d2graph
+
+import "oss.terrastruct.com/d2/d2target"
+
+func (obj *Object) IsCycleDiagram() bool {
+ return obj != nil && obj.Shape.Value == d2target.ShapeCycleDiagram
+}
diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go
new file mode 100644
index 0000000000..ca5d99ce23
--- /dev/null
+++ b/d2layouts/d2cycle/layout.go
@@ -0,0 +1,264 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/label"
+ "oss.terrastruct.com/util-go/go2"
+)
+
+const (
+ MIN_RADIUS = 200
+ PADDING = 20
+
+ // number of chords used to search for the arc/border crossing
+ BORDER_SEARCH_STEPS = 100
+ // bisection iterations to refine the crossing angle
+ BORDER_REFINE_STEPS = 30
+)
+
+// Layout arranges the graph's root objects on a circle and routes each edge
+// as a circular arc that starts and ends exactly on the shape borders.
+func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error {
+ objects := g.Root.ChildrenArray
+ if len(objects) == 0 {
+ return nil
+ }
+
+ for _, obj := range g.Objects {
+ positionLabelsIcons(obj)
+ }
+
+ radius := calculateRadius(objects)
+ positionObjects(objects, radius)
+
+ for _, edge := range g.Edges {
+ createCircularArc(edge)
+ }
+
+ return nil
+}
+
+func calculateRadius(objects []*d2graph.Object) float64 {
+ if len(objects) < 2 {
+ return MIN_RADIUS
+ }
+ numObjects := float64(len(objects))
+ maxSize := 0.0
+ for _, obj := range objects {
+ size := math.Max(obj.Box.Width, obj.Box.Height)
+ maxSize = math.Max(maxSize, size)
+ }
+ // ensure neighboring objects don't overlap
+ minRadius := (maxSize/2.0 + PADDING) / math.Sin(math.Pi/numObjects)
+ return math.Max(minRadius, MIN_RADIUS)
+}
+
+func positionObjects(objects []*d2graph.Object, radius float64) {
+ numObjects := float64(len(objects))
+ // offset so the first object is at the top-center
+ angleOffset := -math.Pi / 2
+
+ for i, obj := range objects {
+ angle := angleOffset + (2 * math.Pi * float64(i) / numObjects)
+
+ x := radius * math.Cos(angle)
+ y := radius * math.Sin(angle)
+
+ // center the box at (x, y), snapped to integer coordinates so the
+ // box is not shifted later when positions are truncated for export
+ obj.TopLeft = geo.NewPoint(
+ math.Round(x-obj.Box.Width/2),
+ math.Round(y-obj.Box.Height/2),
+ )
+ }
+}
+
+// createCircularArc routes an edge as a circular arc on the layout circle.
+// The arc is clipped so it starts on the source shape's border and ends on
+// the destination shape's border, then emitted as cubic Bézier segments so
+// it renders perfectly smooth.
+func createCircularArc(edge *d2graph.Edge) {
+ if edge.Src == nil || edge.Dst == nil ||
+ edge.Src.TopLeft == nil || edge.Dst.TopLeft == nil {
+ return
+ }
+
+ srcCenter := edge.Src.Center()
+ dstCenter := edge.Dst.Center()
+
+ srcAngle := math.Atan2(srcCenter.Y, srcCenter.X)
+ dstAngle := math.Atan2(dstCenter.Y, dstCenter.X)
+ // always route the arc in the direction of increasing angle
+ // (this also makes a self-referencing edge a full loop)
+ if dstAngle <= srcAngle {
+ dstAngle += 2 * math.Pi
+ }
+
+ arcRadius := (math.Hypot(srcCenter.X, srcCenter.Y) + math.Hypot(dstCenter.X, dstCenter.Y)) / 2
+
+ // clip the arc to the shape borders
+ startAngle, foundStart := findBorderCrossing(edge.Src, arcRadius, srcAngle, dstAngle)
+ endAngle, foundEnd := findBorderCrossing(edge.Dst, arcRadius, dstAngle, srcAngle)
+ if !foundStart || !foundEnd || startAngle >= endAngle {
+ // fallback: keep the center-to-center arc
+ startAngle = srcAngle
+ endAngle = dstAngle
+ }
+
+ edge.Route = arcToBeziers(arcRadius, startAngle, endAngle)
+ edge.IsCurve = true
+
+ if edge.Label.Value != "" && edge.LabelPosition == nil {
+ edge.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+}
+
+// findBorderCrossing finds the angle at which the circle of arcRadius
+// (centered at the origin) crosses obj's border, walking from fromAngle
+// (at obj's center, inside obj) toward toAngle. It reports whether a
+// crossing was found.
+func findBorderCrossing(obj *d2graph.Object, arcRadius, fromAngle, toAngle float64) (float64, bool) {
+ shape := obj.ToShape()
+ var perimeter []geo.Intersectable
+ if shape.Is("") || shape.IsRectangular() {
+ // rectangular shapes are clipped at their bounding box
+ // (they don't define a Perimeter, see shape.TraceToShapeBorder)
+ box := shape.GetBox()
+ tl := box.TopLeft
+ tr := geo.NewPoint(tl.X+box.Width, tl.Y)
+ br := geo.NewPoint(tl.X+box.Width, tl.Y+box.Height)
+ bl := geo.NewPoint(tl.X, tl.Y+box.Height)
+ perimeter = []geo.Intersectable{
+ *geo.NewSegment(tl, tr),
+ *geo.NewSegment(tr, br),
+ *geo.NewSegment(br, bl),
+ *geo.NewSegment(bl, tl),
+ }
+ } else {
+ perimeter = shape.Perimeter()
+ }
+ if len(perimeter) == 0 {
+ return 0, false
+ }
+
+ arcPoint := func(angle float64) *geo.Point {
+ return geo.NewPoint(arcRadius*math.Cos(angle), arcRadius*math.Sin(angle))
+ }
+ crosses := func(a, b float64) bool {
+ seg := *geo.NewSegment(arcPoint(a), arcPoint(b))
+ for _, side := range perimeter {
+ if len(side.Intersections(seg)) > 0 {
+ return true
+ }
+ }
+ return false
+ }
+
+ // walk chords from the center outward until one crosses the border
+ step := (toAngle - fromAngle) / BORDER_SEARCH_STEPS
+ var lo, hi float64
+ found := false
+ for i := 0; i < BORDER_SEARCH_STEPS; i++ {
+ a := fromAngle + float64(i)*step
+ b := a + step
+ if crosses(a, b) {
+ lo, hi = a, b
+ found = true
+ break
+ }
+ }
+ if !found {
+ return 0, false
+ }
+
+ // bisect [lo, hi] down to the exact crossing angle
+ // invariant: the crossing stays inside [lo, hi]
+ for i := 0; i < BORDER_REFINE_STEPS; i++ {
+ mid := (lo + hi) / 2
+ if crosses(lo, mid) {
+ hi = mid
+ } else {
+ lo = mid
+ }
+ }
+ return (lo + hi) / 2, true
+}
+
+// arcToBeziers converts the circular arc between startAngle and endAngle
+// (on the circle of the given radius centered at the origin) into a route of
+// cubic Bézier segments: [P0, C1, C2, P1, C1, C2, P2, ...].
+func arcToBeziers(radius, startAngle, endAngle float64) []*geo.Point {
+ span := endAngle - startAngle
+ // one Bézier segment per quarter turn keeps the fit accurate
+ numSegments := int(math.Ceil(span / (math.Pi / 2)))
+ if numSegments < 1 {
+ numSegments = 1
+ }
+ segmentSpan := span / float64(numSegments)
+ // standard circular-arc Bézier approximation constant
+ k := 4.0 / 3.0 * math.Tan(segmentSpan/4)
+
+ arcPoint := func(angle float64) *geo.Point {
+ return geo.NewPoint(radius*math.Cos(angle), radius*math.Sin(angle))
+ }
+ // unit tangent in the direction of increasing angle
+ tangent := func(angle float64) *geo.Point {
+ return geo.NewPoint(-math.Sin(angle), math.Cos(angle))
+ }
+
+ route := make([]*geo.Point, 0, 1+3*numSegments)
+ route = append(route, arcPoint(startAngle))
+ for i := 0; i < numSegments; i++ {
+ a0 := startAngle + float64(i)*segmentSpan
+ a1 := a0 + segmentSpan
+ p0, p1 := arcPoint(a0), arcPoint(a1)
+ t0, t1 := tangent(a0), tangent(a1)
+ route = append(route,
+ geo.NewPoint(p0.X+k*radius*t0.X, p0.Y+k*radius*t0.Y),
+ geo.NewPoint(p1.X-k*radius*t1.X, p1.Y-k*radius*t1.Y),
+ p1,
+ )
+ }
+ return route
+}
+
+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..073ad99851
--- /dev/null
+++ b/d2layouts/d2cycle/layout_test.go
@@ -0,0 +1,140 @@
+package d2cycle_test
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+
+ "oss.terrastruct.com/d2/d2compiler"
+ "oss.terrastruct.com/d2/d2layouts/d2cycle"
+ "oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/log"
+)
+
+// borderDistance returns the distance from p to the border of box (0 if p is
+// exactly on the border).
+func borderDistance(box *geo.Box, p *geo.Point) float64 {
+ x, y := box.TopLeft.X, box.TopLeft.Y
+ r, b := x+box.Width, y+box.Height
+ dx := math.Max(math.Max(x-p.X, p.X-r), 0)
+ dy := math.Max(math.Max(y-p.Y, p.Y-b), 0)
+ if dx > 0 || dy > 0 {
+ // outside: distance to the box
+ return math.Hypot(dx, dy)
+ }
+ // inside: distance to the closest side
+ return math.Min(
+ math.Min(p.X-x, r-p.X),
+ math.Min(p.Y-y, b-p.Y),
+ )
+}
+
+func TestCycleLayout(t *testing.T) {
+ input := `
+shape: cycle
+a -> b -> c -> d -> a
+`
+ g, _, err := d2compiler.Compile("", strings.NewReader(input), nil)
+ assert.Nil(t, err)
+ for _, obj := range g.Root.ChildrenArray {
+ obj.Box = geo.NewBox(nil, 100, 60)
+ }
+
+ ctx := log.WithTB(context.Background(), t)
+ err = d2cycle.Layout(ctx, g, nil)
+ assert.Nil(t, err)
+
+ // nodes are centered on a common circle around the origin
+ radii := make([]float64, len(g.Root.ChildrenArray))
+ for i, obj := range g.Root.ChildrenArray {
+ center := obj.Center()
+ radii[i] = math.Hypot(center.X, center.Y)
+ }
+ for i := 1; i < len(radii); i++ {
+ // TopLeft is snapped to integers so allow 1px of tolerance
+ assert.InDelta(t, radii[0], radii[i], 1.0)
+ }
+
+ for _, edge := range g.Edges {
+ assert.True(t, edge.IsCurve)
+ // cubic Bézier route: [P0, C1, C2, P1, ...]
+ assert.Equal(t, 1, len(edge.Route)%3)
+
+ // the route starts and ends exactly on the shape borders
+ start := edge.Route[0]
+ end := edge.Route[len(edge.Route)-1]
+ assert.Less(t, borderDistance(edge.Src.Box, start), 0.01)
+ assert.Less(t, borderDistance(edge.Dst.Box, end), 0.01)
+
+ // every anchor point lies on the same circle around the origin (the
+ // arc stays perfectly circular)
+ arcRadius := math.Hypot(start.X, start.Y)
+ for i := 3; i < len(edge.Route); i += 3 {
+ p := edge.Route[i]
+ assert.InDelta(t, arcRadius, math.Hypot(p.X, p.Y), 0.01)
+ }
+ }
+}
+
+func TestCycleLayoutNonRectangular(t *testing.T) {
+ input := `
+shape: cycle
+a: {shape: circle}
+b: {shape: hexagon}
+c
+a -> b -> c -> a
+`
+ g, _, err := d2compiler.Compile("", strings.NewReader(input), nil)
+ assert.Nil(t, err)
+ for _, obj := range g.Root.ChildrenArray {
+ obj.Box = geo.NewBox(nil, 80, 80)
+ }
+
+ ctx := log.WithTB(context.Background(), t)
+ err = d2cycle.Layout(ctx, g, nil)
+ assert.Nil(t, err)
+
+ a, has := g.Root.HasChild([]string{"a"})
+ assert.True(t, has)
+
+ for _, edge := range g.Edges {
+ assert.True(t, edge.IsCurve)
+ start := edge.Route[0]
+ end := edge.Route[len(edge.Route)-1]
+
+ // arcs at a circle shape stop on the visible circle perimeter, not
+ // its bounding box
+ if edge.Src == a {
+ center := a.Center()
+ assert.InDelta(t, a.Width/2, math.Hypot(start.X-center.X, start.Y-center.Y), 0.5)
+ }
+ if edge.Dst == a {
+ center := a.Center()
+ assert.InDelta(t, a.Width/2, math.Hypot(end.X-center.X, end.Y-center.Y), 0.5)
+ }
+ }
+}
+
+func TestCycleLayoutSingleNode(t *testing.T) {
+ // a single node must not produce an infinite radius
+ input := `
+shape: cycle
+a
+`
+ g, _, err := d2compiler.Compile("", strings.NewReader(input), nil)
+ assert.Nil(t, err)
+ for _, obj := range g.Root.ChildrenArray {
+ obj.Box = geo.NewBox(nil, 100, 60)
+ }
+
+ ctx := log.WithTB(context.Background(), t)
+ err = d2cycle.Layout(ctx, g, nil)
+ assert.Nil(t, err)
+
+ obj := g.Root.ChildrenArray[0]
+ assert.False(t, math.IsInf(obj.TopLeft.X, 0))
+ assert.False(t, math.IsInf(obj.TopLeft.Y, 0))
+}
diff --git a/d2layouts/d2layouts.go b/d2layouts/d2layouts.go
index c0d41e3973..8ab6acf8e2 100644
--- a/d2layouts/d2layouts.go
+++ b/d2layouts/d2layouts.go
@@ -9,6 +9,7 @@ import (
"strings"
"oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2layouts/d2cycle"
"oss.terrastruct.com/d2/d2layouts/d2grid"
"oss.terrastruct.com/d2/d2layouts/d2near"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
@@ -26,6 +27,7 @@ const (
ConstantNearGraph DiagramType = "constant-near"
GridDiagram DiagramType = "grid-diagram"
SequenceDiagram DiagramType = "sequence-diagram"
+ CycleDiagram DiagramType = "cycle-diagram"
)
type GraphInfo struct {
@@ -260,6 +262,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)
@@ -364,6 +372,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/d2target/d2target.go b/d2target/d2target.go
index 63fcfacbf3..08f130c803 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -1072,6 +1072,7 @@ const (
ShapeSQLTable = "sql_table"
ShapeImage = "image"
ShapeSequenceDiagram = "sequence_diagram"
+ ShapeCycleDiagram = "cycle"
ShapeHierarchy = "hierarchy"
)
@@ -1100,6 +1101,7 @@ var Shapes = []string{
ShapeSQLTable,
ShapeImage,
ShapeSequenceDiagram,
+ ShapeCycleDiagram,
ShapeHierarchy,
}
@@ -1170,6 +1172,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..3004ae4a13
--- /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": 454,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "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": -26,
+ "y": -233
+ },
+ "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": 174,
+ "y": -33
+ },
+ "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": -26,
+ "y": 167
+ },
+ "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": -227,
+ "y": -33
+ },
+ "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": 514,
+ "y": 50
+ },
+ "width": 400,
+ "height": 366,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "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": 488,
+ "y": -183
+ },
+ "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": 661,
+ "y": 117
+ },
+ "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": 314,
+ "y": 117
+ },
+ "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": 974,
+ "y": 0
+ },
+ "width": 53,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "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": 948,
+ "y": -233
+ },
+ "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": 948,
+ "y": 167
+ },
+ "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": 26.999000549316406,
+ "y": -198.42100524902344
+ },
+ {
+ "x": 113.94000244140625,
+ "y": -186.59100341796875
+ },
+ {
+ "x": 183.05299377441406,
+ "y": -119.54100036621094
+ },
+ {
+ "x": 197.51199340820312,
+ "y": -33
+ }
+ ],
+ "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": 197.51199340820312,
+ "y": 33
+ },
+ {
+ "x": 183.05299377441406,
+ "y": 119.54100036621094
+ },
+ {
+ "x": 113.94000244140625,
+ "y": 186.59100341796875
+ },
+ {
+ "x": 26.999000549316406,
+ "y": 198.42100524902344
+ }
+ ],
+ "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": -25.999000549316406,
+ "y": 198.30299377441406
+ },
+ {
+ "x": -113.22899627685547,
+ "y": 186.86599731445312
+ },
+ {
+ "x": -182.74200439453125,
+ "y": 119.7699966430664
+ },
+ {
+ "x": -197.25900268554688,
+ "y": 33
+ }
+ ],
+ "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": 540.9990234375,
+ "y": -148.29800415039062
+ },
+ {
+ "x": 600.708984375,
+ "y": -140.16799926757812
+ },
+ {
+ "x": 653.5759887695312,
+ "y": -105.56600189208984
+ },
+ {
+ "x": 684.9219970703125,
+ "y": -54.0989990234375
+ },
+ {
+ "x": 716.2670288085938,
+ "y": -2.632999897003174
+ },
+ {
+ "x": 722.7529907226562,
+ "y": 60.21699905395508
+ },
+ {
+ "x": 702.5789794921875,
+ "y": 117
+ }
+ ],
+ "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": 663.7100219726562,
+ "y": 183
+ },
+ {
+ "x": 625.7069702148438,
+ "y": 225.77699279785156
+ },
+ {
+ "x": 571.219970703125,
+ "y": 250.2550048828125
+ },
+ {
+ "x": 514,
+ "y": 250.2550048828125
+ },
+ {
+ "x": 456.77899169921875,
+ "y": 250.2550048828125
+ },
+ {
+ "x": 402.2919921875,
+ "y": 225.77699279785156
+ },
+ {
+ "x": 364.28900146484375,
+ "y": 183
+ }
+ ],
+ "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": 1001,
+ "y": -198.16900634765625
+ },
+ {
+ "x": 1100.1070556640625,
+ "y": -184.66600036621094
+ },
+ {
+ "x": 1174,
+ "y": -100.02300262451172
+ },
+ {
+ "x": 1174,
+ "y": 0
+ },
+ {
+ "x": 1174,
+ "y": 100.02300262451172
+ },
+ {
+ "x": 1100.1070556640625,
+ "y": 184.66600036621094
+ },
+ {
+ "x": 1001,
+ "y": 198.16900634765625
+ }
+ ],
+ "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..91f59882ac
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg
@@ -0,0 +1,95 @@
+
\ 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..2c3b46d11e
--- /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": "N7",
+ "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": -14,
+ "y": -221
+ },
+ "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": 186,
+ "y": -21
+ },
+ "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": -14,
+ "y": 179
+ },
+ "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": -215,
+ "y": -21
+ },
+ "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": 486,
+ "y": 62
+ },
+ "width": 400,
+ "height": 366,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "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": 460,
+ "y": -171
+ },
+ "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": 633,
+ "y": 129
+ },
+ "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": 286,
+ "y": 129
+ },
+ "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": 906,
+ "y": 12
+ },
+ "width": 53,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "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": 880,
+ "y": -221
+ },
+ "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": 880,
+ "y": 179
+ },
+ "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": 38.999000549316406,
+ "y": -186.42100524902344
+ },
+ {
+ "x": 125.94000244140625,
+ "y": -174.59100341796875
+ },
+ {
+ "x": 195.05299377441406,
+ "y": -107.54100036621094
+ },
+ {
+ "x": 209.51199340820312,
+ "y": -21
+ }
+ ],
+ "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": 209.51199340820312,
+ "y": 45
+ },
+ {
+ "x": 195.05299377441406,
+ "y": 131.54100036621094
+ },
+ {
+ "x": 125.94000244140625,
+ "y": 198.59100341796875
+ },
+ {
+ "x": 38.999000549316406,
+ "y": 210.42100524902344
+ }
+ ],
+ "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": -13.99899959564209,
+ "y": 210.30299377441406
+ },
+ {
+ "x": -101.22899627685547,
+ "y": 198.86599731445312
+ },
+ {
+ "x": -170.74200439453125,
+ "y": 131.77000427246094
+ },
+ {
+ "x": -185.25900268554688,
+ "y": 45
+ }
+ ],
+ "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": 512.9990234375,
+ "y": -136.29800415039062
+ },
+ {
+ "x": 572.708984375,
+ "y": -128.16799926757812
+ },
+ {
+ "x": 625.5759887695312,
+ "y": -93.56600189208984
+ },
+ {
+ "x": 656.9219970703125,
+ "y": -42.0989990234375
+ },
+ {
+ "x": 688.2670288085938,
+ "y": 9.366000175476074
+ },
+ {
+ "x": 694.7529907226562,
+ "y": 72.21700286865234
+ },
+ {
+ "x": 674.5789794921875,
+ "y": 129
+ }
+ ],
+ "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": 635.7100219726562,
+ "y": 195
+ },
+ {
+ "x": 597.7069702148438,
+ "y": 237.77699279785156
+ },
+ {
+ "x": 543.219970703125,
+ "y": 262.2550048828125
+ },
+ {
+ "x": 486,
+ "y": 262.2550048828125
+ },
+ {
+ "x": 428.77899169921875,
+ "y": 262.2550048828125
+ },
+ {
+ "x": 374.2919921875,
+ "y": 237.77699279785156
+ },
+ {
+ "x": 336.28900146484375,
+ "y": 195
+ }
+ ],
+ "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": 933,
+ "y": -186.16900634765625
+ },
+ {
+ "x": 1032.1070556640625,
+ "y": -172.66600036621094
+ },
+ {
+ "x": 1106,
+ "y": -88.02300262451172
+ },
+ {
+ "x": 1106,
+ "y": 12
+ },
+ {
+ "x": 1106,
+ "y": 112.02300262451172
+ },
+ {
+ "x": 1032.1070556640625,
+ "y": 196.66600036621094
+ },
+ {
+ "x": 933,
+ "y": 210.16900634765625
+ }
+ ],
+ "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..c5150f620a
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg
@@ -0,0 +1,95 @@
+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
+}