diff --git a/d2graph/cyclediagram.go b/d2graph/cyclediagram.go
new file mode 100644
index 000000000..a8f8e0b1a
--- /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/grid_diagram.go b/d2graph/griddiagram.go
similarity index 100%
rename from d2graph/grid_diagram.go
rename to d2graph/griddiagram.go
diff --git a/d2graph/seqdiagram.go b/d2graph/sequencediagram.go
similarity index 100%
rename from d2graph/seqdiagram.go
rename to d2graph/sequencediagram.go
diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go
new file mode 100644
index 000000000..d957c6814
--- /dev/null
+++ b/d2layouts/d2cycle/layout.go
@@ -0,0 +1,241 @@
+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
+ MIN_SEGMENT_LEN = 10
+ ARC_STEPS = 30 // high resolution for smooth arcs
+
+)
+
+// Layout arranges nodes in a circle, ensures label/icon positions are set,
+// then routes edges with arcs that get clipped at node borders.
+func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error {
+ objects := g.Root.ChildrenArray
+ if len(objects) == 0 {
+ return nil
+ }
+
+ // Make sure every object that has label/icon also has a default position
+ for _, obj := range g.Objects {
+ positionLabelsIcons(obj)
+ }
+
+ // Arrange objects in a circle
+ radius := calculateRadius(objects)
+ positionObjects(objects, radius)
+
+ // Create arcs
+ for _, edge := range g.Edges {
+ createCircularArc(edge)
+ }
+
+ return nil
+}
+
+func calculateRadius(objects []*d2graph.Object) float64 {
+ 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 enough radius to fit all objects
+ 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 i=0 is 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)
+ obj.TopLeft = geo.NewPoint(
+ x-obj.Box.Width/2,
+ y-obj.Box.Height/2,
+ )
+ }
+}
+
+// createCircularArc samples a smooth arc from center to center, then
+// forces the endpoints onto each shape's border, and finally calls
+// TraceToShape to clip any additional overrun.
+func createCircularArc(edge *d2graph.Edge) {
+ if edge.Src == nil || edge.Dst == nil {
+ return
+ }
+
+ srcCenter := edge.Src.Center()
+ dstCenter := edge.Dst.Center()
+
+ // angles from origin
+ srcAngle := math.Atan2(srcCenter.Y, srcCenter.X)
+ dstAngle := math.Atan2(dstCenter.Y, dstCenter.X)
+ if dstAngle < srcAngle {
+ dstAngle += 2 * math.Pi
+ }
+
+ arcRadius := math.Hypot(srcCenter.X, srcCenter.Y)
+
+ // Sample points along the arc
+ path := make([]*geo.Point, 0, ARC_STEPS+1)
+ for i := 0; i <= ARC_STEPS; i++ {
+ t := float64(i) / float64(ARC_STEPS)
+ angle := srcAngle + t*(dstAngle-srcAngle)
+ x := arcRadius * math.Cos(angle)
+ y := arcRadius * math.Sin(angle)
+ path = append(path, geo.NewPoint(x, y))
+ }
+ // Set start/end to exact centers
+ path[0] = srcCenter
+ path[len(path)-1] = dstCenter
+
+ // Use TraceToShape to clip route to node borders
+ edge.Route = path
+ startIndex, endIndex := edge.TraceToShape(edge.Route, 0, len(edge.Route)-1)
+ if startIndex < endIndex {
+ edge.Route = edge.Route[startIndex : endIndex+1]
+ }
+ edge.IsCurve = true
+}
+
+// clampPointOutsideBox walks forward from 'startIdx' until the path segment
+// leaves the bounding box. Then it sets path[startIdx] to the intersection.
+// If we never find it, we return (startIdx, path[startIdx]) meaning we can't clamp.
+func clampPointOutsideBox(box *geo.Box, path []*geo.Point, startIdx int) (int, *geo.Point) {
+ if startIdx >= len(path)-1 {
+ return startIdx, path[startIdx]
+ }
+ // If path[startIdx] is outside, no clamp needed
+ if !boxContains(box, path[startIdx]) {
+ return startIdx, path[startIdx]
+ }
+
+ // Walk forward looking for outside
+ for i := startIdx + 1; i < len(path); i++ {
+ insideNext := boxContains(box, path[i])
+ if insideNext {
+ // still inside -> keep going
+ continue
+ }
+ // crossing from inside to outside between path[i-1], path[i]
+ seg := geo.NewSegment(path[i-1], path[i])
+ inters := boxIntersections(box, *seg)
+ if len(inters) > 0 {
+ // use first intersection
+ return i, inters[0]
+ }
+ // fallback => no intersection found
+ return i, path[i]
+ }
+ // entire remainder is inside, so we can't clamp
+ // Just return the end
+ last := len(path) - 1
+ return last, path[last]
+}
+
+// clampPointOutsideBoxReverse scans backward from endIdx while path[j] is in the box.
+// Once we find crossing (outside→inside), we return (j, intersection).
+func clampPointOutsideBoxReverse(box *geo.Box, path []*geo.Point, endIdx int) (int, *geo.Point) {
+ if endIdx <= 0 {
+ return endIdx, path[endIdx]
+ }
+ if !boxContains(box, path[endIdx]) {
+ // already outside
+ return endIdx, path[endIdx]
+ }
+
+ for j := endIdx - 1; j >= 0; j-- {
+ if boxContains(box, path[j]) {
+ continue
+ }
+ // crossing from outside -> inside between path[j], path[j+1]
+ seg := geo.NewSegment(path[j], path[j+1])
+ inters := boxIntersections(box, *seg)
+ if len(inters) > 0 {
+ return j, inters[0]
+ }
+ return j, path[j]
+ }
+
+ // entire path inside
+ return 0, path[0]
+}
+
+// Helper if your geo.Box doesn’t implement Contains()
+func boxContains(b *geo.Box, p *geo.Point) bool {
+ // typical bounding-box check
+ return p.X >= b.TopLeft.X &&
+ p.X <= b.TopLeft.X+b.Width &&
+ p.Y >= b.TopLeft.Y &&
+ p.Y <= b.TopLeft.Y+b.Height
+}
+
+// Helper if your geo.Box doesn’t implement Intersections(geo.Segment) yet
+func boxIntersections(b *geo.Box, seg geo.Segment) []*geo.Point {
+ // We'll assume d2's standard geo.Box has a built-in Intersections(*Segment) method.
+ // If not, implement manually. For example, checking each of the 4 edges:
+ // left, right, top, bottom
+ // For simplicity, if you do have b.Intersections(...) you can just do:
+ // return b.Intersections(seg)
+ return b.Intersections(seg)
+ // If you don't have that, you'd code the line-rect intersection yourself.
+}
+
+// positionLabelsIcons is basically your logic that sets default label/icon positions if needed
+func positionLabelsIcons(obj *d2graph.Object) {
+ // If there's an icon but no icon position, give it a default
+ 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 there's a label but no label position, give it a default
+ 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 the label is bigger than the shape, fallback to outside positions
+ 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/d2layouts.go b/d2layouts/d2layouts.go
index 87874a6b4..b4eff3b9f 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"
@@ -20,12 +21,12 @@ import (
type DiagramType string
-// a grid diagram at a constant near is
const (
DefaultGraphType DiagramType = ""
ConstantNearGraph DiagramType = "constant-near"
GridDiagram DiagramType = "grid-diagram"
SequenceDiagram DiagramType = "sequence-diagram"
+ CycleDiagram DiagramType = "cycle-diagram"
)
type GraphInfo struct {
@@ -260,6 +261,12 @@ func LayoutNested(ctx context.Context, g *d2graph.Graph, graphInfo GraphInfo, co
if err != nil {
return err
}
+ case CycleDiagram:
+ log.Debug(ctx, "layout sequence", 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)
@@ -360,6 +367,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 153b7b345..a42ee3681 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -452,37 +452,78 @@ func getArrowheadAdjustments(connection d2target.Connection, idToShape map[strin
func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string {
var path []string
route := connection.Route
+ if len(route) == 0 {
+ return ""
+ }
+ // Move command to start
path = append(path, fmt.Sprintf("M %f %f",
route[0].X+srcAdj.X,
route[0].Y+srcAdj.Y,
))
if connection.IsCurve {
+ // If we don't have enough points to do triple-step, handle small fallback
+ if len(route) < 3 {
+ // If only 1 or 2 points in route, just draw lines
+ for _, p := range route[1:] {
+ path = append(path, fmt.Sprintf("L %f %f",
+ p.X+dstAdj.X, p.Y+dstAdj.Y,
+ ))
+ }
+ return strings.Join(path, " ")
+ }
+
i := 1
- for ; i < len(route)-3; i += 3 {
+ // Process triple curves in steps of 3
+ for ; i+2 < len(route)-1; i += 3 {
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
route[i].X, route[i].Y,
route[i+1].X, route[i+1].Y,
route[i+2].X, route[i+2].Y,
))
}
- // final curve target adjustment
- path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
- route[i].X, route[i].Y,
- route[i+1].X, route[i+1].Y,
- route[i+2].X+dstAdj.X,
- route[i+2].Y+dstAdj.Y,
- ))
+
+ // Now handle the “final” curve to last point
+ // Make sure i+2 is still within range
+ if i+2 < len(route) {
+ // last triple
+ path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
+ route[i].X, route[i].Y,
+ route[i+1].X, route[i+1].Y,
+ route[i+2].X+dstAdj.X, // final point plus dst adjustment
+ route[i+2].Y+dstAdj.Y,
+ ))
+ } else if i+1 < len(route) {
+ // We have i+1 but not i+2 => do a simpler final curve or line
+ path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
+ route[i].X, route[i].Y,
+ route[i].X, route[i].Y, // repeated for control
+ route[i+1].X+dstAdj.X,
+ route[i+1].Y+dstAdj.Y,
+ ))
+ } else {
+ // We have no final triple => do nothing or fallback line
+ }
} else {
+ // Not a curve => the "rounded corner" logic
for i := 1; i < len(route)-1; i++ {
prevSource := route[i-1]
prevTarget := route[i]
currTarget := route[i+1]
+
+ // Make sure i+1 is valid
+ if i+1 >= len(route) {
+ break
+ }
+
prevVector := prevSource.VectorTo(prevTarget)
currVector := prevTarget.VectorTo(currTarget)
- dist := geo.EuclideanDistance(prevTarget.X, prevTarget.Y, currTarget.X, currTarget.Y)
+ dist := geo.EuclideanDistance(
+ prevTarget.X, prevTarget.Y,
+ currTarget.X, currTarget.Y,
+ )
connectionBorderRadius := connection.BorderRadius
units := math.Min(connectionBorderRadius, dist/2)
@@ -490,20 +531,26 @@ func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string
prevTranslations := prevVector.Unit().Multiply(units).ToPoint()
currTranslations := currVector.Unit().Multiply(units).ToPoint()
+ // Move to corner with "L"
path = append(path, fmt.Sprintf("L %f %f",
prevTarget.X-prevTranslations.X,
prevTarget.Y-prevTranslations.Y,
))
- // If the segment length is too small, instead of drawing 2 arcs, just skip this segment and bezier curve to the next one
if units < connectionBorderRadius && i < len(route)-2 {
+ // Next checks i+2 => ensure it’s in range
+ if i+2 >= len(route) {
+ // can't do nextTarget => break or do fallback
+ continue
+ }
nextTarget := route[i+2]
- nextVector := geo.NewVector(nextTarget.X-currTarget.X, nextTarget.Y-currTarget.Y)
- i++
+ nextVector := geo.NewVector(
+ nextTarget.X-currTarget.X,
+ nextTarget.Y-currTarget.Y,
+ )
+ i++ // skip next point
nextTranslations := nextVector.Unit().Multiply(units).ToPoint()
- // These 2 bezier control points aren't just at the corner -- they are reflected at the corner, which causes the curve to be ~tangent to the corner,
- // which matches how the two arcs look
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
// Control point
prevTarget.X+prevTranslations.X,
@@ -511,7 +558,7 @@ func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string
// Control point
currTarget.X-nextTranslations.X,
currTarget.Y-nextTranslations.Y,
- // Where curve ends
+ // End
currTarget.X+nextTranslations.X,
currTarget.Y+nextTranslations.Y,
))
@@ -525,11 +572,14 @@ func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string
}
}
- lastPoint := route[len(route)-1]
- path = append(path, fmt.Sprintf("L %f %f",
- lastPoint.X+dstAdj.X,
- lastPoint.Y+dstAdj.Y,
- ))
+ // Finally, draw a line to the last route point + dst offset
+ if len(route) > 1 {
+ lastPoint := route[len(route)-1]
+ path = append(path, fmt.Sprintf("L %f %f",
+ lastPoint.X+dstAdj.X,
+ lastPoint.Y+dstAdj.Y,
+ ))
+ }
}
return strings.Join(path, " ")
diff --git a/d2target/d2target.go b/d2target/d2target.go
index 266e0f184..68e7eb331 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -942,6 +942,7 @@ const (
ShapeSQLTable = "sql_table"
ShapeImage = "image"
ShapeSequenceDiagram = "sequence_diagram"
+ ShapeCycleDiagram = "cycle"
ShapeHierarchy = "hierarchy"
)
@@ -969,6 +970,7 @@ var Shapes = []string{
ShapeSQLTable,
ShapeImage,
ShapeSequenceDiagram,
+ ShapeCycleDiagram,
ShapeHierarchy,
}
@@ -1037,6 +1039,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 000000000..21c1b7181
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json
@@ -0,0 +1,694 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": -26,
+ "y": -233
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 173,
+ "y": -33
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": -26,
+ "y": 167
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "d",
+ "type": "rectangle",
+ "pos": {
+ "x": -227,
+ "y": -32
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a -> b)[0]",
+ "src": "a",
+ "srcArrow": "none",
+ "dst": "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": 0,
+ "y": -200
+ },
+ {
+ "x": 10.467000007629395,
+ "y": -199.72500610351562
+ },
+ {
+ "x": 20.905000686645508,
+ "y": -198.9040069580078
+ },
+ {
+ "x": 31.285999298095703,
+ "y": -197.53700256347656
+ },
+ {
+ "x": 41.582000732421875,
+ "y": -195.62899780273438
+ },
+ {
+ "x": 51.76300048828125,
+ "y": -193.18499755859375
+ },
+ {
+ "x": 61.803001403808594,
+ "y": -190.21099853515625
+ },
+ {
+ "x": 71.6729965209961,
+ "y": -186.71600341796875
+ },
+ {
+ "x": 81.34700012207031,
+ "y": -182.70899963378906
+ },
+ {
+ "x": 90.7979965209961,
+ "y": -178.2010040283203
+ },
+ {
+ "x": 100,
+ "y": -173.2050018310547
+ },
+ {
+ "x": 108.927001953125,
+ "y": -167.73399353027344
+ },
+ {
+ "x": 117.55699920654297,
+ "y": -161.80299377441406
+ },
+ {
+ "x": 125.86399841308594,
+ "y": -155.4290008544922
+ },
+ {
+ "x": 133.8260040283203,
+ "y": -148.6280059814453
+ },
+ {
+ "x": 141.42100524902344,
+ "y": -141.42100524902344
+ },
+ {
+ "x": 148.6280059814453,
+ "y": -133.8260040283203
+ },
+ {
+ "x": 155.4290008544922,
+ "y": -125.86399841308594
+ },
+ {
+ "x": 161.80299377441406,
+ "y": -117.55699920654297
+ },
+ {
+ "x": 167.73399353027344,
+ "y": -108.927001953125
+ },
+ {
+ "x": 173.2050018310547,
+ "y": -100
+ },
+ {
+ "x": 178.2010040283203,
+ "y": -90.7979965209961
+ },
+ {
+ "x": 182.70899963378906,
+ "y": -81.34700012207031
+ },
+ {
+ "x": 186.71600341796875,
+ "y": -71.6729965209961
+ },
+ {
+ "x": 190.21099853515625,
+ "y": -61.803001403808594
+ },
+ {
+ "x": 193.18499755859375,
+ "y": -51.76300048828125
+ },
+ {
+ "x": 195.62899780273438,
+ "y": -41.582000732421875
+ },
+ {
+ "x": 197.53700256347656,
+ "y": -31.285999298095703
+ },
+ {
+ "x": 198.9040069580078,
+ "y": -20.905000686645508
+ },
+ {
+ "x": 199.72500610351562,
+ "y": -10.467000007629395
+ },
+ {
+ "x": 200,
+ "y": 0
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(b -> c)[0]",
+ "src": "b",
+ "srcArrow": "none",
+ "dst": "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": 200,
+ "y": 0
+ },
+ {
+ "x": 199.72500610351562,
+ "y": 10.467000007629395
+ },
+ {
+ "x": 198.9040069580078,
+ "y": 20.905000686645508
+ },
+ {
+ "x": 197.53700256347656,
+ "y": 31.285999298095703
+ },
+ {
+ "x": 195.62899780273438,
+ "y": 41.582000732421875
+ },
+ {
+ "x": 193.18499755859375,
+ "y": 51.76300048828125
+ },
+ {
+ "x": 190.21099853515625,
+ "y": 61.803001403808594
+ },
+ {
+ "x": 186.71600341796875,
+ "y": 71.6729965209961
+ },
+ {
+ "x": 182.70899963378906,
+ "y": 81.34700012207031
+ },
+ {
+ "x": 178.2010040283203,
+ "y": 90.7979965209961
+ },
+ {
+ "x": 173.2050018310547,
+ "y": 99.9990005493164
+ },
+ {
+ "x": 167.73399353027344,
+ "y": 108.927001953125
+ },
+ {
+ "x": 161.80299377441406,
+ "y": 117.55699920654297
+ },
+ {
+ "x": 155.4290008544922,
+ "y": 125.86399841308594
+ },
+ {
+ "x": 148.6280059814453,
+ "y": 133.8260040283203
+ },
+ {
+ "x": 141.42100524902344,
+ "y": 141.42100524902344
+ },
+ {
+ "x": 133.8260040283203,
+ "y": 148.6280059814453
+ },
+ {
+ "x": 125.86399841308594,
+ "y": 155.4290008544922
+ },
+ {
+ "x": 117.55699920654297,
+ "y": 161.80299377441406
+ },
+ {
+ "x": 108.927001953125,
+ "y": 167.73399353027344
+ },
+ {
+ "x": 100,
+ "y": 173.2050018310547
+ },
+ {
+ "x": 90.7979965209961,
+ "y": 178.2010040283203
+ },
+ {
+ "x": 81.34700012207031,
+ "y": 182.70899963378906
+ },
+ {
+ "x": 71.6729965209961,
+ "y": 186.71600341796875
+ },
+ {
+ "x": 61.803001403808594,
+ "y": 190.21099853515625
+ },
+ {
+ "x": 51.76300048828125,
+ "y": 193.18499755859375
+ },
+ {
+ "x": 41.582000732421875,
+ "y": 195.62899780273438
+ },
+ {
+ "x": 31.285999298095703,
+ "y": 197.53700256347656
+ },
+ {
+ "x": 20.905000686645508,
+ "y": 198.9040069580078
+ },
+ {
+ "x": 10.467000007629395,
+ "y": 199.72500610351562
+ },
+ {
+ "x": 0,
+ "y": 200
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(c -> d)[0]",
+ "src": "c",
+ "srcArrow": "none",
+ "dst": "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": 0,
+ "y": 200
+ },
+ {
+ "x": -10.467000007629395,
+ "y": 199.72500610351562
+ },
+ {
+ "x": -20.905000686645508,
+ "y": 198.9040069580078
+ },
+ {
+ "x": -31.285999298095703,
+ "y": 197.53700256347656
+ },
+ {
+ "x": -41.582000732421875,
+ "y": 195.62899780273438
+ },
+ {
+ "x": -51.76300048828125,
+ "y": 193.18499755859375
+ },
+ {
+ "x": -61.803001403808594,
+ "y": 190.21099853515625
+ },
+ {
+ "x": -71.6729965209961,
+ "y": 186.71600341796875
+ },
+ {
+ "x": -81.34700012207031,
+ "y": 182.70899963378906
+ },
+ {
+ "x": -90.7979965209961,
+ "y": 178.2010040283203
+ },
+ {
+ "x": -99.9990005493164,
+ "y": 173.2050018310547
+ },
+ {
+ "x": -108.927001953125,
+ "y": 167.73399353027344
+ },
+ {
+ "x": -117.55699920654297,
+ "y": 161.80299377441406
+ },
+ {
+ "x": -125.86399841308594,
+ "y": 155.4290008544922
+ },
+ {
+ "x": -133.8260040283203,
+ "y": 148.6280059814453
+ },
+ {
+ "x": -141.42100524902344,
+ "y": 141.42100524902344
+ },
+ {
+ "x": -148.6280059814453,
+ "y": 133.8260040283203
+ },
+ {
+ "x": -155.4290008544922,
+ "y": 125.86399841308594
+ },
+ {
+ "x": -161.80299377441406,
+ "y": 117.55699920654297
+ },
+ {
+ "x": -167.73399353027344,
+ "y": 108.927001953125
+ },
+ {
+ "x": -173.2050018310547,
+ "y": 99.9990005493164
+ },
+ {
+ "x": -178.2010040283203,
+ "y": 90.7979965209961
+ },
+ {
+ "x": -182.70899963378906,
+ "y": 81.34700012207031
+ },
+ {
+ "x": -186.71600341796875,
+ "y": 71.6729965209961
+ },
+ {
+ "x": -190.21099853515625,
+ "y": 61.803001403808594
+ },
+ {
+ "x": -193.18499755859375,
+ "y": 51.76300048828125
+ },
+ {
+ "x": -195.62899780273438,
+ "y": 41.582000732421875
+ },
+ {
+ "x": -197.53700256347656,
+ "y": 31.285999298095703
+ },
+ {
+ "x": -198.9040069580078,
+ "y": 20.905000686645508
+ },
+ {
+ "x": -199.72500610351562,
+ "y": 10.467000007629395
+ },
+ {
+ "x": -200,
+ "y": 0
+ }
+ ],
+ "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 000000000..4b55a025c
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg
@@ -0,0 +1,98 @@
+
\ 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 000000000..21c1b7181
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json
@@ -0,0 +1,694 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": -26,
+ "y": -233
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 173,
+ "y": -33
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": -26,
+ "y": 167
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ },
+ {
+ "id": "d",
+ "type": "rectangle",
+ "pos": {
+ "x": -227,
+ "y": -32
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "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": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a -> b)[0]",
+ "src": "a",
+ "srcArrow": "none",
+ "dst": "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": 0,
+ "y": -200
+ },
+ {
+ "x": 10.467000007629395,
+ "y": -199.72500610351562
+ },
+ {
+ "x": 20.905000686645508,
+ "y": -198.9040069580078
+ },
+ {
+ "x": 31.285999298095703,
+ "y": -197.53700256347656
+ },
+ {
+ "x": 41.582000732421875,
+ "y": -195.62899780273438
+ },
+ {
+ "x": 51.76300048828125,
+ "y": -193.18499755859375
+ },
+ {
+ "x": 61.803001403808594,
+ "y": -190.21099853515625
+ },
+ {
+ "x": 71.6729965209961,
+ "y": -186.71600341796875
+ },
+ {
+ "x": 81.34700012207031,
+ "y": -182.70899963378906
+ },
+ {
+ "x": 90.7979965209961,
+ "y": -178.2010040283203
+ },
+ {
+ "x": 100,
+ "y": -173.2050018310547
+ },
+ {
+ "x": 108.927001953125,
+ "y": -167.73399353027344
+ },
+ {
+ "x": 117.55699920654297,
+ "y": -161.80299377441406
+ },
+ {
+ "x": 125.86399841308594,
+ "y": -155.4290008544922
+ },
+ {
+ "x": 133.8260040283203,
+ "y": -148.6280059814453
+ },
+ {
+ "x": 141.42100524902344,
+ "y": -141.42100524902344
+ },
+ {
+ "x": 148.6280059814453,
+ "y": -133.8260040283203
+ },
+ {
+ "x": 155.4290008544922,
+ "y": -125.86399841308594
+ },
+ {
+ "x": 161.80299377441406,
+ "y": -117.55699920654297
+ },
+ {
+ "x": 167.73399353027344,
+ "y": -108.927001953125
+ },
+ {
+ "x": 173.2050018310547,
+ "y": -100
+ },
+ {
+ "x": 178.2010040283203,
+ "y": -90.7979965209961
+ },
+ {
+ "x": 182.70899963378906,
+ "y": -81.34700012207031
+ },
+ {
+ "x": 186.71600341796875,
+ "y": -71.6729965209961
+ },
+ {
+ "x": 190.21099853515625,
+ "y": -61.803001403808594
+ },
+ {
+ "x": 193.18499755859375,
+ "y": -51.76300048828125
+ },
+ {
+ "x": 195.62899780273438,
+ "y": -41.582000732421875
+ },
+ {
+ "x": 197.53700256347656,
+ "y": -31.285999298095703
+ },
+ {
+ "x": 198.9040069580078,
+ "y": -20.905000686645508
+ },
+ {
+ "x": 199.72500610351562,
+ "y": -10.467000007629395
+ },
+ {
+ "x": 200,
+ "y": 0
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(b -> c)[0]",
+ "src": "b",
+ "srcArrow": "none",
+ "dst": "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": 200,
+ "y": 0
+ },
+ {
+ "x": 199.72500610351562,
+ "y": 10.467000007629395
+ },
+ {
+ "x": 198.9040069580078,
+ "y": 20.905000686645508
+ },
+ {
+ "x": 197.53700256347656,
+ "y": 31.285999298095703
+ },
+ {
+ "x": 195.62899780273438,
+ "y": 41.582000732421875
+ },
+ {
+ "x": 193.18499755859375,
+ "y": 51.76300048828125
+ },
+ {
+ "x": 190.21099853515625,
+ "y": 61.803001403808594
+ },
+ {
+ "x": 186.71600341796875,
+ "y": 71.6729965209961
+ },
+ {
+ "x": 182.70899963378906,
+ "y": 81.34700012207031
+ },
+ {
+ "x": 178.2010040283203,
+ "y": 90.7979965209961
+ },
+ {
+ "x": 173.2050018310547,
+ "y": 99.9990005493164
+ },
+ {
+ "x": 167.73399353027344,
+ "y": 108.927001953125
+ },
+ {
+ "x": 161.80299377441406,
+ "y": 117.55699920654297
+ },
+ {
+ "x": 155.4290008544922,
+ "y": 125.86399841308594
+ },
+ {
+ "x": 148.6280059814453,
+ "y": 133.8260040283203
+ },
+ {
+ "x": 141.42100524902344,
+ "y": 141.42100524902344
+ },
+ {
+ "x": 133.8260040283203,
+ "y": 148.6280059814453
+ },
+ {
+ "x": 125.86399841308594,
+ "y": 155.4290008544922
+ },
+ {
+ "x": 117.55699920654297,
+ "y": 161.80299377441406
+ },
+ {
+ "x": 108.927001953125,
+ "y": 167.73399353027344
+ },
+ {
+ "x": 100,
+ "y": 173.2050018310547
+ },
+ {
+ "x": 90.7979965209961,
+ "y": 178.2010040283203
+ },
+ {
+ "x": 81.34700012207031,
+ "y": 182.70899963378906
+ },
+ {
+ "x": 71.6729965209961,
+ "y": 186.71600341796875
+ },
+ {
+ "x": 61.803001403808594,
+ "y": 190.21099853515625
+ },
+ {
+ "x": 51.76300048828125,
+ "y": 193.18499755859375
+ },
+ {
+ "x": 41.582000732421875,
+ "y": 195.62899780273438
+ },
+ {
+ "x": 31.285999298095703,
+ "y": 197.53700256347656
+ },
+ {
+ "x": 20.905000686645508,
+ "y": 198.9040069580078
+ },
+ {
+ "x": 10.467000007629395,
+ "y": 199.72500610351562
+ },
+ {
+ "x": 0,
+ "y": 200
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(c -> d)[0]",
+ "src": "c",
+ "srcArrow": "none",
+ "dst": "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": 0,
+ "y": 200
+ },
+ {
+ "x": -10.467000007629395,
+ "y": 199.72500610351562
+ },
+ {
+ "x": -20.905000686645508,
+ "y": 198.9040069580078
+ },
+ {
+ "x": -31.285999298095703,
+ "y": 197.53700256347656
+ },
+ {
+ "x": -41.582000732421875,
+ "y": 195.62899780273438
+ },
+ {
+ "x": -51.76300048828125,
+ "y": 193.18499755859375
+ },
+ {
+ "x": -61.803001403808594,
+ "y": 190.21099853515625
+ },
+ {
+ "x": -71.6729965209961,
+ "y": 186.71600341796875
+ },
+ {
+ "x": -81.34700012207031,
+ "y": 182.70899963378906
+ },
+ {
+ "x": -90.7979965209961,
+ "y": 178.2010040283203
+ },
+ {
+ "x": -99.9990005493164,
+ "y": 173.2050018310547
+ },
+ {
+ "x": -108.927001953125,
+ "y": 167.73399353027344
+ },
+ {
+ "x": -117.55699920654297,
+ "y": 161.80299377441406
+ },
+ {
+ "x": -125.86399841308594,
+ "y": 155.4290008544922
+ },
+ {
+ "x": -133.8260040283203,
+ "y": 148.6280059814453
+ },
+ {
+ "x": -141.42100524902344,
+ "y": 141.42100524902344
+ },
+ {
+ "x": -148.6280059814453,
+ "y": 133.8260040283203
+ },
+ {
+ "x": -155.4290008544922,
+ "y": 125.86399841308594
+ },
+ {
+ "x": -161.80299377441406,
+ "y": 117.55699920654297
+ },
+ {
+ "x": -167.73399353027344,
+ "y": 108.927001953125
+ },
+ {
+ "x": -173.2050018310547,
+ "y": 99.9990005493164
+ },
+ {
+ "x": -178.2010040283203,
+ "y": 90.7979965209961
+ },
+ {
+ "x": -182.70899963378906,
+ "y": 81.34700012207031
+ },
+ {
+ "x": -186.71600341796875,
+ "y": 71.6729965209961
+ },
+ {
+ "x": -190.21099853515625,
+ "y": 61.803001403808594
+ },
+ {
+ "x": -193.18499755859375,
+ "y": 51.76300048828125
+ },
+ {
+ "x": -195.62899780273438,
+ "y": 41.582000732421875
+ },
+ {
+ "x": -197.53700256347656,
+ "y": 31.285999298095703
+ },
+ {
+ "x": -198.9040069580078,
+ "y": 20.905000686645508
+ },
+ {
+ "x": -199.72500610351562,
+ "y": 10.467000007629395
+ },
+ {
+ "x": -200,
+ "y": 0
+ }
+ ],
+ "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 000000000..4b55a025c
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg
@@ -0,0 +1,98 @@
+abcd
+
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt
index fcc6a7619..8478a4588 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -775,3 +775,7 @@ a -> b: hello {
b -> c: {
icon: https://icons.terrastruct.com/essentials%2F213-alarm.svg
}
+
+-- cycle-diagram --
+shape: cycle
+a -> b -> c -> d
diff --git a/lib/geo/point.go b/lib/geo/point.go
index ab8e034a0..4fb108239 100644
--- a/lib/geo/point.go
+++ b/lib/geo/point.go
@@ -324,3 +324,12 @@ func RemovePoints(points Points, toRemove []bool) Points {
}
return without
}
+
+func (v Vector) Normalize() Vector {
+ length := v.Length()
+ if length == 0 {
+ // avoid dividing by 0
+ return Vector{0, 0}
+ }
+ return Vector{v[0] / length, v[1] / length}
+}