From abb6579e86babb63a663ffda5516a62458c6185a Mon Sep 17 00:00:00 2001 From: nicolatrozzi Date: Thu, 21 May 2026 01:02:37 +0200 Subject: [PATCH 1/2] layout: add cycle diagram shape --- ci/release/changelogs/next.md | 1 + d2graph/cyclediagram.go | 7 + d2graph/d2graph.go | 2 +- d2layouts/d2cycle/layout.go | 337 ++++++ d2layouts/d2layouts.go | 10 + d2renderers/d2svg/d2svg.go | 2 +- d2target/d2target.go | 3 + .../txtar/cycle-diagram/dagre/board.exp.json | 992 ++++++++++++++++++ .../txtar/cycle-diagram/dagre/sketch.exp.svg | 95 ++ .../txtar/cycle-diagram/elk/board.exp.json | 992 ++++++++++++++++++ .../txtar/cycle-diagram/elk/sketch.exp.svg | 95 ++ e2etests/txtar.txt | 16 + 12 files changed, 2550 insertions(+), 2 deletions(-) create mode 100644 d2graph/cyclediagram.go create mode 100644 d2layouts/d2cycle/layout.go create mode 100644 e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json create mode 100644 e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg create mode 100644 e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json create mode 100644 e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg 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..8bb1dd7f6f --- /dev/null +++ b/d2layouts/d2cycle/layout.go @@ -0,0 +1,337 @@ +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 edges as +// cubic Bezier arcs trimmed to each shape's visible perimeter. +func Layout(_ context.Context, g *d2graph.Graph, _ 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) + + center := geo.NewPoint(0, 0) + for _, edge := range g.Edges { + routeCircularArc(edge, center) + } + normalizeGraph(g) + return nil +} + +func calculateRadius(objects []*d2graph.Object) float64 { + if len(objects) < 2 { + return minRadius + } + + 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) + obj.TopLeft = geo.NewPoint(center.X-obj.Width/2, center.Y-obj.Height/2) + } +} + +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/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 @@ +abcdabcab + + + \ 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 +} From 3f918f4c5ae5e31155207581c58cea39713c84b6 Mon Sep 17 00:00:00 2001 From: nicolatrozzi Date: Thu, 21 May 2026 09:05:54 +0200 Subject: [PATCH 2/2] layout: preserve nested cycle content --- d2layouts/d2cycle/layout.go | 46 ++++++++++-- d2layouts/d2cycle/layout_test.go | 120 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 d2layouts/d2cycle/layout_test.go diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go index 8bb1dd7f6f..5de815b95c 100644 --- a/d2layouts/d2cycle/layout.go +++ b/d2layouts/d2cycle/layout.go @@ -23,14 +23,20 @@ type anglePoint struct { point *geo.Point } -// Layout arranges the root children around a circle and routes their edges as -// cubic Bezier arcs trimmed to each shape's visible perimeter. -func Layout(_ context.Context, g *d2graph.Graph, _ d2graph.LayoutGraph) error { +// 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) } @@ -40,7 +46,9 @@ func Layout(_ context.Context, g *d2graph.Graph, _ d2graph.LayoutGraph) error { center := geo.NewPoint(0, 0) for _, edge := range g.Edges { - routeCircularArc(edge, center) + if isCycleEdge(g, edge) { + routeCircularArc(edge, center) + } } normalizeGraph(g) return nil @@ -48,7 +56,7 @@ func Layout(_ context.Context, g *d2graph.Graph, _ d2graph.LayoutGraph) error { func calculateRadius(objects []*d2graph.Object) float64 { if len(objects) < 2 { - return minRadius + return 0 } maxHalfDiagonal := 0. @@ -64,8 +72,34 @@ 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) - obj.TopLeft = geo.NewPoint(center.X-obj.Width/2, center.Y-obj.Height/2) + 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) { 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 +}