diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index f4616390ed..e9eeca7f1a 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/terrastruct/d2/pull/2663)
+- layouts: add `shape: cycle` for circular nested diagrams
#### 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/d2graph/d2graph.go b/d2graph/d2graph.go
index dbd935eb8e..68dbc8e356 100644
--- a/d2graph/d2graph.go
+++ b/d2graph/d2graph.go
@@ -545,7 +545,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..5de815b95c
--- /dev/null
+++ b/d2layouts/d2cycle/layout.go
@@ -0,0 +1,371 @@
+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/d2/lib/shape"
+ "oss.terrastruct.com/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 &&
+ edge.Src.IsDescendantOf(obj) &&
+ edge.Dst.IsDescendantOf(obj) {
+ edge.Move(dx, 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..e65f2407de
--- /dev/null
+++ b/d2layouts/d2cycle/layout_test.go
@@ -0,0 +1,120 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "oss.terrastruct.com/d2/d2compiler"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/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 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
+}
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/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go
index 4001a853bd..fccb55707e 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -1747,7 +1747,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 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..1c8e8be61c
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json
@@ -0,0 +1,992 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "four",
+ "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": "four.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": "four.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": "four.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": "four.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": "three",
+ "type": "cycle",
+ "pos": {
+ "x": 513,
+ "y": 14
+ },
+ "width": 415,
+ "height": 439,
+ "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": "three.a",
+ "type": "oval",
+ "pos": {
+ "x": 682,
+ "y": 14
+ },
+ "width": 77,
+ "height": 77,
+ "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": "three.b",
+ "type": "hexagon",
+ "pos": {
+ "x": 868,
+ "y": 318
+ },
+ "width": 50,
+ "height": 69,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N5",
+ "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": "three.c",
+ "type": "diamond",
+ "pos": {
+ "x": 524,
+ "y": 306
+ },
+ "width": 46,
+ "height": 92,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N4",
+ "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": "two",
+ "type": "cycle",
+ "pos": {
+ "x": 988,
+ "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": "two.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 988,
+ "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": "two.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 988,
+ "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": "four.(a -> b)[0]",
+ "src": "four.a",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(b -> c)[0]",
+ "src": "four.b",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(c -> d)[0]",
+ "src": "four.c",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(d -> a)[0]",
+ "src": "four.d",
+ "srcArrow": "none",
+ "dst": "four.a",
+ "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": 29.740999221801758,
+ "y": 200
+ },
+ {
+ "x": 44.22700119018555,
+ "y": 113.41400146484375
+ },
+ {
+ "x": 113.48500061035156,
+ "y": 46.39500045776367
+ },
+ {
+ "x": 200.5,
+ "y": 34.763999938964844
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(a -> b)[0]",
+ "src": "three.a",
+ "srcArrow": "none",
+ "dst": "three.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": 759.0819702148438,
+ "y": 56.20600128173828
+ },
+ {
+ "x": 816.7899780273438,
+ "y": 67.47200012207031
+ },
+ {
+ "x": 866.6190185546875,
+ "y": 103.56199645996094
+ },
+ {
+ "x": 895.3189697265625,
+ "y": 154.88099670410156
+ },
+ {
+ "x": 924.0180053710938,
+ "y": 206.1999969482422
+ },
+ {
+ "x": 928.6810302734375,
+ "y": 267.5469970703125
+ },
+ {
+ "x": 907.760986328125,
+ "y": 322.5
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(b -> c)[0]",
+ "src": "three.b",
+ "srcArrow": "none",
+ "dst": "three.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": 877.760986328125,
+ "y": 376.5
+ },
+ {
+ "x": 839.239013671875,
+ "y": 425.1719970703125
+ },
+ {
+ "x": 780.3289794921875,
+ "y": 453.260009765625
+ },
+ {
+ "x": 718.2620239257812,
+ "y": 452.4840087890625
+ },
+ {
+ "x": 656.1939697265625,
+ "y": 451.7070007324219
+ },
+ {
+ "x": 598.0050048828125,
+ "y": 422.1579895019531
+ },
+ {
+ "x": 560.760986328125,
+ "y": 372.5
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(c -> a)[0]",
+ "src": "three.c",
+ "srcArrow": "none",
+ "dst": "three.a",
+ "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": 535.760986328125,
+ "y": 328.5
+ },
+ {
+ "x": 513,
+ "y": 273.0880126953125
+ },
+ {
+ "x": 516.2730102539062,
+ "y": 210.39199829101562
+ },
+ {
+ "x": 544.6829833984375,
+ "y": 157.65199279785156
+ },
+ {
+ "x": 573.0919799804688,
+ "y": 104.91300201416016
+ },
+ {
+ "x": 623.64501953125,
+ "y": 67.68399810791016
+ },
+ {
+ "x": 682.4409790039062,
+ "y": 56.209999084472656
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "two.(a -> b)[0]",
+ "src": "two.a",
+ "srcArrow": "none",
+ "dst": "two.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": 1041,
+ "y": 34.763999938964844
+ },
+ {
+ "x": 1140.3310546875,
+ "y": 48.04199981689453
+ },
+ {
+ "x": 1214.5,
+ "y": 132.78599548339844
+ },
+ {
+ "x": 1214.5,
+ "y": 233
+ },
+ {
+ "x": 1214.5,
+ "y": 333.2139892578125
+ },
+ {
+ "x": 1140.3310546875,
+ "y": 417.9570007324219
+ },
+ {
+ "x": 1041,
+ "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..298da2b933
--- /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..244b970c4a
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json
@@ -0,0 +1,992 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "four",
+ "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": "four.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": "four.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": "four.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": "four.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": "three",
+ "type": "cycle",
+ "pos": {
+ "x": 485,
+ "y": 25
+ },
+ "width": 416,
+ "height": 440,
+ "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": "three.a",
+ "type": "oval",
+ "pos": {
+ "x": 654,
+ "y": 25
+ },
+ "width": 77,
+ "height": 77,
+ "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": "three.b",
+ "type": "hexagon",
+ "pos": {
+ "x": 841,
+ "y": 329
+ },
+ "width": 50,
+ "height": 69,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N5",
+ "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": "three.c",
+ "type": "diamond",
+ "pos": {
+ "x": 497,
+ "y": 317
+ },
+ "width": 46,
+ "height": 92,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N4",
+ "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": "two",
+ "type": "cycle",
+ "pos": {
+ "x": 921,
+ "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": "two.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 921,
+ "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": "two.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 921,
+ "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": "four.(a -> b)[0]",
+ "src": "four.a",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(b -> c)[0]",
+ "src": "four.b",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(c -> d)[0]",
+ "src": "four.c",
+ "srcArrow": "none",
+ "dst": "four.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": "four.(d -> a)[0]",
+ "src": "four.d",
+ "srcArrow": "none",
+ "dst": "four.a",
+ "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": 41.74100112915039,
+ "y": 212
+ },
+ {
+ "x": 56.22700119018555,
+ "y": 125.41400146484375
+ },
+ {
+ "x": 125.48500061035156,
+ "y": 58.39500045776367
+ },
+ {
+ "x": 212.5,
+ "y": 46.763999938964844
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(a -> b)[0]",
+ "src": "three.a",
+ "srcArrow": "none",
+ "dst": "three.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": 731.5819702148438,
+ "y": 67.5770034790039
+ },
+ {
+ "x": 789.2899780273438,
+ "y": 78.84200286865234
+ },
+ {
+ "x": 839.1190185546875,
+ "y": 114.93199920654297
+ },
+ {
+ "x": 867.8189697265625,
+ "y": 166.25100708007812
+ },
+ {
+ "x": 896.5180053710938,
+ "y": 217.57000732421875
+ },
+ {
+ "x": 901.1810302734375,
+ "y": 278.9169921875
+ },
+ {
+ "x": 880.260986328125,
+ "y": 333.8699951171875
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(b -> c)[0]",
+ "src": "three.b",
+ "srcArrow": "none",
+ "dst": "three.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": 850.260986328125,
+ "y": 387.8699951171875
+ },
+ {
+ "x": 811.739013671875,
+ "y": 436.5419921875
+ },
+ {
+ "x": 752.8289794921875,
+ "y": 464.6300048828125
+ },
+ {
+ "x": 690.7620239257812,
+ "y": 463.85400390625
+ },
+ {
+ "x": 628.6939697265625,
+ "y": 463.0780029296875
+ },
+ {
+ "x": 570.5050048828125,
+ "y": 433.52801513671875
+ },
+ {
+ "x": 533.260986328125,
+ "y": 383.8699951171875
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "three.(c -> a)[0]",
+ "src": "three.c",
+ "srcArrow": "none",
+ "dst": "three.a",
+ "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": 508.260009765625,
+ "y": 339.8699951171875
+ },
+ {
+ "x": 485.5,
+ "y": 284.4580078125
+ },
+ {
+ "x": 488.77301025390625,
+ "y": 221.76199340820312
+ },
+ {
+ "x": 517.1829833984375,
+ "y": 169.02200317382812
+ },
+ {
+ "x": 545.5919799804688,
+ "y": 116.28299713134766
+ },
+ {
+ "x": 596.14501953125,
+ "y": 79.05400085449219
+ },
+ {
+ "x": 654.9409790039062,
+ "y": 67.58000183105469
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "two.(a -> b)[0]",
+ "src": "two.a",
+ "srcArrow": "none",
+ "dst": "two.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": 974.1810302734375,
+ "y": 46.763999938964844
+ },
+ {
+ "x": 1073.511962890625,
+ "y": 60.04199981689453
+ },
+ {
+ "x": 1147.6810302734375,
+ "y": 144.78599548339844
+ },
+ {
+ "x": 1147.6810302734375,
+ "y": 245
+ },
+ {
+ "x": 1147.6810302734375,
+ "y": 345.2139892578125
+ },
+ {
+ "x": 1073.511962890625,
+ "y": 429.9570007324219
+ },
+ {
+ "x": 974.1810302734375,
+ "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..b8b652c121
--- /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..55eafa4b41 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -1773,3 +1773,19 @@ style: {fill-pattern: dots; fill:"radial-gradient(#fbfbf8, #e3e3f0)"; stroke: "#
a->b
+-- cycle-diagram --
+four: "" {
+ shape: cycle
+ a -> b -> c -> d -> a
+}
+three: "" {
+ shape: cycle
+ a: {shape: circle}
+ b: {shape: hexagon}
+ c: {shape: diamond}
+ a -> b -> c -> a
+}
+two: "" {
+ shape: cycle
+ a -> b
+}