diff --git a/d2cli/main.go b/d2cli/main.go
index 6e49ad0615..98ca72f13e 100644
--- a/d2cli/main.go
+++ b/d2cli/main.go
@@ -783,7 +783,7 @@ func relink(currDiagramPath string, d *d2target.Diagram, linkToOutput map[string
if err != nil {
return err
}
- d.Shapes[i].Link = rel
+ d.Shapes[i].Link = filepath.ToSlash(rel)
break
}
}
diff --git a/d2cli/watch.go b/d2cli/watch.go
index 3e3db95b99..4b41102b41 100644
--- a/d2cli/watch.go
+++ b/d2cli/watch.go
@@ -442,7 +442,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
w.boardpathMu.Lock()
var boardPath []string
if w.boardPath != "" {
- boardPath = strings.Split(w.boardPath, string(os.PathSeparator))
+ boardPath = strings.Split(w.boardPath, "/")
}
svg, _, err := compile(ctx, w.ms, w.plugins, &fs, w.layout, w.renderOpts, w.fontFamily, w.monoFontFamily, w.animateInterval, w.inputPath, w.outputPath, boardPath, false, w.bundle, w.forceAppendix, w.pw.Browser, w.outputFormat, w.asciiMode)
w.boardpathMu.Unlock()
diff --git a/d2compiler/compile.go b/d2compiler/compile.go
index 66f6497a54..186ee8f905 100644
--- a/d2compiler/compile.go
+++ b/d2compiler/compile.go
@@ -1251,6 +1251,9 @@ func (c *compiler) validatePositionsCompatibility(g *d2graph.Graph) {
if o.OuterSequenceDiagram() != nil {
c.errorf(pos.MapKey, `position keywords cannot be used inside shape "sequence_diagram"`)
}
+ if o.OuterCycleDiagram() != nil {
+ c.errorf(pos.MapKey, `position keywords cannot be used inside shape "cycle"`)
+ }
if o.Parent.GridColumns != nil || o.Parent.GridRows != nil {
c.errorf(pos.MapKey, `position keywords cannot be used with grids`)
}
@@ -1290,6 +1293,14 @@ func (c *compiler) validateEdges(g *d2graph.Graph) {
c.errorf(edge.GetAstEdge(), "edge from sequence diagram %#v cannot enter itself", edge.Dst.AbsID())
continue
}
+ if edge.Src.IsCycleDiagram() && edge.Dst.IsDescendantOf(edge.Src) {
+ c.errorf(edge.GetAstEdge(), "edge from cycle diagram %#v cannot enter itself", edge.Src.AbsID())
+ continue
+ }
+ if edge.Dst.IsCycleDiagram() && edge.Src.IsDescendantOf(edge.Dst) {
+ c.errorf(edge.GetAstEdge(), "edge from cycle diagram %#v cannot enter itself", edge.Dst.AbsID())
+ continue
+ }
}
}
diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go
index aacae33711..b34ca38a2e 100644
--- a/d2compiler/compile_test.go
+++ b/d2compiler/compile_test.go
@@ -16,6 +16,7 @@ import (
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
+ "oss.terrastruct.com/d2/internal/testdiff"
)
func TestCompile(t *testing.T) {
@@ -3238,6 +3239,12 @@ grid.cell -> grid.cell.c: no
grid.cell -> grid.cell.c.d: no
seq -> seq.e: no
seq -> seq.e.f: no
+cycle: {
+ shape: cycle
+ e.f
+}
+cycle -> cycle.e: no
+cycle -> cycle.e.f: no
`,
expErr: `d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:13:1: edge from constant near "tl" cannot enter itself
d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:14:1: edge from constant near "tl" cannot enter itself
@@ -3246,7 +3253,9 @@ d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:18:1: edge
d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:15:1: edge from grid diagram "grid" cannot enter itself
d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:16:1: edge from grid diagram "grid" cannot enter itself
d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:19:1: edge from sequence diagram "seq" cannot enter itself
-d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:20:1: edge from sequence diagram "seq" cannot enter itself`,
+d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:20:1: edge from sequence diagram "seq" cannot enter itself
+d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:25:1: edge from cycle diagram "cycle" cannot enter itself
+d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:26:1: edge from cycle diagram "cycle" cannot enter itself`,
},
{
name: "grid_nested",
@@ -3688,6 +3697,18 @@ d2/testdata/d2compiler/TestCompile/no_arrowheads_in_shape.d2:2:3: "source-arrowh
`,
expErr: `d2/testdata/d2compiler/TestCompile/fixed-pos-shape-hierarchy.d2:4:2: position keywords cannot be used with shape "hierarchy"
d2/testdata/d2compiler/TestCompile/fixed-pos-shape-hierarchy.d2:5:2: position keywords cannot be used with shape "hierarchy"`,
+ },
+ {
+ name: "fixed-pos-shape-cycle",
+ text: `x: {
+ shape: cycle
+ a -> b
+ a.top: 20
+ a.left: 20
+}
+`,
+ expErr: `d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2:4:3: position keywords cannot be used inside shape "cycle"
+d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2:5:3: position keywords cannot be used inside shape "cycle"`,
},
{
name: "vars-in-imports",
@@ -4085,7 +4106,7 @@ svc_1.t2 -> b: do with B
Err: err,
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2compiler", t.Name()), got)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2compiler", t.Name()), got)
assert.Success(t, err)
})
}
@@ -6349,7 +6370,7 @@ func assertCompile(t *testing.T, text string, expErr string) (*d2graph.Graph, *d
Err: err,
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2compiler", t.Name()), got)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2compiler", t.Name()), got)
assert.Success(t, err)
return g, config
}
diff --git a/d2exporter/export.go b/d2exporter/export.go
index 0c0c11864c..0c09df3100 100644
--- a/d2exporter/export.go
+++ b/d2exporter/export.go
@@ -223,7 +223,7 @@ func toShape(obj *d2graph.Object, g *d2graph.Graph) d2target.Shape {
shape.Italic = text.IsItalic
shape.FontSize = text.FontSize
- if obj.IsSequenceDiagram() {
+ if obj.IsSequenceDiagram() || obj.IsCycleDiagram() {
shape.StrokeWidth = 0
}
diff --git a/d2exporter/export_test.go b/d2exporter/export_test.go
index b4f13e29a2..d1bad70874 100644
--- a/d2exporter/export_test.go
+++ b/d2exporter/export_test.go
@@ -10,7 +10,6 @@ import (
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2compiler"
@@ -20,6 +19,7 @@ import (
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2target"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
@@ -294,7 +294,7 @@ func run(t *testing.T, tc testCase) {
got.Connections[i].LabelPosition = ""
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2exporter", t.Name()), got)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2exporter", t.Name()), got)
assert.Success(t, err)
}
diff --git a/d2graph/cycle_diagram.go b/d2graph/cycle_diagram.go
new file mode 100644
index 0000000000..0b2c5d661a
--- /dev/null
+++ b/d2graph/cycle_diagram.go
@@ -0,0 +1,17 @@
+package d2graph
+
+import "oss.terrastruct.com/d2/d2target"
+
+func (obj *Object) IsCycleDiagram() bool {
+ return obj != nil && obj.Shape.Value == d2target.ShapeCycleDiagram
+}
+
+func (obj *Object) OuterCycleDiagram() *Object {
+ for obj != nil {
+ obj = obj.Parent
+ if obj.IsCycleDiagram() {
+ return obj
+ }
+ }
+ return nil
+}
diff --git a/d2ir/compile_test.go b/d2ir/compile_test.go
index 787a747e0b..0773a453ae 100644
--- a/d2ir/compile_test.go
+++ b/d2ir/compile_test.go
@@ -8,12 +8,12 @@ import (
"testing"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/mapfs"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2ir"
"oss.terrastruct.com/d2/d2parser"
+ "oss.terrastruct.com/d2/internal/testdiff"
)
func TestCompile(t *testing.T) {
@@ -74,7 +74,7 @@ func compileFS(t testing.TB, path string, mfs map[string]string) (*d2ir.Map, err
return nil, err
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2ir", t.Name()), m)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2ir", t.Name()), m)
if err != nil {
return nil, err
}
diff --git a/d2ir/import.go b/d2ir/import.go
index dff3edbee0..4e51aef5c6 100644
--- a/d2ir/import.go
+++ b/d2ir/import.go
@@ -18,13 +18,7 @@ func (c *compiler) pushImportStack(imp *d2ast.Import) (string, bool) {
return "", false
}
if len(c.importStack) > 0 {
- if path.Ext(impPath) != ".d2" {
- impPath += ".d2"
- }
-
- if !filepath.IsAbs(impPath) {
- impPath = path.Join(path.Dir(c.importStack[len(c.importStack)-1]), impPath)
- }
+ impPath = resolveImportPath(c.importStack[len(c.importStack)-1], impPath)
}
for i, p := range c.importStack {
@@ -131,13 +125,7 @@ func (c *compiler) peekImport(imp *d2ast.Import) (*Map, bool) {
}
if len(c.importStack) > 0 {
- if path.Ext(impPath) != ".d2" {
- impPath += ".d2"
- }
-
- if !filepath.IsAbs(impPath) {
- impPath = path.Join(path.Dir(c.importStack[len(c.importStack)-1]), impPath)
- }
+ impPath = resolveImportPath(c.importStack[len(c.importStack)-1], impPath)
}
var f fs.File
@@ -171,6 +159,29 @@ func (c *compiler) peekImport(imp *d2ast.Import) (*Map, bool) {
return ir, true
}
+func resolveImportPath(parentPath, impPath string) string {
+ if path.Ext(impPath) != ".d2" {
+ impPath += ".d2"
+ }
+
+ if isOSPath(parentPath) {
+ impPath = filepath.FromSlash(impPath)
+ if !filepath.IsAbs(impPath) {
+ impPath = filepath.Join(filepath.Dir(parentPath), impPath)
+ }
+ return impPath
+ }
+
+ if !path.IsAbs(impPath) {
+ impPath = path.Join(path.Dir(parentPath), impPath)
+ }
+ return impPath
+}
+
+func isOSPath(p string) bool {
+ return filepath.IsAbs(p) || strings.Contains(p, string(filepath.Separator))
+}
+
func nilScopeMap(n Node) {
switch n := n.(type) {
case *Map:
diff --git a/d2ir/import_test.go b/d2ir/import_test.go
index e9557b0ab8..a06b242d25 100644
--- a/d2ir/import_test.go
+++ b/d2ir/import_test.go
@@ -1,11 +1,15 @@
package d2ir_test
import (
+ "os"
+ "path/filepath"
+ "strings"
"testing"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/d2/d2ir"
+ "oss.terrastruct.com/d2/d2parser"
)
func testCompileImports(t *testing.T) {
@@ -124,6 +128,24 @@ label: meow`,
assertQuery(t, m, 0, 0, "wowa", "x")
},
},
+ {
+ name: "os/absolute_path",
+ run: func(t testing.TB) {
+ dir := t.TempDir()
+ indexPath := filepath.Join(dir, "index.d2")
+ err := os.WriteFile(indexPath, []byte("x: @x"), 0600)
+ assert.Success(t, err)
+ err = os.WriteFile(filepath.Join(dir, "x.d2"), []byte("shape: circle\nlabel: meow"), 0600)
+ assert.Success(t, err)
+
+ m, err := compileFile(t, indexPath)
+ assert.Success(t, err)
+ assertQuery(t, m, 3, 0, nil, "")
+ assertQuery(t, m, 2, 0, nil, "x")
+ assertQuery(t, m, 0, 0, "circle", "x.shape")
+ assertQuery(t, m, 0, 0, "meow", "x.label")
+ },
+ },
{
name: "nested/spread",
run: func(t testing.TB) {
@@ -240,7 +262,16 @@ label: meow`,
_, err := compileFS(t, "index.d2", map[string]string{
"index.d2": "...@x.d2",
})
- assert.ErrorString(t, err, `index.d2:1:1: failed to import "x.d2": open x.d2: no such file or directory`)
+ assert.Error(t, err)
+ errText := err.Error()
+ if !strings.HasPrefix(errText, `index.d2:1:1: failed to import "x.d2": open x.d2: `) {
+ t.Fatalf("unexpected import error: %v", err)
+ }
+ if !strings.Contains(errText, "no such file or directory") &&
+ !strings.Contains(errText, "The system cannot find the file specified") &&
+ !strings.Contains(errText, "file does not exist") {
+ t.Fatalf("unexpected import error: %v", err)
+ }
},
},
{
@@ -293,3 +324,18 @@ x.d2:1:7: connection missing source`)
runa(t, tca)
})
}
+
+func compileFile(t testing.TB, filePath string) (*d2ir.Map, error) {
+ t.Helper()
+
+ b, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, err
+ }
+ ast, err := d2parser.Parse(filePath, strings.NewReader(string(b)), nil)
+ if err != nil {
+ return nil, err
+ }
+ m, _, err := d2ir.Compile(ast, nil)
+ return m, err
+}
diff --git a/d2layouts/cycle_diagram_test.go b/d2layouts/cycle_diagram_test.go
new file mode 100644
index 0000000000..70eb81c466
--- /dev/null
+++ b/d2layouts/cycle_diagram_test.go
@@ -0,0 +1,120 @@
+package d2layouts
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "oss.terrastruct.com/d2/d2compiler"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/label"
+ "oss.terrastruct.com/d2/lib/log"
+ "oss.terrastruct.com/util-go/go2"
+)
+
+func TestNestedCycleInjectsExternalEdgesInsideContainer(t *testing.T) {
+ g, _, err := d2compiler.Compile("", strings.NewReader(`
+cluster: {
+ shape: cycle
+ a -> b -> c -> a
+}
+outside
+outside -> cluster.b
+cluster.c -> outside
+`), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sizeTestObjects(g)
+
+ ctx := log.WithDefault(context.Background())
+ err = LayoutNested(ctx, g, NestedGraphInfo(g.Root), fixedCycleRootLayout, cycleTestRouter)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cluster := testObjectByID(t, g, "cluster")
+ if cluster.LabelPosition == nil {
+ t.Fatal("expected nested cycle label position to be set")
+ }
+ if *cluster.LabelPosition != label.OutsideTopCenter.String() {
+ t.Fatalf("expected nested cycle label outside top center, got %q", *cluster.LabelPosition)
+ }
+ for _, obj := range g.Objects {
+ if obj != cluster && obj.IsDescendantOf(cluster) {
+ assertObjectInside(t, obj, cluster)
+ }
+ }
+ for _, edge := range g.Edges {
+ if edge.Src.IsDescendantOf(cluster) && edge.Dst.IsDescendantOf(cluster) {
+ for _, p := range edge.Route {
+ assertPointInside(t, p, cluster.Box)
+ }
+ }
+ }
+}
+
+func fixedCycleRootLayout(ctx context.Context, g *d2graph.Graph) error {
+ ensureTestLabelPositions(g.Objects)
+ x := 0.0
+ for _, obj := range g.Root.ChildrenArray {
+ obj.TopLeft = geo.NewPoint(x, 0)
+ x += obj.Width + 80
+ }
+ for _, edge := range g.Edges {
+ edge.Route = []*geo.Point{edge.Src.Center(), edge.Dst.Center()}
+ }
+ return nil
+}
+
+func cycleTestRouter(ctx context.Context, g *d2graph.Graph, edges []*d2graph.Edge) error {
+ ensureTestLabelPositions(g.Objects)
+ return DefaultRouter(ctx, g, edges)
+}
+
+func sizeTestObjects(g *d2graph.Graph) {
+ for _, obj := range g.Objects {
+ if obj.Box == nil {
+ obj.Box = geo.NewBox(geo.NewPoint(0, 0), 100, 100)
+ continue
+ }
+ obj.Box.Width = 100
+ obj.Box.Height = 100
+ }
+ ensureTestLabelPositions(g.Objects)
+}
+
+func ensureTestLabelPositions(objects []*d2graph.Object) {
+ for _, obj := range objects {
+ if obj.HasLabel() && obj.LabelPosition == nil {
+ obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+ }
+}
+
+func testObjectByID(t *testing.T, g *d2graph.Graph, id string) *d2graph.Object {
+ t.Helper()
+ for _, obj := range g.Objects {
+ if obj.ID == id {
+ return obj
+ }
+ }
+ t.Fatalf("object %q not found", id)
+ return nil
+}
+
+func assertObjectInside(t *testing.T, obj, container *d2graph.Object) {
+ t.Helper()
+ assertPointInside(t, obj.TopLeft, container.Box)
+ assertPointInside(t, geo.NewPoint(obj.TopLeft.X+obj.Width, obj.TopLeft.Y+obj.Height), container.Box)
+}
+
+func assertPointInside(t *testing.T, p *geo.Point, box *geo.Box) {
+ t.Helper()
+ if p.X < box.TopLeft.X-0.001 || p.Y < box.TopLeft.Y-0.001 ||
+ p.X > box.TopLeft.X+box.Width+0.001 ||
+ p.Y > box.TopLeft.Y+box.Height+0.001 {
+ t.Fatalf("point %v is outside box %v", p, box)
+ }
+}
diff --git a/d2layouts/d2cycle/layout.go b/d2layouts/d2cycle/layout.go
new file mode 100644
index 0000000000..fdf5a9e392
--- /dev/null
+++ b/d2layouts/d2cycle/layout.go
@@ -0,0 +1,1161 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+ "strconv"
+
+ "oss.terrastruct.com/d2/d2ast"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2target"
+ "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 = 20
+ intersectionTolerance = 0.001
+ parameterTolerance = 1e-7
+)
+
+type moveDelta struct {
+ x float64
+ y float64
+}
+
+type edgeKey struct {
+ src *d2graph.Object
+ dst *d2graph.Object
+}
+
+type cycleChain struct {
+ edges []*d2graph.Edge
+ sourceOrder int
+}
+
+func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error {
+ objects := orderedCycleObjects(g.Root.ChildrenArray, g.Edges)
+ if len(objects) == 0 {
+ return nil
+ }
+
+ if layout != nil {
+ if err := layout(ctx, g); err != nil {
+ return err
+ }
+ }
+
+ positionLabelsIcons(g.Root)
+ for _, obj := range g.Objects {
+ positionLabelsIcons(obj)
+ }
+
+ moved := positionObjects(objects, calculateRadius(objects))
+ updateEdgeRoutes(g, cycleEdgeSet(g.Root.ChildrenArray, g.Edges), moved)
+ if g.RootLevel > 0 {
+ fitRootToCycle(g)
+ }
+ return nil
+}
+
+func orderedCycleObjects(objects []*d2graph.Object, edges []*d2graph.Edge) []*d2graph.Object {
+ cycleEdges := selectedCycleEdges(objects, edges)
+ if len(cycleEdges) == 0 {
+ return objects
+ }
+ return orderObjectsByEdges(objects, cycleEdges)
+}
+
+func orderObjectsByEdges(objects []*d2graph.Object, edges []*d2graph.Edge) []*d2graph.Object {
+ var ordered []*d2graph.Object
+ inOrder := make(map[*d2graph.Object]struct{}, len(objects))
+
+ add := func(obj *d2graph.Object) {
+ if _, ok := inOrder[obj]; ok {
+ return
+ }
+ ordered = append(ordered, obj)
+ inOrder[obj] = struct{}{}
+ }
+
+ for _, edge := range edges {
+ add(edge.Src)
+ add(edge.Dst)
+ }
+ for _, obj := range objects {
+ if _, ok := inOrder[obj]; !ok {
+ ordered = append(ordered, obj)
+ }
+ }
+ return ordered
+}
+
+func cycleEdgeSet(objects []*d2graph.Object, edges []*d2graph.Edge) map[*d2graph.Edge]struct{} {
+ cycleEdges := make(map[*d2graph.Edge]struct{})
+ for _, edge := range selectedCycleEdges(objects, edges) {
+ cycleEdges[edge] = struct{}{}
+ }
+ return cycleEdges
+}
+
+func selectedCycleEdges(objects []*d2graph.Object, edges []*d2graph.Edge) []*d2graph.Edge {
+ chain := bestSourceChain(objects, edges)
+ if chain == nil {
+ return singleSourceEdges(objects, edges)
+ }
+ if chain.closed() {
+ return chain.edges
+ }
+ selected := append([]*d2graph.Edge(nil), chain.edges...)
+ seen := make(map[edgeKey]struct{}, len(chain.edges))
+ for _, edge := range chain.edges {
+ seen[edgeKey{src: edge.Src, dst: edge.Dst}] = struct{}{}
+ }
+ for _, edge := range adjacentRootEdges(orderObjectsByEdges(objects, chain.edges), edges, nil) {
+ key := edgeKey{src: edge.Src, dst: edge.Dst}
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ selected = append(selected, edge)
+ seen[key] = struct{}{}
+ }
+ return selected
+}
+
+func bestSourceChain(objects []*d2graph.Object, edges []*d2graph.Edge) *cycleChain {
+ var best *cycleChain
+ for _, chain := range sourceChains(objects, edges) {
+ if !chain.complete() {
+ continue
+ }
+ if best == nil || betterChain(chain, best) {
+ best = chain
+ }
+ }
+ return best
+}
+
+func sourceChains(objects []*d2graph.Object, edges []*d2graph.Edge) []*cycleChain {
+ rootObjects := rootObjectSet(objects)
+ byKey := make(map[*d2ast.Key]*cycleChain)
+ var chains []*cycleChain
+
+ for sourceOrder, edge := range edges {
+ if edge.Src == edge.Dst || !isRootEdge(edge, rootObjects) {
+ continue
+ }
+ for _, ref := range edge.References {
+ if ref.MapKey == nil || len(ref.MapKey.Edges) <= 1 ||
+ ref.MapKeyEdgeIndex < 0 || ref.MapKeyEdgeIndex >= len(ref.MapKey.Edges) {
+ continue
+ }
+ chain := byKey[ref.MapKey]
+ if chain == nil {
+ chain = &cycleChain{
+ edges: make([]*d2graph.Edge, len(ref.MapKey.Edges)),
+ sourceOrder: sourceOrder,
+ }
+ byKey[ref.MapKey] = chain
+ chains = append(chains, chain)
+ }
+ if chain.edges[ref.MapKeyEdgeIndex] == nil {
+ chain.edges[ref.MapKeyEdgeIndex] = edge
+ }
+ }
+ }
+ return chains
+}
+
+func (chain *cycleChain) complete() bool {
+ for _, edge := range chain.edges {
+ if edge == nil {
+ return false
+ }
+ }
+ return len(chain.edges) > 0
+}
+
+func betterChain(candidate, best *cycleChain) bool {
+ candidateClosed := candidate.closed()
+ bestClosed := best.closed()
+ if candidateClosed != bestClosed {
+ return candidateClosed
+ }
+ if len(candidate.edges) != len(best.edges) {
+ return len(candidate.edges) > len(best.edges)
+ }
+ return candidate.sourceOrder < best.sourceOrder
+}
+
+func (chain *cycleChain) closed() bool {
+ if len(chain.edges) < 2 {
+ return false
+ }
+ return chain.edges[len(chain.edges)-1].Dst == chain.edges[0].Src
+}
+
+func singleSourceEdges(objects []*d2graph.Object, edges []*d2graph.Edge) []*d2graph.Edge {
+ return adjacentRootEdges(objects, edges, func(edge *d2graph.Edge) bool {
+ return edgeStatementLen(edge) == 1
+ })
+}
+
+func adjacentRootEdges(objects []*d2graph.Object, edges []*d2graph.Edge, keep func(*d2graph.Edge) bool) []*d2graph.Edge {
+ rootObjects := rootObjectSet(objects)
+ successors := cycleSuccessors(objects)
+ seen := make(map[edgeKey]struct{})
+ var selected []*d2graph.Edge
+ for _, edge := range edges {
+ if edge.Src == edge.Dst || !isRootEdge(edge, rootObjects) {
+ continue
+ }
+ if successors[edge.Src] != edge.Dst {
+ continue
+ }
+ if keep != nil && !keep(edge) {
+ continue
+ }
+ key := edgeKey{src: edge.Src, dst: edge.Dst}
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ selected = append(selected, edge)
+ seen[key] = struct{}{}
+ }
+ return selected
+}
+
+func cycleSuccessors(objects []*d2graph.Object) map[*d2graph.Object]*d2graph.Object {
+ successors := make(map[*d2graph.Object]*d2graph.Object, len(objects))
+ if len(objects) < 2 {
+ return successors
+ }
+ for i, obj := range objects {
+ successors[obj] = objects[(i+1)%len(objects)]
+ }
+ return successors
+}
+
+func rootObjectSet(objects []*d2graph.Object) map[*d2graph.Object]struct{} {
+ rootObjects := make(map[*d2graph.Object]struct{}, len(objects))
+ for _, obj := range objects {
+ rootObjects[obj] = struct{}{}
+ }
+ return rootObjects
+}
+
+func isRootEdge(edge *d2graph.Edge, rootObjects map[*d2graph.Object]struct{}) bool {
+ _, srcOK := rootObjects[edge.Src]
+ _, dstOK := rootObjects[edge.Dst]
+ return srcOK && dstOK
+}
+
+func edgeStatementLen(edge *d2graph.Edge) int {
+ maxLen := 0
+ for _, ref := range edge.References {
+ if ref.MapKey != nil && len(ref.MapKey.Edges) > maxLen {
+ maxLen = len(ref.MapKey.Edges)
+ }
+ }
+ return maxLen
+}
+
+func calculateRadius(objects []*d2graph.Object) float64 {
+ if len(objects) == 1 {
+ return 0
+ }
+
+ maxSize := 0.0
+ for _, obj := range objects {
+ maxSize = math.Max(maxSize, objectVisualRadius(obj))
+ }
+
+ numObjects := float64(len(objects))
+ return math.Max((maxSize+padding)/math.Sin(math.Pi/numObjects), minRadius)
+}
+
+func objectVisualRadius(obj *d2graph.Object) float64 {
+ center := obj.Center()
+ maxDistance := 0.0
+ addBox := func(topLeft *geo.Point, width, height float64) {
+ if topLeft == nil {
+ return
+ }
+ for _, p := range []*geo.Point{
+ topLeft,
+ geo.NewPoint(topLeft.X+width, topLeft.Y),
+ geo.NewPoint(topLeft.X, topLeft.Y+height),
+ geo.NewPoint(topLeft.X+width, topLeft.Y+height),
+ } {
+ maxDistance = math.Max(maxDistance, geo.EuclideanDistance(center.X, center.Y, p.X, p.Y))
+ }
+ }
+
+ addBox(obj.TopLeft, obj.Width, obj.Height)
+ if obj.HasLabel() {
+ addBox(obj.GetLabelTopLeft(), float64(obj.LabelDimensions.Width), float64(obj.LabelDimensions.Height))
+ }
+ if iconTL, iconSize := objectIconBox(obj); iconTL != nil {
+ addBox(iconTL, iconSize, iconSize)
+ }
+ return maxDistance
+}
+
+func positionObjects(objects []*d2graph.Object, radius float64) map[*d2graph.Object]moveDelta {
+ moved := make(map[*d2graph.Object]moveDelta)
+ numObjects := float64(len(objects))
+ angleOffset := -math.Pi / 2
+
+ for i, obj := range objects {
+ angle := angleOffset + 2*math.Pi*float64(i)/numObjects
+ x := radius*math.Cos(angle) - obj.Box.Width/2
+ y := radius*math.Sin(angle) - obj.Box.Height/2
+ delta := moveDelta{x: x - obj.TopLeft.X, y: y - obj.TopLeft.Y}
+
+ recordMove(moved, obj, delta)
+ obj.MoveWithDescendants(delta.x, delta.y)
+ }
+ return moved
+}
+
+func recordMove(moved map[*d2graph.Object]moveDelta, obj *d2graph.Object, delta moveDelta) {
+ moved[obj] = delta
+ obj.IterDescendants(func(_, child *d2graph.Object) {
+ moved[child] = delta
+ })
+}
+
+func updateEdgeRoutes(g *d2graph.Graph, cycleEdges map[*d2graph.Edge]struct{}, moved map[*d2graph.Object]moveDelta) {
+ for _, edge := range g.Edges {
+ if isCycleEdge(g, edge, cycleEdges) {
+ createCircularArc(edge)
+ continue
+ }
+
+ srcDelta, srcMoved := moved[edge.Src]
+ dstDelta, dstMoved := moved[edge.Dst]
+ switch {
+ case len(edge.Route) == 0:
+ routeStraight(edge)
+ case srcMoved && dstMoved && srcDelta == dstDelta:
+ edge.Move(srcDelta.x, srcDelta.y)
+ case srcMoved || dstMoved:
+ routeStraight(edge)
+ }
+ }
+}
+
+func isCycleEdge(g *d2graph.Graph, edge *d2graph.Edge, cycleEdges map[*d2graph.Edge]struct{}) bool {
+ if edge.Src == nil || edge.Dst == nil ||
+ edge.Src == edge.Dst ||
+ edge.Src.Parent != g.Root ||
+ edge.Dst.Parent != g.Root {
+ return false
+ }
+ _, ok := cycleEdges[edge]
+ return ok
+}
+
+func createCircularArc(edge *d2graph.Edge) {
+ srcCenter := edge.Src.Center()
+ dstCenter := edge.Dst.Center()
+ srcAngle := math.Atan2(srcCenter.Y, srcCenter.X)
+ dstAngle := math.Atan2(dstCenter.Y, dstCenter.X)
+ if dstAngle < srcAngle {
+ dstAngle += 2 * math.Pi
+ }
+
+ radius := math.Hypot(srcCenter.X, srcCenter.Y)
+ if radius == 0 {
+ routeStraight(edge)
+ return
+ }
+
+ startAngle := trimStartAngle(edge.Src.ToShape(), radius, srcAngle, dstAngle)
+ endAngle := trimEndAngle(edge.Dst.ToShape(), radius, startAngle, dstAngle)
+ if endAngle <= startAngle {
+ routeStraight(edge)
+ return
+ }
+
+ edge.Route = cubicArcRoute(radius, startAngle, endAngle)
+ edge.IsCurve = true
+ if edge.Label.Value != "" && edge.LabelPosition == nil {
+ edge.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+}
+
+func routeStraight(edge *d2graph.Edge) {
+ if edge.Src == edge.Dst {
+ routeSelfLoop(edge)
+ return
+ }
+ edge.Route = []*geo.Point{edge.Src.Center(), edge.Dst.Center()}
+ edge.TraceToShape(edge.Route, 0, 1)
+ edge.IsCurve = false
+ if edge.Label.Value != "" {
+ edge.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+}
+
+func routeSelfLoop(edge *d2graph.Edge) {
+ center := edge.Src.Center()
+ box := edge.Src.Box
+ gap := math.Max(math.Max(box.Width, box.Height)/2, padding)
+ right := box.TopLeft.X + box.Width + gap
+ top := box.TopLeft.Y - gap
+ edge.Route = []*geo.Point{
+ center,
+ geo.NewPoint(right, center.Y),
+ geo.NewPoint(right, top),
+ geo.NewPoint(center.X, top),
+ center.Copy(),
+ }
+ edge.TraceToShape(edge.Route, 0, len(edge.Route)-1)
+ edge.IsCurve = false
+ if edge.Label.Value != "" {
+ edge.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
+ }
+}
+
+func trimStartAngle(s shape.Shape, radius, startAngle, endAngle float64) float64 {
+ if angle, ok := cycleShapeBorderAngle(s, radius, startAngle, endAngle, true); ok {
+ return angle
+ }
+
+ box := s.GetBox()
+ if !box.Contains(pointOnCircle(radius, startAngle)) {
+ return startAngle
+ }
+
+ low, high := startAngle, endAngle
+ for i := 0; i < 64; i++ {
+ mid := (low + high) / 2
+ if box.Contains(pointOnCircle(radius, mid)) {
+ low = mid
+ } else {
+ high = mid
+ }
+ }
+ return high
+}
+
+func trimEndAngle(s shape.Shape, radius, startAngle, endAngle float64) float64 {
+ if angle, ok := cycleShapeBorderAngle(s, radius, startAngle, endAngle, false); ok {
+ return angle
+ }
+
+ box := s.GetBox()
+ if !box.Contains(pointOnCircle(radius, endAngle)) {
+ return endAngle
+ }
+
+ low, high := startAngle, endAngle
+ for i := 0; i < 64; i++ {
+ mid := (low + high) / 2
+ if box.Contains(pointOnCircle(radius, mid)) {
+ high = mid
+ } else {
+ low = mid
+ }
+ }
+ return high
+}
+
+func cycleShapeBorderAngle(s shape.Shape, radius, startAngle, endAngle float64, pickStart bool) (float64, bool) {
+ var intersections []*geo.Point
+ if s.GetType() == shape.CIRCLE_TYPE {
+ intersections = cycleCircleIntersections(radius, s.GetBox())
+ } else if s.IsRectangular() {
+ intersections = cycleSegmentIntersections(radius, boxSegments(s.GetBox()))
+ } else {
+ intersections = cyclePerimeterIntersections(radius, s.Perimeter())
+ if len(intersections) == 0 {
+ return cycleEllipseBorderAngle(s.Perimeter(), radius, startAngle, endAngle, pickStart)
+ }
+ }
+
+ best := 0.0
+ found := false
+ for _, p := range intersections {
+ angle := normalizeAngleInRange(math.Atan2(p.Y, p.X), startAngle, endAngle)
+ if angle < startAngle || angle > endAngle {
+ continue
+ }
+ if !found || (pickStart && angle < best) || (!pickStart && angle > best) {
+ best = angle
+ found = true
+ }
+ }
+ return best, found
+}
+
+func cycleEllipseBorderAngle(perimeter []geo.Intersectable, radius, startAngle, endAngle float64, pickStart bool) (float64, bool) {
+ if len(perimeter) != 1 {
+ return 0, false
+ }
+ ellipse, ok := perimeter[0].(*geo.Ellipse)
+ if !ok {
+ return 0, false
+ }
+
+ if pickStart {
+ if !ellipseContains(ellipse, pointOnCircle(radius, startAngle)) ||
+ ellipseContains(ellipse, pointOnCircle(radius, endAngle)) {
+ return 0, false
+ }
+ } else {
+ if ellipseContains(ellipse, pointOnCircle(radius, startAngle)) ||
+ !ellipseContains(ellipse, pointOnCircle(radius, endAngle)) {
+ return 0, false
+ }
+ }
+
+ low, high := startAngle, endAngle
+ for i := 0; i < 64; i++ {
+ mid := (low + high) / 2
+ inside := ellipseContains(ellipse, pointOnCircle(radius, mid))
+ if pickStart {
+ if inside {
+ low = mid
+ } else {
+ high = mid
+ }
+ } else {
+ if inside {
+ high = mid
+ } else {
+ low = mid
+ }
+ }
+ }
+ return high, true
+}
+
+func ellipseContains(ellipse *geo.Ellipse, p *geo.Point) bool {
+ if ellipse.Rx <= 0 || ellipse.Ry <= 0 {
+ return false
+ }
+ dx := (p.X - ellipse.Center.X) / ellipse.Rx
+ dy := (p.Y - ellipse.Center.Y) / ellipse.Ry
+ return dx*dx+dy*dy <= 1
+}
+
+func cycleCircleIntersections(radius float64, box *geo.Box) []*geo.Point {
+ return circleCircleIntersections(radius, box.Center(), box.Width/2)
+}
+
+func circleCircleIntersections(radius float64, center *geo.Point, shapeRadius float64) []*geo.Point {
+ d := math.Hypot(center.X, center.Y)
+ if d == 0 || shapeRadius <= 0 {
+ return nil
+ }
+
+ a := (radius*radius - shapeRadius*shapeRadius + d*d) / (2 * d)
+ h2 := radius*radius - a*a
+ if h2 < 0 {
+ if h2 > -intersectionTolerance {
+ h2 = 0
+ } else {
+ return nil
+ }
+ }
+
+ h := math.Sqrt(h2)
+ x2 := a * center.X / d
+ y2 := a * center.Y / d
+ rx := -center.Y * h / d
+ ry := center.X * h / d
+ return []*geo.Point{
+ geo.NewPoint(x2+rx, y2+ry),
+ geo.NewPoint(x2-rx, y2-ry),
+ }
+}
+
+func cyclePerimeterIntersections(radius float64, perimeter []geo.Intersectable) []*geo.Point {
+ var intersections []*geo.Point
+ for _, side := range perimeter {
+ switch side := side.(type) {
+ case *geo.Segment:
+ intersections = append(intersections, circleSegmentIntersections(radius, side)...)
+ case geo.Segment:
+ intersections = append(intersections, circleSegmentIntersections(radius, &side)...)
+ case *geo.BezierCurve:
+ intersections = append(intersections, circleBezierIntersections(radius, side)...)
+ case geo.BezierCurve:
+ intersections = append(intersections, circleBezierIntersections(radius, &side)...)
+ case *geo.Ellipse:
+ intersections = append(intersections, cycleEllipseIntersections(radius, side)...)
+ case geo.Ellipse:
+ intersections = append(intersections, cycleEllipseIntersections(radius, &side)...)
+ }
+ }
+ return intersections
+}
+
+func cycleEllipseIntersections(radius float64, ellipse *geo.Ellipse) []*geo.Point {
+ if math.Abs(ellipse.Rx-ellipse.Ry) <= intersectionTolerance {
+ return circleCircleIntersections(radius, ellipse.Center, ellipse.Rx)
+ }
+ return nil
+}
+
+func circleBezierIntersections(radius float64, curve *geo.BezierCurve) []*geo.Point {
+ var intersections []*geo.Point
+ // Solve the circle/curve intersection directly so tangencies are stable.
+ // Sampling can miss the exact point where a curved edge meets the cycle.
+ for _, t := range polynomialRootsInUnit(bezierCirclePolynomial(radius, curve.Points())) {
+ if math.Abs(circleOffset(radius, curve.At(t))) <= intersectionTolerance {
+ intersections = appendUniquePoint(intersections, curve.At(t))
+ }
+ }
+ return intersections
+}
+
+func circleOffset(radius float64, p *geo.Point) float64 {
+ return math.Hypot(p.X, p.Y) - radius
+}
+
+func bezierCirclePolynomial(radius float64, points []*geo.Point) []float64 {
+ if len(points) != 4 {
+ return nil
+ }
+ x := cubicPowerCoefficients(points[0].X, points[1].X, points[2].X, points[3].X)
+ y := cubicPowerCoefficients(points[0].Y, points[1].Y, points[2].Y, points[3].Y)
+ coeffs := polynomialAdd(polynomialMultiply(x, x), polynomialMultiply(y, y))
+ coeffs[0] -= radius * radius
+ return coeffs
+}
+
+func cubicPowerCoefficients(p0, p1, p2, p3 float64) []float64 {
+ return []float64{
+ p0,
+ -3*p0 + 3*p1,
+ 3*p0 - 6*p1 + 3*p2,
+ -p0 + 3*p1 - 3*p2 + p3,
+ }
+}
+
+func polynomialRootsInUnit(coeffs []float64) []float64 {
+ coeffs = trimPolynomial(coeffs)
+ degree := len(coeffs) - 1
+ if degree <= 0 {
+ return nil
+ }
+ if degree == 1 {
+ if coeffs[1] == 0 {
+ return nil
+ }
+ root := -coeffs[0] / coeffs[1]
+ if root >= -intersectionTolerance && root <= 1+intersectionTolerance {
+ return []float64{clampUnit(root)}
+ }
+ return nil
+ }
+
+ valueTolerance := polynomialValueTolerance(coeffs)
+ breaks := []float64{0}
+ for _, root := range polynomialRootsInUnit(polynomialDerivative(coeffs)) {
+ if root > parameterTolerance && root < 1-parameterTolerance {
+ breaks = appendUniqueFloat(breaks, root)
+ }
+ }
+ breaks = appendUniqueFloat(breaks, 1)
+
+ var roots []float64
+ for _, t := range breaks {
+ if math.Abs(polynomialEval(coeffs, t)) <= valueTolerance {
+ roots = appendUniqueFloat(roots, clampUnit(t))
+ }
+ }
+ for i := 0; i+1 < len(breaks); i++ {
+ low, high := breaks[i], breaks[i+1]
+ lowValue := polynomialEval(coeffs, low)
+ highValue := polynomialEval(coeffs, high)
+ if lowValue*highValue >= 0 {
+ continue
+ }
+ roots = appendUniqueFloat(roots, bisectPolynomialRoot(coeffs, low, high, lowValue))
+ }
+ return roots
+}
+
+func bisectPolynomialRoot(coeffs []float64, low, high, lowValue float64) float64 {
+ for i := 0; i < 64; i++ {
+ mid := (low + high) / 2
+ midValue := polynomialEval(coeffs, mid)
+ if math.Abs(midValue) <= polynomialValueTolerance(coeffs) {
+ return mid
+ }
+ if lowValue*midValue > 0 {
+ low = mid
+ lowValue = midValue
+ } else {
+ high = mid
+ }
+ }
+ return (low + high) / 2
+}
+
+func polynomialDerivative(coeffs []float64) []float64 {
+ if len(coeffs) <= 1 {
+ return nil
+ }
+ derivative := make([]float64, len(coeffs)-1)
+ for i := 1; i < len(coeffs); i++ {
+ derivative[i-1] = coeffs[i] * float64(i)
+ }
+ return derivative
+}
+
+func polynomialMultiply(a, b []float64) []float64 {
+ product := make([]float64, len(a)+len(b)-1)
+ for i := range a {
+ for j := range b {
+ product[i+j] += a[i] * b[j]
+ }
+ }
+ return product
+}
+
+func polynomialAdd(a, b []float64) []float64 {
+ sum := make([]float64, max(len(a), len(b)))
+ for i := range a {
+ sum[i] += a[i]
+ }
+ for i := range b {
+ sum[i] += b[i]
+ }
+ return sum
+}
+
+func polynomialEval(coeffs []float64, t float64) float64 {
+ value := 0.0
+ for i := len(coeffs) - 1; i >= 0; i-- {
+ value = value*t + coeffs[i]
+ }
+ return value
+}
+
+func trimPolynomial(coeffs []float64) []float64 {
+ tolerance := polynomialValueTolerance(coeffs)
+ for len(coeffs) > 0 && math.Abs(coeffs[len(coeffs)-1]) <= tolerance {
+ coeffs = coeffs[:len(coeffs)-1]
+ }
+ return coeffs
+}
+
+func polynomialValueTolerance(coeffs []float64) float64 {
+ maxCoeff := 0.0
+ for _, coeff := range coeffs {
+ maxCoeff = math.Max(maxCoeff, math.Abs(coeff))
+ }
+ return math.Max(1e-9, maxCoeff*1e-10)
+}
+
+func appendUniqueFloat(values []float64, value float64) []float64 {
+ value = clampUnit(value)
+ for _, existing := range values {
+ if math.Abs(existing-value) <= parameterTolerance {
+ return values
+ }
+ }
+ values = append(values, value)
+ for i := len(values) - 1; i > 0 && values[i] < values[i-1]; i-- {
+ values[i], values[i-1] = values[i-1], values[i]
+ }
+ return values
+}
+
+func clampUnit(value float64) float64 {
+ if value < 0 {
+ return 0
+ }
+ if value > 1 {
+ return 1
+ }
+ return value
+}
+
+func appendUniquePoint(points []*geo.Point, p *geo.Point) []*geo.Point {
+ for _, existing := range points {
+ if geo.EuclideanDistance(existing.X, existing.Y, p.X, p.Y) <= intersectionTolerance {
+ return points
+ }
+ }
+ return append(points, p)
+}
+
+func cycleSegmentIntersections(radius float64, segments []*geo.Segment) []*geo.Point {
+ var intersections []*geo.Point
+ for _, segment := range segments {
+ intersections = append(intersections, circleSegmentIntersections(radius, segment)...)
+ }
+ return intersections
+}
+
+func circleSegmentIntersections(radius float64, segment *geo.Segment) []*geo.Point {
+ dx := segment.End.X - segment.Start.X
+ dy := segment.End.Y - segment.Start.Y
+ a := dx*dx + dy*dy
+ if a == 0 {
+ return nil
+ }
+
+ b := 2 * (segment.Start.X*dx + segment.Start.Y*dy)
+ c := segment.Start.X*segment.Start.X + segment.Start.Y*segment.Start.Y - radius*radius
+ discriminant := b*b - 4*a*c
+ if discriminant < 0 {
+ if discriminant > -intersectionTolerance {
+ discriminant = 0
+ } else {
+ return nil
+ }
+ }
+
+ root := math.Sqrt(discriminant)
+ var intersections []*geo.Point
+ for _, t := range []float64{(-b - root) / (2 * a), (-b + root) / (2 * a)} {
+ if t < -intersectionTolerance || t > 1+intersectionTolerance {
+ continue
+ }
+ if t < 0 {
+ t = 0
+ } else if t > 1 {
+ t = 1
+ }
+ intersections = append(intersections, geo.NewPoint(
+ segment.Start.X+t*dx,
+ segment.Start.Y+t*dy,
+ ))
+ if root == 0 {
+ break
+ }
+ }
+ return intersections
+}
+
+func boxSegments(box *geo.Box) []*geo.Segment {
+ tl := box.TopLeft
+ tr := geo.NewPoint(tl.X+box.Width, tl.Y)
+ br := geo.NewPoint(tr.X, tr.Y+box.Height)
+ bl := geo.NewPoint(tl.X, br.Y)
+ return []*geo.Segment{
+ geo.NewSegment(tl, tr),
+ geo.NewSegment(tr, br),
+ geo.NewSegment(br, bl),
+ geo.NewSegment(bl, tl),
+ }
+}
+
+func normalizeAngleInRange(angle, startAngle, endAngle float64) float64 {
+ for angle < startAngle {
+ angle += 2 * math.Pi
+ }
+ for angle > endAngle && angle-2*math.Pi >= startAngle {
+ angle -= 2 * math.Pi
+ }
+ return angle
+}
+
+func cubicArcRoute(radius, startAngle, endAngle float64) []*geo.Point {
+ segments := int(math.Ceil((endAngle - startAngle) / (math.Pi / 2)))
+ step := (endAngle - startAngle) / float64(segments)
+
+ route := []*geo.Point{pointOnCircle(radius, startAngle)}
+ for i := 0; i < segments; i++ {
+ a1 := startAngle + float64(i)*step
+ route = append(route, cubicArcSegment(radius, a1, a1+step)...)
+ }
+ return route
+}
+
+func cubicArcSegment(radius, startAngle, endAngle float64) []*geo.Point {
+ delta := endAngle - startAngle
+ k := 4.0 / 3.0 * math.Tan(delta/4.0)
+
+ p0 := pointOnCircle(radius, startAngle)
+ p3 := pointOnCircle(radius, endAngle)
+ p1 := geo.NewPoint(
+ p0.X-k*radius*math.Sin(startAngle),
+ p0.Y+k*radius*math.Cos(startAngle),
+ )
+ p2 := geo.NewPoint(
+ p3.X+k*radius*math.Sin(endAngle),
+ p3.Y-k*radius*math.Cos(endAngle),
+ )
+ return []*geo.Point{p1, p2, p3}
+}
+
+func pointOnCircle(radius, angle float64) *geo.Point {
+ return geo.NewPoint(radius*math.Cos(angle), radius*math.Sin(angle))
+}
+
+func fitRootToCycle(g *d2graph.Graph) {
+ tl, br := cycleBounds(g)
+ if math.IsInf(tl.X, 0) || math.IsInf(tl.Y, 0) ||
+ math.IsInf(br.X, 0) || math.IsInf(br.Y, 0) {
+ return
+ }
+
+ dx := -tl.X
+ dy := -tl.Y
+ if dx != 0 || dy != 0 {
+ for _, obj := range g.Root.ChildrenArray {
+ obj.MoveWithDescendants(dx, 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 cycleBounds(g *d2graph.Graph) (tl, br *geo.Point) {
+ tl = geo.NewPoint(math.Inf(1), math.Inf(1))
+ br = geo.NewPoint(math.Inf(-1), math.Inf(-1))
+
+ addPoint := func(p *geo.Point) {
+ if p == nil {
+ return
+ }
+ tl.X = math.Min(tl.X, p.X)
+ tl.Y = math.Min(tl.Y, p.Y)
+ br.X = math.Max(br.X, p.X)
+ br.Y = math.Max(br.Y, p.Y)
+ }
+ addBox := func(topLeft *geo.Point, width, height float64) {
+ if topLeft == nil {
+ return
+ }
+ addPoint(topLeft)
+ addPoint(geo.NewPoint(topLeft.X+width, topLeft.Y+height))
+ }
+
+ for _, obj := range g.Objects {
+ if obj.TopLeft == nil {
+ continue
+ }
+ addPoint(obj.TopLeft)
+ addPoint(geo.NewPoint(obj.TopLeft.X+obj.Width, obj.TopLeft.Y+obj.Height))
+ if obj.HasLabel() {
+ addBox(obj.GetLabelTopLeft(), float64(obj.LabelDimensions.Width), float64(obj.LabelDimensions.Height))
+ }
+ if iconTL, iconSize := objectIconBox(obj); iconTL != nil {
+ addBox(iconTL, iconSize, iconSize)
+ }
+ }
+ for _, edge := range g.Edges {
+ for _, p := range edge.Route {
+ addPoint(p)
+ }
+ if labelTL, width, height := edgeLabelBox(edge); labelTL != nil {
+ addBox(labelTL, width, height)
+ }
+ if iconTL, width, height := edgeIconBox(edge); iconTL != nil {
+ addBox(iconTL, width, height)
+ }
+ if labelTL, width, height := edgeArrowheadLabelBox(edge, false); labelTL != nil {
+ addBox(labelTL, width, height)
+ }
+ if labelTL, width, height := edgeArrowheadLabelBox(edge, true); labelTL != nil {
+ addBox(labelTL, width, height)
+ }
+ }
+ return tl, br
+}
+
+func objectIconBox(obj *d2graph.Object) (*geo.Point, float64) {
+ if !obj.HasIcon() || obj.IconPosition == nil {
+ return nil, 0
+ }
+ iconPosition := label.FromString(*obj.IconPosition)
+ box := obj.ToShape().GetBox()
+ if !iconPosition.IsOutside() {
+ box = obj.ToShape().GetInnerBox()
+ }
+ iconSize := float64(d2target.GetIconSize(box, *obj.IconPosition))
+ return iconPosition.GetPointOnBox(box, label.PADDING, iconSize, iconSize), iconSize
+}
+
+func edgeLabelBox(edge *d2graph.Edge) (*geo.Point, float64, float64) {
+ if edge.Label.Value == "" || len(edge.Route) < 2 {
+ return nil, 0, 0
+ }
+ labelPosition := label.InsideMiddleCenter
+ if edge.LabelPosition != nil {
+ labelPosition = label.FromString(*edge.LabelPosition)
+ }
+ if labelPosition == label.Unset {
+ labelPosition = label.InsideMiddleCenter
+ }
+ labelPercentage := 0.0
+ if edge.LabelPercentage != nil {
+ labelPercentage = *edge.LabelPercentage
+ }
+ width := float64(edge.LabelDimensions.Width)
+ height := float64(edge.LabelDimensions.Height)
+ point, _ := labelPosition.GetPointOnRoute(edge.Route, 2, labelPercentage, width, height)
+ return point, width, height
+}
+
+func edgeIconBox(edge *d2graph.Edge) (*geo.Point, float64, float64) {
+ if edge.Icon == nil || len(edge.Route) < 2 {
+ return nil, 0, 0
+ }
+ connection := edgeTargetConnection(edge)
+ connection.Icon = edge.Icon
+ if edge.IconPosition != nil {
+ if position, ok := d2ast.LabelPositionsMapping[edge.IconPosition.Value]; ok {
+ connection.IconPosition = position.String()
+ } else {
+ connection.IconPosition = label.FromString(edge.IconPosition.Value).String()
+ }
+ } else {
+ connection.IconPosition = label.InsideMiddleCenter.String()
+ }
+ return connection.GetIconPosition(), d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE
+}
+
+func edgeArrowheadLabelBox(edge *d2graph.Edge, isDst bool) (*geo.Point, float64, float64) {
+ if len(edge.Route) < 2 {
+ return nil, 0, 0
+ }
+
+ var attrs *d2graph.Attributes
+ if isDst {
+ attrs = edge.DstArrowhead
+ } else {
+ attrs = edge.SrcArrowhead
+ }
+ if attrs == nil || attrs.Label.Value == "" {
+ return nil, 0, 0
+ }
+
+ connection := edgeTargetConnection(edge)
+ width := attrs.LabelDimensions.Width
+ height := attrs.LabelDimensions.Height
+ text := &d2target.Text{
+ Label: attrs.Label.Value,
+ LabelWidth: width,
+ LabelHeight: height,
+ }
+ if isDst {
+ connection.DstLabel = text
+ } else {
+ connection.SrcLabel = text
+ }
+ return connection.GetArrowheadLabelPosition(isDst), float64(width), float64(height)
+}
+
+func edgeTargetConnection(edge *d2graph.Edge) *d2target.Connection {
+ connection := d2target.BaseConnection()
+ connection.Route = edge.Route
+ if edge.Label.Value != "" {
+ connection.Label = edge.Label.Value
+ connection.LabelWidth = edge.LabelDimensions.Width
+ connection.LabelHeight = edge.LabelDimensions.Height
+ }
+ if edge.LabelPosition != nil {
+ connection.LabelPosition = *edge.LabelPosition
+ } else {
+ connection.LabelPosition = label.InsideMiddleCenter.String()
+ }
+ if edge.LabelPercentage != nil {
+ connection.LabelPercentage = *edge.LabelPercentage
+ }
+ if edge.Style.StrokeWidth != nil {
+ if strokeWidth, err := strconv.Atoi(edge.Style.StrokeWidth.Value); err == nil {
+ connection.StrokeWidth = strokeWidth
+ }
+ }
+ if edge.SrcArrow {
+ connection.SrcArrow = d2target.DefaultArrowhead
+ if edge.SrcArrowhead != nil {
+ connection.SrcArrow = edge.SrcArrowhead.ToArrowhead()
+ }
+ }
+ if edge.DstArrow {
+ connection.DstArrow = d2target.DefaultArrowhead
+ if edge.DstArrowhead != nil {
+ connection.DstArrow = edge.DstArrowhead.ToArrowhead()
+ }
+ }
+ return connection
+}
+
+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())
+ }
+ } 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.IsCycleDiagram() && len(obj.ChildrenArray) > 0 && obj.HasLabel() && obj.Attributes.LabelPosition == nil {
+ obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.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())
+ }
+ }
+ }
+ avoidLabelIconOverlap(obj)
+}
+
+func avoidLabelIconOverlap(obj *d2graph.Object) {
+ if obj.Attributes.LabelPosition != nil || !obj.HasLabel() || !obj.HasIcon() ||
+ obj.LabelPosition == nil || obj.IconPosition == nil {
+ return
+ }
+ labelTL := obj.GetLabelTopLeft()
+ iconTL, iconSize := objectIconBox(obj)
+ if labelTL == nil || iconTL == nil {
+ return
+ }
+ labelBox := geo.Box{
+ TopLeft: labelTL,
+ Width: float64(obj.LabelDimensions.Width),
+ Height: float64(obj.LabelDimensions.Height),
+ }
+ iconBox := geo.Box{
+ TopLeft: iconTL,
+ Width: iconSize,
+ Height: iconSize,
+ }
+ if boxesIntersect(labelBox, iconBox) {
+ obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
+ }
+}
+
+func boxesIntersect(a, b geo.Box) bool {
+ return a.TopLeft.X < b.TopLeft.X+b.Width &&
+ a.TopLeft.X+a.Width > b.TopLeft.X &&
+ a.TopLeft.Y < b.TopLeft.Y+b.Height &&
+ a.TopLeft.Y+a.Height > b.TopLeft.Y
+}
diff --git a/d2layouts/d2cycle/layout_test.go b/d2layouts/d2cycle/layout_test.go
new file mode 100644
index 0000000000..ab5d308d80
--- /dev/null
+++ b/d2layouts/d2cycle/layout_test.go
@@ -0,0 +1,1003 @@
+package d2cycle
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "oss.terrastruct.com/d2/d2compiler"
+ "oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2target"
+ "oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/label"
+ "oss.terrastruct.com/d2/lib/shape"
+)
+
+func TestCycleEdgesStartOnNonRectangularShapeBorder(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a.shape: circle
+a -> b -> c
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ var edgeStart *geo.Point
+ var srcCenter *geo.Point
+ for _, edge := range g.Edges {
+ if edge.Src.ID == "a" {
+ edgeStart = edge.Route[0]
+ srcCenter = edge.Src.Center()
+ break
+ }
+ }
+ if edgeStart == nil {
+ t.Fatal("expected edge from a")
+ }
+
+ got := geo.EuclideanDistance(srcCenter.X, srcCenter.Y, edgeStart.X, edgeStart.Y)
+ if math.Abs(got-50) > 0.5 {
+ t.Fatalf("expected cycle edge to start on circle border radius 50, got %.2f", got)
+ }
+}
+
+func TestCompileCycleSetsRootShape(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b
+`)
+
+ if !g.Root.IsCycleDiagram() {
+ t.Fatalf("expected root shape cycle, got %q", g.Root.Shape.Value)
+ }
+ for _, obj := range g.Objects {
+ if obj.ID == "shape" {
+ t.Fatalf("reserved shape key compiled as object: %v", obj)
+ }
+ }
+}
+
+func TestCycleEdgeEndpointsRoundToVisibleBoxBorder(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c -> d -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ assertRoundedOnBoxBorder(t, edge.Route[0], edge.Src)
+ assertRoundedOnBoxBorder(t, edge.Route[len(edge.Route)-1], edge.Dst)
+ }
+}
+
+func TestCycleEdgesEndOnCircleAndHexagonBorders(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a.shape: circle
+b.shape: hexagon
+c.shape: circle
+a -> b -> c -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ assertOnActualShapeBorder(t, edge.Route[0], edge.Src)
+ assertOnActualShapeBorder(t, edge.Route[len(edge.Route)-1], edge.Dst)
+ }
+}
+
+func TestCycleEdgesEndOnBezierShapeBorders(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a.shape: cloud
+b.shape: cylinder
+c.shape: queue
+d.shape: document
+a -> b -> c -> d -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ assertOnActualShapeBorder(t, edge.Route[0], edge.Src)
+ assertOnActualShapeBorder(t, edge.Route[len(edge.Route)-1], edge.Dst)
+ }
+}
+
+func TestCircleBezierIntersectionsFindsOffSampleTangent(t *testing.T) {
+ radius := 100.0
+ curve := straightBezier(geo.NewPoint(-100.6, radius), geo.NewPoint(99.4, radius))
+
+ points := circleBezierIntersections(radius, curve)
+
+ assertHasPointNear(t, points, geo.NewPoint(0, radius), 0.001)
+}
+
+func TestCircleBezierIntersectionsKeepsCloseRoots(t *testing.T) {
+ t1 := 0.0945
+ t2 := 0.0955
+ p1 := pointOnCircle(1, 0.35)
+ p2 := pointOnCircle(1, 0.38)
+ curve := bezierThrough(
+ t1,
+ t2,
+ geo.NewPoint(1.3, 0.0),
+ geo.NewPoint(1.3, 0.8),
+ p1,
+ p2,
+ )
+
+ points := circleBezierIntersections(1, curve)
+
+ assertHasPointNear(t, points, p1, 0.001)
+ assertHasPointNear(t, points, p2, 0.001)
+}
+
+func TestCyclePerimeterIntersectionsIncludesCompositeCircleEllipse(t *testing.T) {
+ head := geo.NewEllipse(geo.NewPoint(0, 80), 30, 30)
+
+ points := cyclePerimeterIntersections(100, []geo.Intersectable{head})
+
+ if len(points) != 2 {
+ t.Fatalf("expected two circle/head intersections, got %d: %v", len(points), points)
+ }
+ for _, p := range points {
+ if math.Abs(math.Hypot(p.X, p.Y)-100) > 0.001 {
+ t.Fatalf("intersection %v is not on the cycle circle", p)
+ }
+ if math.Abs(geo.EuclideanDistance(0, 80, p.X, p.Y)-30) > 0.001 {
+ t.Fatalf("intersection %v is not on the head ellipse", p)
+ }
+ }
+}
+
+func TestCycleArcSamplesStayOnCycleRadiusForNonRectangularShapes(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a.shape: circle
+b.shape: hexagon
+c.shape: circle
+a -> b -> c -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ radius := math.Hypot(edge.Src.Center().X, edge.Src.Center().Y)
+ for i := 0; i+3 < len(edge.Route); i += 3 {
+ for step := 0; step <= 10; step++ {
+ p := cubicPoint(edge.Route[i], edge.Route[i+1], edge.Route[i+2], edge.Route[i+3], float64(step)/10)
+ got := math.Hypot(p.X, p.Y)
+ if math.Abs(got-radius) > 0.15 {
+ t.Fatalf("edge %s -> %s sample radius got %.3f want %.3f at segment %d step %d point %v",
+ edge.Src.AbsID(), edge.Dst.AbsID(), got, radius, i/3, step, p)
+ }
+ }
+ }
+ }
+}
+
+func TestSingleObjectCycleIsCentered(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ a := g.Root.ChildrenArray[0]
+ if math.Abs(a.Center().X) > 0.001 || math.Abs(a.Center().Y) > 0.001 {
+ t.Fatalf("expected single cycle object centered at origin, got %v", a.Center())
+ }
+}
+
+func TestSingleObjectSelfEdgeHasFiniteRoute(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> a: retry
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := g.Edges[0]
+ if edge.IsCurve {
+ t.Fatal("expected self edge to use a normal self-loop route")
+ }
+ for _, p := range edge.Route {
+ if math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) {
+ t.Fatalf("self edge route contains invalid point %v", p)
+ }
+ }
+}
+
+func TestSingleEdgeCycleUsesCircularArc(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := edgeByEndpoints(t, g, objectByID(t, g, "a"), objectByID(t, g, "b"))
+ if !edge.IsCurve {
+ t.Fatal("expected single root edge to use a circular cycle arc")
+ }
+}
+
+func TestNestedEdgeRoutesMoveWithSameCycleNode(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: {
+ x -> y
+}
+b
+a -> b
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, fixedNestedLayout); err != nil {
+ t.Fatal(err)
+ }
+
+ x := objectByID(t, g, "x")
+ y := objectByID(t, g, "y")
+ edge := edgeByEndpoints(t, g, x, y)
+
+ if edge.IsCurve {
+ t.Fatal("expected internal nested edge to keep core-layout route instead of cycle arc")
+ }
+ assertRouteEndpoint(t, edge.Route[0], x.Center())
+ assertRouteEndpoint(t, edge.Route[1], y.Center())
+}
+
+func TestNonAdjacentDirectEdgeIsNotCycleArc(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c -> d -> a
+a -> c
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := edgeByEndpoints(t, g, objectByID(t, g, "a"), objectByID(t, g, "c"))
+ if edge.IsCurve {
+ t.Fatal("expected non-adjacent direct edge to use normal routing instead of a cycle arc")
+ }
+ assertOnShapeBorder(t, edge.Route[0], edge.Src)
+ assertOnShapeBorder(t, edge.Route[len(edge.Route)-1], edge.Dst)
+}
+
+func TestSingleStatementNonAdjacentEdgeIsNotCycleArc(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a
+b
+c
+a -> c
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := edgeByEndpoints(t, g, objectByID(t, g, "a"), objectByID(t, g, "c"))
+ if edge.IsCurve {
+ t.Fatal("expected non-adjacent single edge to use normal routing instead of a cycle arc")
+ }
+ assertOnShapeBorder(t, edge.Route[0], edge.Src)
+ assertOnShapeBorder(t, edge.Route[len(edge.Route)-1], edge.Dst)
+}
+
+func TestPredeclaredNodeKeepsEdgeChainOrder(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+c: C has a longer label
+a -> b -> c -> d -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, pair := range [][2]string{{"a", "b"}, {"b", "c"}, {"c", "d"}, {"d", "a"}} {
+ edge := edgeByEndpoints(t, g, objectByID(t, g, pair[0]), objectByID(t, g, pair[1]))
+ if !edge.IsCurve {
+ t.Fatalf("expected edge %s -> %s to follow the cycle arc", pair[0], pair[1])
+ }
+ }
+}
+
+func TestOpenChainCanCloseWithSingleStatementEdge(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c
+c -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, pair := range [][2]string{{"a", "b"}, {"b", "c"}, {"c", "a"}} {
+ edge := edgeByEndpoints(t, g, objectByID(t, g, pair[0]), objectByID(t, g, pair[1]))
+ if !edge.IsCurve {
+ t.Fatalf("expected edge %s -> %s to follow the cycle arc", pair[0], pair[1])
+ }
+ }
+}
+
+func TestOpenChainCanContinueWithSingleStatementEdge(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c
+c -> d
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := edgeByEndpoints(t, g, objectByID(t, g, "c"), objectByID(t, g, "d"))
+ if !edge.IsCurve {
+ t.Fatal("expected single edge continuing an open chain to follow the cycle arc")
+ }
+}
+
+func TestOpenChainCanContinueAcrossSourceChains(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c
+c -> d -> a
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, pair := range [][2]string{{"a", "b"}, {"b", "c"}, {"c", "d"}, {"d", "a"}} {
+ edge := edgeByEndpoints(t, g, objectByID(t, g, pair[0]), objectByID(t, g, pair[1]))
+ if !edge.IsCurve {
+ t.Fatalf("expected edge %s -> %s to follow the cycle arc", pair[0], pair[1])
+ }
+ }
+}
+
+func TestParallelEdgeDoesNotReuseCycleArc(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c -> a
+a -> b: duplicate
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edges := edgesByEndpoints(t, g, objectByID(t, g, "a"), objectByID(t, g, "b"))
+ if len(edges) != 2 {
+ t.Fatalf("expected two a -> b edges, got %d", len(edges))
+ }
+ curved := 0
+ for _, edge := range edges {
+ if edge.IsCurve {
+ curved++
+ }
+ }
+ if curved != 1 {
+ t.Fatalf("expected exactly one a -> b edge on the cycle arc, got %d", curved)
+ }
+}
+
+func TestSingleStatementParallelEdgeUsesOnlyOneCycleArc(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a
+b
+a -> b
+a -> b: duplicate
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edges := edgesByEndpoints(t, g, objectByID(t, g, "a"), objectByID(t, g, "b"))
+ curved := 0
+ for _, edge := range edges {
+ if edge.IsCurve {
+ curved++
+ }
+ }
+ if curved != 1 {
+ t.Fatalf("expected exactly one parallel edge on the cycle arc, got %d", curved)
+ }
+}
+
+func TestClosedCycleKeepsClosingArcWithLongerTail(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b -> c -> a
+c -> d -> e
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ closing := edgeByEndpoints(t, g, objectByID(t, g, "c"), objectByID(t, g, "a"))
+ if !closing.IsCurve {
+ t.Fatal("expected closing cycle edge c -> a to stay on the circular route")
+ }
+ for _, pair := range [][2]string{{"c", "d"}, {"d", "e"}} {
+ edge := edgeByEndpoints(t, g, objectByID(t, g, pair[0]), objectByID(t, g, pair[1]))
+ if edge.IsCurve {
+ t.Fatalf("expected tail edge %s -> %s to use normal routing", pair[0], pair[1])
+ }
+ }
+}
+
+func TestNestedCycleFitsObjectsAndRoutes(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+ if g.Root.Box == nil {
+ t.Fatal("expected nested cycle root box to be set")
+ }
+
+ for _, obj := range g.Objects {
+ assertPointInsideBox(t, obj.TopLeft, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(obj.TopLeft.X+obj.Width, obj.TopLeft.Y+obj.Height), g.Root.Box)
+ }
+ for _, edge := range g.Edges {
+ for _, p := range edge.Route {
+ assertPointInsideBox(t, p, g.Root.Box)
+ }
+ }
+}
+
+func TestNestedCycleBoundsIncludeOutsideLabel(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: wide label
+b
+a -> b
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+ a := objectByID(t, g, "a")
+ a.LabelDimensions = d2target.TextDimensions{Width: 420, Height: 24}
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ if a.LabelPosition == nil || !label.FromString(*a.LabelPosition).IsOutside() {
+ t.Fatalf("expected outside label position, got %v", a.LabelPosition)
+ }
+ labelTL := a.GetLabelTopLeft()
+ assertPointInsideBox(t, labelTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(labelTL.X+float64(a.LabelDimensions.Width), labelTL.Y+float64(a.LabelDimensions.Height)), g.Root.Box)
+}
+
+func TestNestedCycleBoundsIncludeOutsideIcon(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: {
+ icon: https://icons.terrastruct.com/essentials/004-picture.svg
+ x
+}
+b
+a -> b
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ a := objectByID(t, g, "a")
+ if a.IconPosition == nil || !label.FromString(*a.IconPosition).IsOutside() {
+ t.Fatalf("expected outside icon position, got %v", a.IconPosition)
+ }
+ iconPosition := label.FromString(*a.IconPosition)
+ iconBox := a.ToShape().GetBox()
+ if !iconPosition.IsOutside() {
+ iconBox = a.ToShape().GetInnerBox()
+ }
+ iconSize := float64(d2target.GetIconSize(iconBox, *a.IconPosition))
+ iconTL := iconPosition.GetPointOnBox(iconBox, label.PADDING, iconSize, iconSize)
+ assertPointInsideBox(t, iconTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(iconTL.X+iconSize, iconTL.Y+iconSize), g.Root.Box)
+}
+
+func TestCycleRadiusIncludesOutsideLabels(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: long
+b: long
+c: long
+d: long
+e: long
+f: long
+g: long
+h: long
+a -> b -> c -> d -> e -> f -> g -> h -> a
+`)
+ for _, obj := range g.Objects {
+ obj.Box = geo.NewBox(geo.NewPoint(0, 0), 20, 20)
+ obj.LabelDimensions = d2target.TextDimensions{Width: 300, Height: 24}
+ position := label.OutsideBottomCenter.String()
+ obj.LabelPosition = &position
+ }
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ var boxes []geo.Box
+ for _, obj := range g.Objects {
+ boxes = append(boxes, geo.Box{
+ TopLeft: obj.GetLabelTopLeft(),
+ Width: float64(obj.LabelDimensions.Width),
+ Height: float64(obj.LabelDimensions.Height),
+ })
+ }
+ assertNoBoxOverlaps(t, boxes)
+}
+
+func TestNestedCycleBoundsIncludeEdgeLabels(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b: very long label
+b -> c: very long label
+c -> a: very long label
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+ for _, edge := range g.Edges {
+ edge.LabelDimensions = d2target.TextDimensions{Width: 240, Height: 24}
+ }
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, edge := range g.Edges {
+ labelTL, width, height := edgeLabelBox(edge)
+ assertPointInsideBox(t, labelTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(labelTL.X+width, labelTL.Y+height), g.Root.Box)
+ }
+}
+
+func TestNestedCycleBoundsIncludeConnectionIcon(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b: {
+ label: edge icon beside label
+ icon: https://icons.terrastruct.com/essentials/004-picture.svg
+}
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+ g.Edges[0].LabelDimensions = d2target.TextDimensions{Width: 220, Height: 24}
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ edge := g.Edges[0]
+ iconTL, width, height := edgeIconBox(edge)
+ assertPointInsideBox(t, iconTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(iconTL.X+width, iconTL.Y+height), g.Root.Box)
+}
+
+func TestNestedCycleBoundsIncludeArrowheadLabels(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a -> b: {
+ source-arrowhead: very long source label
+ target-arrowhead: very long target label
+ style.stroke-width: 10
+}
+`)
+ g.RootLevel = 1
+ sizeObjects(g)
+ edge := g.Edges[0]
+ if edge.SrcArrowhead == nil {
+ t.Fatal("expected source arrowhead")
+ }
+ if edge.DstArrowhead == nil {
+ t.Fatal("expected target arrowhead")
+ }
+ edge.SrcArrowhead.LabelDimensions = d2target.TextDimensions{Width: 460, Height: 24}
+ edge.DstArrowhead.LabelDimensions = d2target.TextDimensions{Width: 520, Height: 24}
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ labelTL, width, height := edgeArrowheadLabelBox(edge, false)
+ assertPointInsideBox(t, labelTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(labelTL.X+width, labelTL.Y+height), g.Root.Box)
+
+ labelTL, width, height = edgeArrowheadLabelBox(edge, true)
+ assertPointInsideBox(t, labelTL, g.Root.Box)
+ assertPointInsideBox(t, geo.NewPoint(labelTL.X+width, labelTL.Y+height), g.Root.Box)
+}
+
+func TestCycleContainerDefaultIconLabelAvoidOverlap(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: long container label {
+ icon: https://icons.terrastruct.com/essentials/004-picture.svg
+ x
+}
+b
+a -> b
+`)
+ sizeObjects(g)
+ a := objectByID(t, g, "a")
+ a.LabelDimensions = d2target.TextDimensions{Width: 360, Height: 24}
+
+ if err := Layout(context.Background(), g, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ labelTL := a.GetLabelTopLeft()
+ iconTL, iconSize := objectIconBox(a)
+ labelBox := geo.Box{
+ TopLeft: labelTL,
+ Width: float64(a.LabelDimensions.Width),
+ Height: float64(a.LabelDimensions.Height),
+ }
+ iconBox := geo.Box{
+ TopLeft: iconTL,
+ Width: iconSize,
+ Height: iconSize,
+ }
+ if boxesOverlap(labelBox, iconBox) {
+ t.Fatalf("label %v overlaps icon %v", labelBox, iconBox)
+ }
+}
+
+func TestCrossNodeDescendantEdgeIsReroutedAfterCycleMove(t *testing.T) {
+ g := compileCycle(t, `
+shape: cycle
+a: {
+ x
+}
+b: {
+ y
+}
+a -> b
+a.x -> b.y
+`)
+ sizeObjects(g)
+
+ if err := Layout(context.Background(), g, fixedNestedLayout); err != nil {
+ t.Fatal(err)
+ }
+
+ x := objectByID(t, g, "x")
+ y := objectByID(t, g, "y")
+ edge := edgeByEndpoints(t, g, x, y)
+
+ if edge.IsCurve {
+ t.Fatal("expected cross-node descendant edge to be rerouted as a normal edge")
+ }
+ assertOnShapeBorder(t, edge.Route[0], x)
+ assertOnShapeBorder(t, edge.Route[1], y)
+}
+
+func compileCycle(t *testing.T, text string) *d2graph.Graph {
+ t.Helper()
+ g, _, err := d2compiler.Compile("", strings.NewReader(text), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return g
+}
+
+func sizeObjects(g *d2graph.Graph) {
+ for _, obj := range g.Objects {
+ obj.Box = geo.NewBox(geo.NewPoint(0, 0), 100, 100)
+ }
+}
+
+func fixedNestedLayout(ctx context.Context, g *d2graph.Graph) error {
+ for _, obj := range g.Objects {
+ switch obj.ID {
+ 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
+}
+
+func objectByID(t *testing.T, g *d2graph.Graph, id string) *d2graph.Object {
+ t.Helper()
+ for _, obj := range g.Objects {
+ if obj.ID == id {
+ return obj
+ }
+ }
+ t.Fatalf("object %q not found", id)
+ return nil
+}
+
+func edgeByEndpoints(t *testing.T, g *d2graph.Graph, src, dst *d2graph.Object) *d2graph.Edge {
+ t.Helper()
+ for _, edge := range g.Edges {
+ if edge.Src == src && edge.Dst == dst {
+ return edge
+ }
+ }
+ t.Fatalf("edge %s -> %s not found", src.AbsID(), dst.AbsID())
+ return nil
+}
+
+func edgesByEndpoints(t *testing.T, g *d2graph.Graph, src, dst *d2graph.Object) []*d2graph.Edge {
+ t.Helper()
+ var edges []*d2graph.Edge
+ for _, edge := range g.Edges {
+ if edge.Src == src && edge.Dst == dst {
+ edges = append(edges, edge)
+ }
+ }
+ return edges
+}
+
+func assertRouteEndpoint(t *testing.T, got, want *geo.Point) {
+ t.Helper()
+ if math.Abs(got.X-want.X) > 0.001 || math.Abs(got.Y-want.Y) > 0.001 {
+ t.Fatalf("route endpoint got %v want %v", got, want)
+ }
+}
+
+func assertOnShapeBorder(t *testing.T, got *geo.Point, obj *d2graph.Object) {
+ t.Helper()
+ box := obj.Box
+ onX := math.Abs(got.X-box.TopLeft.X) <= 0.001 ||
+ math.Abs(got.X-(box.TopLeft.X+box.Width)) <= 0.001
+ onY := math.Abs(got.Y-box.TopLeft.Y) <= 0.001 ||
+ math.Abs(got.Y-(box.TopLeft.Y+box.Height)) <= 0.001
+ if !onX && !onY {
+ t.Fatalf("route endpoint %v is not on border of %s box %v", got, obj.AbsID(), box)
+ }
+}
+
+func assertPointInsideBox(t *testing.T, p *geo.Point, box *geo.Box) {
+ t.Helper()
+ if p.X < box.TopLeft.X-0.001 || p.Y < box.TopLeft.Y-0.001 ||
+ p.X > box.TopLeft.X+box.Width+0.001 ||
+ p.Y > box.TopLeft.Y+box.Height+0.001 {
+ t.Fatalf("point %v is outside box %v", p, box)
+ }
+}
+
+func assertRoundedOnBoxBorder(t *testing.T, got *geo.Point, obj *d2graph.Object) {
+ t.Helper()
+ box := obj.Box
+ x := math.Round(got.X)
+ y := math.Round(got.Y)
+ left := math.Round(box.TopLeft.X)
+ right := math.Round(box.TopLeft.X + box.Width)
+ top := math.Round(box.TopLeft.Y)
+ bottom := math.Round(box.TopLeft.Y + box.Height)
+ if x != left && x != right && y != top && y != bottom {
+ t.Fatalf("rounded route endpoint %v is not on visible box border of %s box %v", got, obj.AbsID(), box)
+ }
+}
+
+func assertOnActualShapeBorder(t *testing.T, got *geo.Point, obj *d2graph.Object) {
+ t.Helper()
+ s := obj.ToShape()
+ switch s.GetType() {
+ case shape.CIRCLE_TYPE:
+ radius := obj.Width / 2
+ dist := geo.EuclideanDistance(obj.Center().X, obj.Center().Y, got.X, got.Y)
+ if math.Abs(dist-radius) > 0.001 {
+ t.Fatalf("route endpoint %v is not on circle border of %s: radius got %.3f want %.3f", got, obj.AbsID(), dist, radius)
+ }
+ case shape.HEXAGON_TYPE:
+ for _, side := range s.Perimeter() {
+ segment, ok := side.(*geo.Segment)
+ if !ok {
+ continue
+ }
+ if got.DistanceToLine(segment.Start, segment.End) <= 0.001 && pointWithinSegment(got, segment, 0.001) {
+ return
+ }
+ }
+ t.Fatalf("route endpoint %v is not on hexagon border of %s", got, obj.AbsID())
+ default:
+ if !pointNearShapePerimeter(got, s, 1.0) {
+ t.Fatalf("route endpoint %v is not on shape border of %s", got, obj.AbsID())
+ }
+ }
+}
+
+func pointNearShapePerimeter(p *geo.Point, s shape.Shape, tolerance float64) bool {
+ for _, side := range s.Perimeter() {
+ switch side := side.(type) {
+ case *geo.Segment:
+ if p.DistanceToLine(side.Start, side.End) <= tolerance && pointWithinSegment(p, side, tolerance) {
+ return true
+ }
+ case geo.Segment:
+ if p.DistanceToLine(side.Start, side.End) <= tolerance && pointWithinSegment(p, &side, tolerance) {
+ return true
+ }
+ case *geo.BezierCurve:
+ if pointNearBezier(p, side, tolerance) {
+ return true
+ }
+ case geo.BezierCurve:
+ if pointNearBezier(p, &side, tolerance) {
+ return true
+ }
+ case *geo.Ellipse:
+ if pointNearEllipse(p, side, tolerance) {
+ return true
+ }
+ case geo.Ellipse:
+ if pointNearEllipse(p, &side, tolerance) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func pointNearBezier(p *geo.Point, curve *geo.BezierCurve, tolerance float64) bool {
+ const samples = 200
+ for i := 0; i <= samples; i++ {
+ cp := curve.At(float64(i) / samples)
+ if geo.EuclideanDistance(p.X, p.Y, cp.X, cp.Y) <= tolerance {
+ return true
+ }
+ }
+ return false
+}
+
+func pointNearEllipse(p *geo.Point, ellipse *geo.Ellipse, tolerance float64) bool {
+ if ellipse.Rx <= 0 || ellipse.Ry <= 0 {
+ return false
+ }
+ dx := (p.X - ellipse.Center.X) / ellipse.Rx
+ dy := (p.Y - ellipse.Center.Y) / ellipse.Ry
+ return math.Abs(dx*dx+dy*dy-1) <= tolerance/math.Min(ellipse.Rx, ellipse.Ry)
+}
+
+func pointWithinSegment(p *geo.Point, segment *geo.Segment, tolerance float64) bool {
+ minX := math.Min(segment.Start.X, segment.End.X) - tolerance
+ maxX := math.Max(segment.Start.X, segment.End.X) + tolerance
+ minY := math.Min(segment.Start.Y, segment.End.Y) - tolerance
+ maxY := math.Max(segment.Start.Y, segment.End.Y) + tolerance
+ return p.X >= minX && p.X <= maxX && p.Y >= minY && p.Y <= maxY
+}
+
+func assertNoBoxOverlaps(t *testing.T, boxes []geo.Box) {
+ t.Helper()
+ for i := 0; i < len(boxes); i++ {
+ for j := i + 1; j < len(boxes); j++ {
+ if boxesOverlap(boxes[i], boxes[j]) {
+ t.Fatalf("box %d %v overlaps box %d %v", i, boxes[i], j, boxes[j])
+ }
+ }
+ }
+}
+
+func boxesOverlap(a, b geo.Box) bool {
+ return a.TopLeft.X < b.TopLeft.X+b.Width &&
+ a.TopLeft.X+a.Width > b.TopLeft.X &&
+ a.TopLeft.Y < b.TopLeft.Y+b.Height &&
+ a.TopLeft.Y+a.Height > b.TopLeft.Y
+}
+
+func assertHasPointNear(t *testing.T, points []*geo.Point, want *geo.Point, tolerance float64) {
+ t.Helper()
+ for _, got := range points {
+ if geo.EuclideanDistance(got.X, got.Y, want.X, want.Y) <= tolerance {
+ return
+ }
+ }
+ t.Fatalf("expected point near %v in %v", want, points)
+}
+
+func straightBezier(start, end *geo.Point) *geo.BezierCurve {
+ return geo.NewBezierCurve([]*geo.Point{
+ start,
+ geo.NewPoint(start.X+(end.X-start.X)/3, start.Y+(end.Y-start.Y)/3),
+ geo.NewPoint(start.X+2*(end.X-start.X)/3, start.Y+2*(end.Y-start.Y)/3),
+ end,
+ })
+}
+
+func bezierThrough(t1, t2 float64, p0, p3, at1, at2 *geo.Point) *geo.BezierCurve {
+ mt1 := 1 - t1
+ mt2 := 1 - t2
+ b0t1 := mt1 * mt1 * mt1
+ b1t1 := 3 * mt1 * mt1 * t1
+ b2t1 := 3 * mt1 * t1 * t1
+ b3t1 := t1 * t1 * t1
+ b0t2 := mt2 * mt2 * mt2
+ b1t2 := 3 * mt2 * mt2 * t2
+ b2t2 := 3 * mt2 * t2 * t2
+ b3t2 := t2 * t2 * t2
+ den := b1t1*b2t2 - b1t2*b2t1
+
+ solve := func(v1, v2, p0, p3 float64) (float64, float64) {
+ r1 := v1 - b0t1*p0 - b3t1*p3
+ r2 := v2 - b0t2*p0 - b3t2*p3
+ return (r1*b2t2 - r2*b2t1) / den, (b1t1*r2 - b1t2*r1) / den
+ }
+
+ p1x, p2x := solve(at1.X, at2.X, p0.X, p3.X)
+ p1y, p2y := solve(at1.Y, at2.Y, p0.Y, p3.Y)
+ return geo.NewBezierCurve([]*geo.Point{
+ p0,
+ geo.NewPoint(p1x, p1y),
+ geo.NewPoint(p2x, p2y),
+ p3,
+ })
+}
+
+func cubicPoint(p0, p1, p2, p3 *geo.Point, t float64) *geo.Point {
+ mt := 1 - t
+ return geo.NewPoint(
+ mt*mt*mt*p0.X+3*mt*mt*t*p1.X+3*mt*t*t*p2.X+t*t*t*p3.X,
+ mt*mt*mt*p0.Y+3*mt*mt*t*p1.Y+3*mt*t*t*p2.Y+t*t*t*p3.Y,
+ )
+}
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/d2oracle/edit_test.go b/d2oracle/edit_test.go
index c3a79fd962..de86d14000 100644
--- a/d2oracle/edit_test.go
+++ b/d2oracle/edit_test.go
@@ -18,6 +18,7 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2oracle"
"oss.terrastruct.com/d2/d2target"
+ "oss.terrastruct.com/d2/internal/testdiff"
)
// TODO: make assertions less specific
@@ -8316,7 +8317,7 @@ func (tc editTest) run(t *testing.T) {
Err: fmt.Sprintf("%#v", err),
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2oracle", t.Name()), got)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2oracle", t.Name()), got)
assert.Success(t, err)
}
diff --git a/d2parser/parse_test.go b/d2parser/parse_test.go
index fc7d1b7293..1d60e3788e 100644
--- a/d2parser/parse_test.go
+++ b/d2parser/parse_test.go
@@ -7,11 +7,11 @@ import (
"testing"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2format"
"oss.terrastruct.com/d2/d2parser"
+ "oss.terrastruct.com/d2/internal/testdiff"
)
type testCase struct {
@@ -536,7 +536,7 @@ func runa(t *testing.T, tca []testCase) {
Err: err,
}
- err = diff.TestdataJSON(filepath.Join("..", "testdata", "d2parser", t.Name()), got)
+ err = testdiff.TestdataJSON(filepath.Join("..", "testdata", "d2parser", t.Name()), got)
assert.Success(t, err)
})
}
diff --git a/d2renderers/d2fonts/d2fonts_common.go b/d2renderers/d2fonts/d2fonts_common.go
index a9ce697fa1..47b3f6ab99 100644
--- a/d2renderers/d2fonts/d2fonts_common.go
+++ b/d2renderers/d2fonts/d2fonts_common.go
@@ -11,6 +11,7 @@ package d2fonts
import (
"encoding/base64"
"fmt"
+ "strings"
"sync"
"oss.terrastruct.com/d2/lib/font"
@@ -108,6 +109,10 @@ var FontFamiliesMu sync.Mutex
var FontEncodings syncmap.SyncMap[Font, string]
var FontFaces syncmap.SyncMap[Font, []byte]
+func trimFontEncoding(encoding string) string {
+ return strings.TrimRight(encoding, "\r\n")
+}
+
var D2_FONT_TO_FAMILY = map[string]FontFamily{
"default": SourceSansPro,
"mono": SourceCodePro,
diff --git a/d2renderers/d2fonts/d2fonts_embed.go b/d2renderers/d2fonts/d2fonts_embed.go
index d5add0a5ff..340335c36f 100644
--- a/d2renderers/d2fonts/d2fonts_embed.go
+++ b/d2renderers/d2fonts/d2fonts_embed.go
@@ -5,7 +5,6 @@ package d2fonts
import (
"embed"
_ "embed"
- "strings"
"oss.terrastruct.com/d2/lib/syncmap"
)
@@ -128,7 +127,7 @@ func init() {
}, fuzzyBubblesBoldBase64)
FontEncodings.Range(func(k Font, v string) bool {
- FontEncodings.Set(k, strings.TrimSuffix(v, "\n"))
+ FontEncodings.Set(k, trimFontEncoding(v))
return true
})
diff --git a/d2renderers/d2fonts/d2fonts_embed_wasm.go b/d2renderers/d2fonts/d2fonts_embed_wasm.go
index db199900bf..f085eebc1d 100644
--- a/d2renderers/d2fonts/d2fonts_embed_wasm.go
+++ b/d2renderers/d2fonts/d2fonts_embed_wasm.go
@@ -6,7 +6,6 @@ import (
"embed"
_ "embed"
"fmt"
- "strings"
"oss.terrastruct.com/d2/lib/compression"
"oss.terrastruct.com/d2/lib/syncmap"
@@ -129,7 +128,7 @@ func init() {
// trimEncodings removes trailing newlines from all font encodings
func trimEncodings() {
FontEncodings.Range(func(k Font, v string) bool {
- FontEncodings.Set(k, strings.TrimSuffix(v, "\n"))
+ FontEncodings.Set(k, trimFontEncoding(v))
return true
})
}
diff --git a/d2renderers/d2fonts/d2fonts_test.go b/d2renderers/d2fonts/d2fonts_test.go
index 9c1cf4f004..2a011e1141 100644
--- a/d2renderers/d2fonts/d2fonts_test.go
+++ b/d2renderers/d2fonts/d2fonts_test.go
@@ -2,11 +2,12 @@ package d2fonts
import (
"path/filepath"
+ "strings"
"testing"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/font"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
)
func TestCutFont(t *testing.T) {
@@ -18,6 +19,33 @@ func TestCutFont(t *testing.T) {
fontBuf := make([]byte, len(face))
copy(fontBuf, face)
fontBuf = font.UTF8CutFont(fontBuf, " 1")
- err := diff.Testdata(filepath.Join("testdata", "d2fonts", "cut"), ".txt", fontBuf)
+ err := testdiff.Testdata(filepath.Join("testdata", "d2fonts", "cut"), ".txt", fontBuf)
assert.Success(t, err)
}
+
+func TestFontEncodingsHaveNoNewlines(t *testing.T) {
+ FontEncodings.Range(func(f Font, encoding string) bool {
+ if strings.ContainsAny(encoding, "\r\n") {
+ t.Fatalf("font encoding for %s/%s contains a newline", f.Family, f.Style)
+ }
+ return true
+ })
+}
+
+func TestTrimFontEncoding(t *testing.T) {
+ tcs := []struct {
+ in string
+ out string
+ }{
+ {in: "abc\r\n", out: "abc"},
+ {in: "abc\n", out: "abc"},
+ {in: "abc\r", out: "abc"},
+ {in: "ab\nc", out: "ab\nc"},
+ }
+
+ for _, tc := range tcs {
+ if got := trimFontEncoding(tc.in); got != tc.out {
+ t.Fatalf("trimFontEncoding(%q) = %q, want %q", tc.in, got, tc.out)
+ }
+ }
+}
diff --git a/d2renderers/d2sketch/sketch_test.go b/d2renderers/d2sketch/sketch_test.go
index 571e18ed80..aa3ab09aca 100644
--- a/d2renderers/d2sketch/sketch_test.go
+++ b/d2renderers/d2sketch/sketch_test.go
@@ -12,7 +12,6 @@ import (
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2graph"
@@ -22,6 +21,7 @@ import (
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
@@ -1461,13 +1461,12 @@ func run(t *testing.T, tc testCase) {
assert.Success(t, err)
err = os.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
- defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
// We want the visual diffs to compare, but there's floating point precision differences between CI and user machines, so don't compare raw strings
- err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
+ err = testdiff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
}
diff --git a/d2renderers/d2svg/appendix/appendix_test.go b/d2renderers/d2svg/appendix/appendix_test.go
index 9b0bbd1bab..5ec59c0be5 100644
--- a/d2renderers/d2svg/appendix/appendix_test.go
+++ b/d2renderers/d2svg/appendix/appendix_test.go
@@ -12,13 +12,13 @@ import (
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
@@ -142,6 +142,8 @@ func runa(t *testing.T, tcs []testCase) {
}
func run(t *testing.T, tc testCase) {
+ tc.script = strings.ReplaceAll(tc.script, "\r\n", "\n")
+
ctx := context.Background()
ctx = log.WithTB(ctx, t)
ctx = log.Leveled(ctx, slog.LevelDebug)
@@ -177,12 +179,11 @@ func run(t *testing.T, tc testCase) {
assert.Success(t, err)
err = os.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
- defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
- err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
+ err = testdiff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
}
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/d2renderers/d2svg/dark_theme/dark_theme_test.go b/d2renderers/d2svg/dark_theme/dark_theme_test.go
index d1c4182b97..3f9186bbf4 100644
--- a/d2renderers/d2svg/dark_theme/dark_theme_test.go
+++ b/d2renderers/d2svg/dark_theme/dark_theme_test.go
@@ -12,7 +12,6 @@ import (
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2graph"
@@ -20,6 +19,7 @@ import (
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
@@ -450,12 +450,11 @@ func run(t *testing.T, tc testCase) {
assert.Success(t, err)
err = os.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
- defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
- err = diff.Testdata(filepath.Join(dataPath, "dark_theme"), ".svg", svgBytes)
+ err = testdiff.Testdata(filepath.Join(dataPath, "dark_theme"), ".svg", svgBytes)
assert.Success(t, err)
}
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-cli/main_test.go b/e2etests-cli/main_test.go
index 8d2913f6e9..e5d44fc2a7 100644
--- a/e2etests-cli/main_test.go
+++ b/e2etests-cli/main_test.go
@@ -16,11 +16,11 @@ import (
"github.com/coder/websocket"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/util-go/xos"
"oss.terrastruct.com/d2/d2cli"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/pptx"
"oss.terrastruct.com/d2/lib/xgif"
)
@@ -122,7 +122,7 @@ local.code -> aws.ec2: {
err := runTestMain(t, ctx, dir, env, "--center=true", "hello-world.d2")
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -149,7 +149,7 @@ local.code -> aws.ec2: {
err := runTestMain(t, ctx, dir, env, "test.d2", "layer-link.svg")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "layer-link"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "layer-link"))
},
},
{
@@ -173,7 +173,7 @@ if i'm wrong: {
err := runTestMain(t, ctx, dir, env, "index.d2")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "index"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "index"))
},
},
{
@@ -197,7 +197,7 @@ if i'm wrong: {
err := runTestMain(t, ctx, dir, env, "index.d2")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "index"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "index"))
},
},
{
@@ -220,7 +220,7 @@ if i'm wrong: {
err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "empty-base.d2")
assert.Success(t, err)
svg := readFile(t, dir, "empty-base.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
assert.Equal(t, 3, getNumBoards(string(svg)))
},
},
@@ -248,7 +248,7 @@ steps: {
err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "animation.d2")
assert.Success(t, err)
svg := readFile(t, dir, "animation.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -280,7 +280,7 @@ steps: {
err := runTestMain(t, ctx, dir, env, "--animate-interval=1400", "animation.d2")
assert.Success(t, err)
svg := readFile(t, dir, "animation.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -309,7 +309,7 @@ layers: {
err := runTestMain(t, ctx, dir, env, "linked.d2")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "linked"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "linked"))
},
},
{
@@ -322,7 +322,7 @@ a -> b: italic font
err := runTestMain(t, ctx, dir, env, "--font-bold=./RockSalt-Regular.ttf", "font.d2")
assert.Success(t, err)
svg := readFile(t, dir, "font.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -360,7 +360,7 @@ scenarios: {
err := runTestMain(t, ctx, dir, env, "--target", "", "target-root.d2", "target-root.svg")
assert.Success(t, err)
svg := readFile(t, dir, "target-root.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -377,7 +377,7 @@ scenarios: {
err := runTestMain(t, ctx, dir, env, "--target", "b", "target-b.d2", "target-b.svg")
assert.Success(t, err)
svg := readFile(t, dir, "target-b.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -395,7 +395,7 @@ scenarios: {
err := runTestMain(t, ctx, dir, env, "--target", `layers.a.layers."x / y . z"`, "target-nested-with-special-chars.d2", "target-nested-with-special-chars.svg")
assert.Success(t, err)
svg := readFile(t, dir, "target-nested-with-special-chars.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -428,7 +428,7 @@ layers: {
err := runTestMain(t, ctx, dir, env, "--target", `l1.index.l3`, "target-nested-index.d2", "target-nested-index.svg")
assert.Success(t, err)
svg := readFile(t, dir, "target-nested-index.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -453,7 +453,7 @@ layers: {
err := runTestMain(t, ctx, dir, env, "--target", `index.nest1.nest2`, "target-nested-index2.d2", "target-nested-index2.svg")
assert.Success(t, err)
svg := readFile(t, dir, "target-nested-index2.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -578,7 +578,7 @@ costumes.monster -> monsters.id
err := runTestMain(t, ctx, dir, env, "theme-override.d2", "theme-override.svg")
assert.Success(t, err)
svg := readFile(t, dir, "theme-override.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
// theme color is used in SVG
assert.NotEqual(t, -1, strings.Index(string(svg), "#2E2E2E"))
},
@@ -613,7 +613,7 @@ scenarios: {
err := runTestMain(t, ctx, dir, env, "life.d2")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "life"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "life"))
},
},
{
@@ -646,7 +646,7 @@ scenarios: {
err := runTestMain(t, ctx, dir, env, "life")
assert.Success(t, err)
- assert.TestdataDir(t, filepath.Join(dir, "life"))
+ testdiff.TestdataDir(t, filepath.Join(dir, "life"))
},
},
{
@@ -988,7 +988,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := tms.Wait(ctx)
assert.Success(t, err)
- assert.Testdata(t, ".svg", stdout.Bytes())
+ testdiff.TestdataTB(t, ".svg", stdout.Bytes())
},
},
{
@@ -998,7 +998,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1011,7 +1011,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1022,7 +1022,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1033,7 +1033,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1046,7 +1046,7 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1058,12 +1058,15 @@ bank.Equities.app14522 -> bank.Fixed Income.app14500: security reference
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
name: "chain_icon_import",
run: func(t *testing.T, ctx context.Context, dir string, env *xos.Env) {
+ const iconDataURI = "data:image/svg+xml;base64," +
+ "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiByeD0iNCIgZmlsbD0iIzJENzJDQiIvPjxjaXJjbGUgY3g9IjgiIGN5PSI4IiByPSIzIiBmaWxsPSIjRkZGIi8+PC9zdmc+"
+
writeFile(t, dir, "hello-world.d2", `...@y
hello.class: Ecs`)
writeFile(t, dir, "y.d2", `
@@ -1075,17 +1078,17 @@ classes: {
}
}
`)
- writeFile(t, dir, "x.d2", `
+ writeFile(t, dir, "x.d2", fmt.Sprintf(`
vars: {
icons: {
- ecs: "https://icons.terrastruct.com/aws%2FCompute%2FAmazon-Elastic-Container-Service.svg"
+ ecs: %q
}
}
-`)
+`, iconDataURI))
err := runTestMain(t, ctx, dir, env, filepath.Join(dir, "hello-world.d2"))
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1098,15 +1101,15 @@ vars: {
assert.Success(t, err)
t.Run("hello-world-x-y", func(t *testing.T) {
svg := readFile(t, dir, "hello-world/x/y.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
})
t.Run("hello-world-x", func(t *testing.T) {
svg := readFile(t, dir, "hello-world/x/index.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
})
t.Run("hello-world", func(t *testing.T) {
svg := readFile(t, dir, "hello-world/index.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
})
},
},
@@ -1127,7 +1130,7 @@ i used to read
err := runTestMain(t, ctx, dir, env, "--pad=10", "hello-world.d2")
assert.Success(t, err)
svg := readFile(t, dir, "hello-world.svg")
- assert.Testdata(t, ".svg", svg)
+ testdiff.TestdataTB(t, ".svg", svg)
},
},
{
@@ -1715,7 +1718,7 @@ func removeD2Files(tb testing.TB, dir string) {
}
func testdataIgnoreDiff(tb testing.TB, ext string, got []byte) {
- _ = diff.Testdata(filepath.Join("testdata", tb.Name()), ext, got)
+ _ = testdiff.Testdata(filepath.Join("testdata", tb.Name()), ext, got)
}
// getNumBoards gets the number of boards in an SVG file through a non-robust pattern search
diff --git a/e2etests-cli/testdata/TestCLI_E2E/chain_icon_import.exp.svg b/e2etests-cli/testdata/TestCLI_E2E/chain_icon_import.exp.svg
index 12ac9ed9e7..58e86989af 100644
--- a/e2etests-cli/testdata/TestCLI_E2E/chain_icon_import.exp.svg
+++ b/e2etests-cli/testdata/TestCLI_E2E/chain_icon_import.exp.svg
@@ -1,9 +1,9 @@
-
diff --git a/e2etests/e2e_test.go b/e2etests/e2e_test.go
index 99d555b555..2c0ab95baa 100644
--- a/e2etests/e2e_test.go
+++ b/e2etests/e2e_test.go
@@ -15,7 +15,6 @@ import (
trequire "github.com/stretchr/testify/require"
"oss.terrastruct.com/util-go/assert"
- "oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2compiler"
@@ -29,6 +28,7 @@ import (
"oss.terrastruct.com/d2/d2renderers/d2ascii/charset"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2target"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
@@ -87,7 +87,7 @@ func testTxtar(t *testing.T) {
for _, f := range archive.Files {
tcs = append(tcs, testCase{
name: f.Name,
- script: string(f.Data),
+ script: normalizeTestScript(string(f.Data)),
})
}
runa(t, tcs)
@@ -100,7 +100,7 @@ func testASCIITxtar(t *testing.T) {
for _, f := range archive.Files {
tc := testCase{
name: f.Name,
- script: string(f.Data),
+ script: normalizeTestScript(string(f.Data)),
}
t.Run(tc.name, func(t *testing.T) {
@@ -114,6 +114,10 @@ func testASCIITxtar(t *testing.T) {
}
}
+func normalizeTestScript(script string) string {
+ return strings.ReplaceAll(script, "\r\n", "\n")
+}
+
func runASCIITxtarTest(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t)
@@ -198,7 +202,7 @@ func runASCIITxtarTest(t *testing.T, tc testCase) {
// Write SVG file
var err2, err3 error
if os.Getenv("SKIP_SVG_CHECK") == "" {
- err2 = diff.Testdata(filepath.Join(outputDir, "sketch"), ".svg", svgBytes)
+ err2 = testdiff.Testdata(filepath.Join(outputDir, "sketch"), ".svg", svgBytes)
}
extendedAsciiArtist := d2ascii.NewASCIIartist()
@@ -210,7 +214,7 @@ func runASCIITxtarTest(t *testing.T, tc testCase) {
}
extendedBytes, err := extendedAsciiArtist.Render(ctx, diagram, extendedRenderOpts)
assert.Success(t, err)
- err3 = diff.Testdata(filepath.Join(outputDir, "extended"), ".txt", extendedBytes)
+ err3 = testdiff.Testdata(filepath.Join(outputDir, "extended"), ".txt", extendedBytes)
// Standard ASCII
var err4 error
@@ -221,7 +225,7 @@ func runASCIITxtarTest(t *testing.T, tc testCase) {
}
standardBytes, err := standardAsciiArtist.Render(ctx, diagram, standardRenderOpts)
assert.Success(t, err)
- err4 = diff.Testdata(filepath.Join(outputDir, "standard"), ".txt", standardBytes)
+ err4 = testdiff.Testdata(filepath.Join(outputDir, "standard"), ".txt", standardBytes)
assert.Success(t, err2)
assert.Success(t, err3)
@@ -414,9 +418,9 @@ func run(t *testing.T, tc testCase) {
assert.Success(t, err)
var err2 error
- err = diff.TestdataJSON(filepath.Join(dataPath, "board"), diagram)
+ err = testdiff.TestdataJSON(filepath.Join(dataPath, "board"), diagram)
if os.Getenv("SKIP_SVG_CHECK") == "" {
- err2 = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
+ err2 = testdiff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
}
assert.Success(t, err)
@@ -442,7 +446,7 @@ func loadFromFile(t *testing.T, name string) testCase {
return testCase{
name: name,
- script: string(d2Text),
+ script: normalizeTestScript(string(d2Text)),
}
}
diff --git a/e2etests/stable_test.go b/e2etests/stable_test.go
index 33664eba0f..a8b8ed80a6 100644
--- a/e2etests/stable_test.go
+++ b/e2etests/stable_test.go
@@ -1084,7 +1084,7 @@ A code block continues until it reaches a line that is not indented
},
{
name: "giant_markdown_test",
- script: mdTestScript(testMarkdown),
+ script: mdTestScript(normalizeTestScript(testMarkdown)),
},
{
name: "code_snippet",
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..d4b94b349c
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/board.exp.json
@@ -0,0 +1,1715 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "1",
+ "type": "cycle",
+ "pos": {
+ "x": 0,
+ "y": 77
+ },
+ "width": 453,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "1.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 77
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 400,
+ "y": 277
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 200,
+ "y": 477
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.d",
+ "type": "rectangle",
+ "pos": {
+ "x": 0,
+ "y": 277
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2",
+ "type": "cycle",
+ "pos": {
+ "x": 513,
+ "y": 77
+ },
+ "width": 226,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Two nodes",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 127,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "2.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 513,
+ "y": 77
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 513,
+ "y": 477
+ },
+ "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": "3",
+ "type": "cycle",
+ "pos": {
+ "x": 799,
+ "y": 74
+ },
+ "width": 467,
+ "height": 472,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Non-rectangular shapes",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 278,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "3.a",
+ "type": "c4-person",
+ "pos": {
+ "x": 1000,
+ "y": 74
+ },
+ "width": 52,
+ "height": 78,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B3",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.b",
+ "type": "cloud",
+ "pos": {
+ "x": 1186,
+ "y": 278
+ },
+ "width": 80,
+ "height": 70,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "contentAspectRatio": 0.33475609756097563,
+ "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": "3.c",
+ "type": "queue",
+ "pos": {
+ "x": 973,
+ "y": 480
+ },
+ "width": 105,
+ "height": 66,
+ "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": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.d",
+ "type": "document",
+ "pos": {
+ "x": 799,
+ "y": 275
+ },
+ "width": 54,
+ "height": 76,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "AB5",
+ "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": "4",
+ "type": "cycle",
+ "pos": {
+ "x": 1326,
+ "y": 0
+ },
+ "width": 265,
+ "height": 619,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Nested cycle",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 145,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "4.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 1326,
+ "y": 36
+ },
+ "width": 114,
+ "height": 292,
+ "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": 24,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 10,
+ "labelHeight": 31,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "4.a.x",
+ "type": "rectangle",
+ "pos": {
+ "x": 1357,
+ "y": 66
+ },
+ "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": "x",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 3
+ },
+ {
+ "id": "4.a.y",
+ "type": "rectangle",
+ "pos": {
+ "x": 1356,
+ "y": 232
+ },
+ "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": "y",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 3
+ },
+ {
+ "id": "4.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 1356,
+ "y": 553
+ },
+ "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": "5",
+ "type": "cycle",
+ "pos": {
+ "x": 1651,
+ "y": 88
+ },
+ "width": 440,
+ "height": 444,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Edge labels",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 131,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "5.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 1833,
+ "y": 88
+ },
+ "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": "5.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 2006,
+ "y": 388
+ },
+ "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": "5.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 1660,
+ "y": 388
+ },
+ "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
+ }
+ ],
+ "connections": [
+ {
+ "id": "1.(a -> b)[0]",
+ "src": "1.a",
+ "srcArrow": "none",
+ "dst": "1.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 253.5,
+ "y": 111.76300048828125
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 123.3949966430664
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 190.41299438476562
+ },
+ {
+ "x": 424.25799560546875,
+ "y": 277
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(b -> c)[0]",
+ "src": "1.b",
+ "srcArrow": "none",
+ "dst": "1.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 424.25799560546875,
+ "y": 343
+ },
+ {
+ "x": 409.77301025390625,
+ "y": 429.58599853515625
+ },
+ {
+ "x": 340.5150146484375,
+ "y": 496.60400390625
+ },
+ {
+ "x": 253.5,
+ "y": 508.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(c -> d)[0]",
+ "src": "1.c",
+ "srcArrow": "none",
+ "dst": "1.d",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 200.5,
+ "y": 508.2359924316406
+ },
+ {
+ "x": 113.48400115966797,
+ "y": 496.60400390625
+ },
+ {
+ "x": 44.22600173950195,
+ "y": 429.58599853515625
+ },
+ {
+ "x": 29.740999221801758,
+ "y": 343
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(d -> a)[0]",
+ "src": "1.d",
+ "srcArrow": "none",
+ "dst": "1.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": 277
+ },
+ {
+ "x": 44.22600173950195,
+ "y": 190.41299438476562
+ },
+ {
+ "x": 113.48400115966797,
+ "y": 123.3949966430664
+ },
+ {
+ "x": 200.49899291992188,
+ "y": 111.76300048828125
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(a -> b)[0]",
+ "src": "2.a",
+ "srcArrow": "none",
+ "dst": "2.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 566,
+ "y": 111.76300048828125
+ },
+ {
+ "x": 665.3309936523438,
+ "y": 125.04100036621094
+ },
+ {
+ "x": 739.5,
+ "y": 209.78500366210938
+ },
+ {
+ "x": 739.5,
+ "y": 310
+ },
+ {
+ "x": 739.5,
+ "y": 410.2139892578125
+ },
+ {
+ "x": 665.3309936523438,
+ "y": 494.9580078125
+ },
+ {
+ "x": 566,
+ "y": 508.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(a -> b)[0]",
+ "src": "3.a",
+ "srcArrow": "none",
+ "dst": "3.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1052,
+ "y": 114.6969985961914
+ },
+ {
+ "x": 1139.7430419921875,
+ "y": 126.20099639892578
+ },
+ {
+ "x": 1209.4949951171875,
+ "y": 194.00100708007812
+ },
+ {
+ "x": 1223.4849853515625,
+ "y": 281.3819885253906
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(b -> c)[0]",
+ "src": "3.b",
+ "srcArrow": "none",
+ "dst": "3.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": 1223.0880126953125,
+ "y": 347
+ },
+ {
+ "x": 1209.8780517578125,
+ "y": 423.5740051269531
+ },
+ {
+ "x": 1153.510009765625,
+ "y": 485.5660095214844
+ },
+ {
+ "x": 1078.532958984375,
+ "y": 505.97698974609375
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(c -> d)[0]",
+ "src": "3.c",
+ "srcArrow": "none",
+ "dst": "3.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": 973.447998046875,
+ "y": 505.97198486328125
+ },
+ {
+ "x": 895.2030029296875,
+ "y": 484.66400146484375
+ },
+ {
+ "x": 837.5700073242188,
+ "y": 418.21600341796875
+ },
+ {
+ "x": 827.5360107421875,
+ "y": 337.7449951171875
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(d -> a)[0]",
+ "src": "3.d",
+ "srcArrow": "none",
+ "dst": "3.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": 829.6430053710938,
+ "y": 275
+ },
+ {
+ "x": 846.0020141601562,
+ "y": 190.46600341796875
+ },
+ {
+ "x": 914.6279907226562,
+ "y": 125.88999938964844
+ },
+ {
+ "x": 999.9990234375,
+ "y": 114.6969985961914
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "4.a.(x -> y)[0]",
+ "src": "4.a.x",
+ "srcArrow": "none",
+ "dst": "4.a.y",
+ "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": 1383,
+ "y": 132
+ },
+ {
+ "x": 1383,
+ "y": 172
+ },
+ {
+ "x": 1383,
+ "y": 192
+ },
+ {
+ "x": 1383,
+ "y": 232
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "4.(a -> b)[0]",
+ "src": "4.a",
+ "srcArrow": "none",
+ "dst": "4.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": 1440,
+ "y": 190.2050018310547
+ },
+ {
+ "x": 1531.583984375,
+ "y": 217.13299560546875
+ },
+ {
+ "x": 1591.8299560546875,
+ "y": 304.4800109863281
+ },
+ {
+ "x": 1584.4659423828125,
+ "y": 399.6570129394531
+ },
+ {
+ "x": 1577.10205078125,
+ "y": 494.8330078125
+ },
+ {
+ "x": 1504.135986328125,
+ "y": 571.8729858398438
+ },
+ {
+ "x": 1409.5,
+ "y": 584.3920288085938
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(a -> b)[0]",
+ "src": "5.a",
+ "srcArrow": "none",
+ "dst": "5.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "advance to review",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 120,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1886.5,
+ "y": 122.76300048828125
+ },
+ {
+ "x": 1946.282958984375,
+ "y": 130.7550048828125
+ },
+ {
+ "x": 1999.2669677734375,
+ "y": 165.2989959716797
+ },
+ {
+ "x": 2030.697021484375,
+ "y": 216.7779998779297
+ },
+ {
+ "x": 2062.1279296875,
+ "y": 268.2560119628906
+ },
+ {
+ "x": 2068.64892578125,
+ "y": 331.1700134277344
+ },
+ {
+ "x": 2048.443115234375,
+ "y": 388
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(b -> c)[0]",
+ "src": "5.b",
+ "srcArrow": "none",
+ "dst": "5.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "ship to production",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 120,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 2009.3680419921875,
+ "y": 454
+ },
+ {
+ "x": 1971.41796875,
+ "y": 496.6210021972656
+ },
+ {
+ "x": 1917.0679931640625,
+ "y": 521
+ },
+ {
+ "x": 1860,
+ "y": 521
+ },
+ {
+ "x": 1802.9310302734375,
+ "y": 521
+ },
+ {
+ "x": 1748.5810546875,
+ "y": 496.6210021972656
+ },
+ {
+ "x": 1710.6309814453125,
+ "y": 454
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(c -> a)[0]",
+ "src": "5.c",
+ "srcArrow": "none",
+ "dst": "5.a",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "retry intake",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 76,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1671.5560302734375,
+ "y": 388
+ },
+ {
+ "x": 1651.3499755859375,
+ "y": 331.1700134277344
+ },
+ {
+ "x": 1657.8709716796875,
+ "y": 268.2560119628906
+ },
+ {
+ "x": 1689.302001953125,
+ "y": 216.7779998779297
+ },
+ {
+ "x": 1720.7320556640625,
+ "y": 165.2989959716797
+ },
+ {
+ "x": 1773.7159423828125,
+ "y": 130.7550048828125
+ },
+ {
+ "x": 1833.5,
+ "y": 122.76300048828125
+ }
+ ],
+ "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..98c16b53da
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/dagre/sketch.exp.svg
@@ -0,0 +1,111 @@
+Two nodesNon-rectangular shapesNested cycleEdge labelsabcdababcdababcxy advance to reviewship to productionretry intake
+
+
+
+
+
\ 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..250c2a69a8
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/board.exp.json
@@ -0,0 +1,1706 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "monoFontFamily": "SourceCodePro",
+ "shapes": [
+ {
+ "id": "1",
+ "type": "cycle",
+ "pos": {
+ "x": 12,
+ "y": 71
+ },
+ "width": 454,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "1.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 212,
+ "y": 71
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 412,
+ "y": 271
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 212,
+ "y": 471
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "1.d",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 271
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2",
+ "type": "cycle",
+ "pos": {
+ "x": 485,
+ "y": 71
+ },
+ "width": 227,
+ "height": 466,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Two nodes",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 127,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "2.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 485,
+ "y": 71
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B5",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "2.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 485,
+ "y": 471
+ },
+ "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": "3",
+ "type": "cycle",
+ "pos": {
+ "x": 732,
+ "y": 68
+ },
+ "width": 467,
+ "height": 472,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Non-rectangular shapes",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 278,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "3.a",
+ "type": "c4-person",
+ "pos": {
+ "x": 933,
+ "y": 68
+ },
+ "width": 52,
+ "height": 78,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B3",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.b",
+ "type": "cloud",
+ "pos": {
+ "x": 1119,
+ "y": 272
+ },
+ "width": 80,
+ "height": 70,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "contentAspectRatio": 0.33475609756097563,
+ "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": "3.c",
+ "type": "queue",
+ "pos": {
+ "x": 906,
+ "y": 474
+ },
+ "width": 105,
+ "height": 66,
+ "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": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "3.d",
+ "type": "document",
+ "pos": {
+ "x": 732,
+ "y": 269
+ },
+ "width": 54,
+ "height": 76,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "AB5",
+ "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": "4",
+ "type": "cycle",
+ "pos": {
+ "x": 1219,
+ "y": 12
+ },
+ "width": 288,
+ "height": 584,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Nested cycle",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 145,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "4.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 1219,
+ "y": 12
+ },
+ "width": 154,
+ "height": 302,
+ "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": 24,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 10,
+ "labelHeight": 31,
+ "labelPosition": "INSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 2
+ },
+ {
+ "id": "4.a.x",
+ "type": "rectangle",
+ "pos": {
+ "x": 1269,
+ "y": 62
+ },
+ "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": "x",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 3
+ },
+ {
+ "id": "4.a.y",
+ "type": "rectangle",
+ "pos": {
+ "x": 1269,
+ "y": 198
+ },
+ "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": "y",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 3
+ },
+ {
+ "id": "4.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 1269,
+ "y": 530
+ },
+ "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": "5",
+ "type": "cycle",
+ "pos": {
+ "x": 1526,
+ "y": 82
+ },
+ "width": 440,
+ "height": 444,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "Edge labels",
+ "fontSize": 28,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 131,
+ "labelHeight": 36,
+ "labelPosition": "OUTSIDE_TOP_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "5.a",
+ "type": "rectangle",
+ "pos": {
+ "x": 1708,
+ "y": 82
+ },
+ "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": "5.b",
+ "type": "rectangle",
+ "pos": {
+ "x": 1881,
+ "y": 382
+ },
+ "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": "5.c",
+ "type": "rectangle",
+ "pos": {
+ "x": 1535,
+ "y": 382
+ },
+ "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
+ }
+ ],
+ "connections": [
+ {
+ "id": "1.(a -> b)[0]",
+ "src": "1.a",
+ "srcArrow": "none",
+ "dst": "1.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 265.5,
+ "y": 105.76300048828125
+ },
+ {
+ "x": 352.5150146484375,
+ "y": 117.3949966430664
+ },
+ {
+ "x": 421.77301025390625,
+ "y": 184.41299438476562
+ },
+ {
+ "x": 436.25799560546875,
+ "y": 271
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(b -> c)[0]",
+ "src": "1.b",
+ "srcArrow": "none",
+ "dst": "1.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 436.25799560546875,
+ "y": 337
+ },
+ {
+ "x": 421.77301025390625,
+ "y": 423.58599853515625
+ },
+ {
+ "x": 352.5150146484375,
+ "y": 490.60400390625
+ },
+ {
+ "x": 265.5,
+ "y": 502.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(c -> d)[0]",
+ "src": "1.c",
+ "srcArrow": "none",
+ "dst": "1.d",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 212.5,
+ "y": 502.2359924316406
+ },
+ {
+ "x": 125.48400115966797,
+ "y": 490.60400390625
+ },
+ {
+ "x": 56.22600173950195,
+ "y": 423.58599853515625
+ },
+ {
+ "x": 41.74100112915039,
+ "y": 337
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "1.(d -> a)[0]",
+ "src": "1.d",
+ "srcArrow": "none",
+ "dst": "1.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": 271
+ },
+ {
+ "x": 56.22600173950195,
+ "y": 184.41299438476562
+ },
+ {
+ "x": 125.48400115966797,
+ "y": 117.3949966430664
+ },
+ {
+ "x": 212.49899291992188,
+ "y": 105.76300048828125
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "2.(a -> b)[0]",
+ "src": "2.a",
+ "srcArrow": "none",
+ "dst": "2.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 538.5,
+ "y": 105.76300048828125
+ },
+ {
+ "x": 637.8309936523438,
+ "y": 119.04100036621094
+ },
+ {
+ "x": 712,
+ "y": 203.78500366210938
+ },
+ {
+ "x": 712,
+ "y": 304
+ },
+ {
+ "x": 712,
+ "y": 404.2139892578125
+ },
+ {
+ "x": 637.8309936523438,
+ "y": 488.9580078125
+ },
+ {
+ "x": 538.5,
+ "y": 502.2359924316406
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(a -> b)[0]",
+ "src": "3.a",
+ "srcArrow": "none",
+ "dst": "3.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 985,
+ "y": 108.6969985961914
+ },
+ {
+ "x": 1072.7430419921875,
+ "y": 120.20099639892578
+ },
+ {
+ "x": 1142.4949951171875,
+ "y": 188.00100708007812
+ },
+ {
+ "x": 1156.4849853515625,
+ "y": 275.3819885253906
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(b -> c)[0]",
+ "src": "3.b",
+ "srcArrow": "none",
+ "dst": "3.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": 1156.0880126953125,
+ "y": 341
+ },
+ {
+ "x": 1142.8780517578125,
+ "y": 417.5740051269531
+ },
+ {
+ "x": 1086.510009765625,
+ "y": 479.5660095214844
+ },
+ {
+ "x": 1011.5330200195312,
+ "y": 499.97698974609375
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(c -> d)[0]",
+ "src": "3.c",
+ "srcArrow": "none",
+ "dst": "3.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": 906.447998046875,
+ "y": 499.97198486328125
+ },
+ {
+ "x": 828.2030029296875,
+ "y": 478.66400146484375
+ },
+ {
+ "x": 770.5700073242188,
+ "y": 412.21600341796875
+ },
+ {
+ "x": 760.5360107421875,
+ "y": 331.7449951171875
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "3.(d -> a)[0]",
+ "src": "3.d",
+ "srcArrow": "none",
+ "dst": "3.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": 762.6430053710938,
+ "y": 269
+ },
+ {
+ "x": 779.0020141601562,
+ "y": 184.46600341796875
+ },
+ {
+ "x": 847.6279907226562,
+ "y": 119.88999938964844
+ },
+ {
+ "x": 932.9990234375,
+ "y": 108.6969985961914
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "4.a.(x -> y)[0]",
+ "src": "4.a.x",
+ "srcArrow": "none",
+ "dst": "4.a.y",
+ "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": 1296,
+ "y": 128
+ },
+ {
+ "x": 1296,
+ "y": 198
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "4.(a -> b)[0]",
+ "src": "4.a",
+ "srcArrow": "none",
+ "dst": "4.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": 1373,
+ "y": 178.41600036621094
+ },
+ {
+ "x": 1456.3890380859375,
+ "y": 213.20199584960938
+ },
+ {
+ "x": 1506.0980224609375,
+ "y": 299.5780029296875
+ },
+ {
+ "x": 1494.281982421875,
+ "y": 389.156005859375
+ },
+ {
+ "x": 1482.4649658203125,
+ "y": 478.7340087890625
+ },
+ {
+ "x": 1412.0570068359375,
+ "y": 549.2639770507812
+ },
+ {
+ "x": 1322.5,
+ "y": 561.2360229492188
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(a -> b)[0]",
+ "src": "5.a",
+ "srcArrow": "none",
+ "dst": "5.b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "advance to review",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 120,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1761.5980224609375,
+ "y": 116.76300048828125
+ },
+ {
+ "x": 1821.3819580078125,
+ "y": 124.75499725341797
+ },
+ {
+ "x": 1874.365966796875,
+ "y": 159.2989959716797
+ },
+ {
+ "x": 1905.7960205078125,
+ "y": 210.7779998779297
+ },
+ {
+ "x": 1937.22705078125,
+ "y": 262.2560119628906
+ },
+ {
+ "x": 1943.748046875,
+ "y": 325.1700134277344
+ },
+ {
+ "x": 1923.5419921875,
+ "y": 382
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(b -> c)[0]",
+ "src": "5.b",
+ "srcArrow": "none",
+ "dst": "5.c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "ship to production",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 120,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1884.467041015625,
+ "y": 448
+ },
+ {
+ "x": 1846.5169677734375,
+ "y": 490.6210021972656
+ },
+ {
+ "x": 1792.1669921875,
+ "y": 515
+ },
+ {
+ "x": 1735.0980224609375,
+ "y": 515
+ },
+ {
+ "x": 1678.030029296875,
+ "y": 515
+ },
+ {
+ "x": 1623.6800537109375,
+ "y": 490.6210021972656
+ },
+ {
+ "x": 1585.72998046875,
+ "y": 448
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "5.(c -> a)[0]",
+ "src": "5.c",
+ "srcArrow": "none",
+ "dst": "5.a",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "retry intake",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 76,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 1546.655029296875,
+ "y": 382
+ },
+ {
+ "x": 1526.448974609375,
+ "y": 325.1700134277344
+ },
+ {
+ "x": 1532.969970703125,
+ "y": 262.2560119628906
+ },
+ {
+ "x": 1564.4000244140625,
+ "y": 210.7779998779297
+ },
+ {
+ "x": 1595.8310546875,
+ "y": 159.2989959716797
+ },
+ {
+ "x": 1648.81494140625,
+ "y": 124.75499725341797
+ },
+ {
+ "x": 1708.5980224609375,
+ "y": 116.76300048828125
+ }
+ ],
+ "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..4435379bcf
--- /dev/null
+++ b/e2etests/testdata/txtar/cycle-diagram/elk/sketch.exp.svg
@@ -0,0 +1,111 @@
+Two nodesNon-rectangular shapesNested cycleEdge labelsabcdababcdababcxy advance to reviewship to productionretry intake
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt
index 7585d91b3e..d82256846e 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -48,6 +48,38 @@ without.classEx -> with.classEx
without.codeEx -> with.codeEx
without.mdEx -> with.mdEx
+-- cycle-diagram --
+1: "" {
+ shape: cycle
+ a -> b -> c -> d -> a
+}
+2: Two nodes {
+ shape: cycle
+ a -> b
+}
+3: Non-rectangular shapes {
+ shape: cycle
+ a.shape: c4-person
+ b.shape: cloud
+ c.shape: queue
+ d.shape: document
+ a -> b -> c -> d -> a
+}
+4: Nested cycle {
+ shape: cycle
+ a: {
+ x -> y
+ }
+ b
+ a -> b
+}
+5: Edge labels {
+ shape: cycle
+ a -> b: advance to review
+ b -> c: ship to production
+ c -> a: retry intake
+}
+
-- theme-overrides --
direction: right
@@ -1772,4 +1804,3 @@ vars:"d2 v0.7.0" {
style: {fill-pattern: dots; fill:"radial-gradient(#fbfbf8, #e3e3f0)"; stroke: "#a0a0a0"; stroke-width: 1; border-radius: 8}
a->b
-
diff --git a/internal/testdiff/testdiff.go b/internal/testdiff/testdiff.go
new file mode 100644
index 0000000000..713f1daed2
--- /dev/null
+++ b/internal/testdiff/testdiff.go
@@ -0,0 +1,102 @@
+package testdiff
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "go.uber.org/multierr"
+ "oss.terrastruct.com/util-go/diff"
+)
+
+func TestdataJSON(path string, got interface{}) error {
+ err := diff.TestdataJSON(path, got)
+ return allowNewlineOnlyDiff(path, ".json", err)
+}
+
+func Testdata(path, ext string, got []byte) error {
+ err := diff.Testdata(path, ext, got)
+ return allowNewlineOnlyDiff(path, ext, err)
+}
+
+func TestdataTB(tb testing.TB, ext string, got []byte) {
+ tb.Helper()
+ if err := Testdata(filepath.Join("testdata", tb.Name()), ext, got); err != nil {
+ tb.Fatal(err)
+ }
+}
+
+func TestdataDir(tb testing.TB, dir string) {
+ tb.Helper()
+ err := testdataDir(filepath.Join("testdata", tb.Name()), dir)
+ if err != nil {
+ for _, err := range multierr.Errors(err) {
+ tb.Error(err)
+ }
+ }
+ if tb.Failed() {
+ tb.FailNow()
+ }
+}
+
+func testdataDir(testName, dir string) (err error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+
+ for _, entry := range entries {
+ dirPath := filepath.Join(dir, entry.Name())
+ if entry.IsDir() {
+ err = multierr.Combine(err, testdataDir(filepath.Join(testName, entry.Name()), dirPath))
+ continue
+ }
+
+ ext := filepath.Ext(entry.Name())
+ name := strings.TrimSuffix(entry.Name(), ext)
+ got, readErr := os.ReadFile(dirPath)
+ if readErr != nil {
+ err = multierr.Combine(err, readErr)
+ continue
+ }
+ err = multierr.Combine(err, Testdata(filepath.Join(testName, name), ext, got))
+ }
+ return err
+}
+
+func allowNewlineOnlyDiff(path, ext string, err error) error {
+ if err == nil {
+ return nil
+ }
+ if os.Getenv("TESTDATA_ACCEPT") != "" || os.Getenv("TA") != "" {
+ return err
+ }
+ if !strings.HasPrefix(err.Error(), "diff (rerun with ") {
+ return err
+ }
+
+ expPath := fmt.Sprintf("%s.exp%s", path, ext)
+ gotPath := fmt.Sprintf("%s.got%s", path, ext)
+
+ exp, expErr := os.ReadFile(expPath)
+ gotb, gotErr := os.ReadFile(gotPath)
+ if expErr != nil || gotErr != nil {
+ return err
+ }
+
+ if !bytes.Equal(normalizeNewlines(exp), normalizeNewlines(gotb)) {
+ return err
+ }
+
+ if removeErr := os.Remove(gotPath); removeErr != nil {
+ return removeErr
+ }
+ return nil
+}
+
+func normalizeNewlines(b []byte) []byte {
+ return bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
+}
diff --git a/internal/testdiff/testdiff_test.go b/internal/testdiff/testdiff_test.go
new file mode 100644
index 0000000000..afd0275212
--- /dev/null
+++ b/internal/testdiff/testdiff_test.go
@@ -0,0 +1,113 @@
+package testdiff
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+type testValue struct {
+ Name string `json:"name"`
+}
+
+func TestTestdataJSONAllowsOnlyNewlineDiff(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "case")
+ expPath := path + ".exp.json"
+ gotPath := path + ".got.json"
+
+ err := os.WriteFile(expPath, []byte("{\r\n \"name\": \"ok\"\r\n}\r\n"), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = TestdataJSON(path, testValue{Name: "ok"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(gotPath); !os.IsNotExist(err) {
+ t.Fatalf("expected got file to be removed after newline-only diff, got err=%v", err)
+ }
+}
+
+func TestTestdataJSONKeepsRealDiff(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "case")
+ expPath := path + ".exp.json"
+ gotPath := path + ".got.json"
+
+ err := os.WriteFile(expPath, []byte("{\r\n \"name\": \"old\"\r\n}\r\n"), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = TestdataJSON(path, testValue{Name: "new"})
+ if err == nil {
+ t.Fatal("expected real JSON diff to fail")
+ }
+
+ if _, err := os.Stat(gotPath); err != nil {
+ t.Fatalf("expected got file to remain after real diff, got err=%v", err)
+ }
+}
+
+func TestTestdataJSONPreservesAcceptErrors(t *testing.T) {
+ t.Setenv("TA", "1")
+
+ path := filepath.Join(t.TempDir(), "case")
+ expPath := path + ".exp.json"
+ gotPath := path + ".got.json"
+
+ err := os.Mkdir(expPath, 0700)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = TestdataJSON(path, testValue{Name: "ok"})
+ if err == nil {
+ t.Fatal("expected accept-mode rename error to be preserved")
+ }
+
+ if _, err := os.Stat(gotPath); err != nil {
+ t.Fatalf("expected got file to remain after accept error, got err=%v", err)
+ }
+}
+
+func TestTestdataAllowsOnlyNewlineDiff(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "case")
+ expPath := path + ".exp.txt"
+ gotPath := path + ".got.txt"
+
+ err := os.WriteFile(expPath, []byte("line 1\r\nline 2\r\n"), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = Testdata(path, ".txt", []byte("line 1\nline 2\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := os.Stat(gotPath); !os.IsNotExist(err) {
+ t.Fatalf("expected got file to be removed after newline-only diff, got err=%v", err)
+ }
+}
+
+func TestTestdataKeepsRealDiff(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "case")
+ expPath := path + ".exp.svg"
+ gotPath := path + ".got.svg"
+
+ err := os.WriteFile(expPath, []byte("\r\n old\r\n\r\n"), 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ err = Testdata(path, ".svg", []byte("\n new\n\n"))
+ if err == nil {
+ t.Fatal("expected real SVG diff to fail")
+ }
+
+ if _, err := os.Stat(gotPath); err != nil {
+ t.Fatalf("expected got file to remain after real diff, got err=%v", err)
+ }
+}
diff --git a/lib/geo/bezier.go b/lib/geo/bezier.go
index 2408e51772..0238c1ab44 100644
--- a/lib/geo/bezier.go
+++ b/lib/geo/bezier.go
@@ -97,6 +97,14 @@ func NewBezierCurve(points []*Point) *BezierCurve {
return curve
}
+func (bc BezierCurve) Points() []*Point {
+ points := make([]*Point, len(bc.points))
+ for i, p := range bc.points {
+ points[i] = p.Copy()
+ }
+ return points
+}
+
func (bc BezierCurve) Intersections(segment Segment) []*Point {
return ComputeIntersections(
[]float64{
diff --git a/lib/urlenc/urlenc_test.go b/lib/urlenc/urlenc_test.go
index 787f1b5fd1..705a829655 100644
--- a/lib/urlenc/urlenc_test.go
+++ b/lib/urlenc/urlenc_test.go
@@ -3,6 +3,7 @@ package urlenc
import (
"testing"
+ "oss.terrastruct.com/d2/internal/testdiff"
"oss.terrastruct.com/util-go/assert"
)
@@ -179,7 +180,7 @@ feature -> etc: Candidate sources
encoded, err := Encode(script)
assert.Success(t, err)
- assert.Testdata(t, ".txt", []byte(encoded))
+ testdiff.TestdataTB(t, ".txt", []byte(encoded))
decoded, err := Decode(encoded)
assert.Success(t, err)
diff --git a/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.exp.json b/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.exp.json
new file mode 100644
index 0000000000..1b161b26db
--- /dev/null
+++ b/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.exp.json
@@ -0,0 +1,15 @@
+{
+ "graph": null,
+ "err": {
+ "errs": [
+ {
+ "range": "d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2,3:2:31-3:11:40",
+ "errmsg": "d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2:4:3: position keywords cannot be used inside shape \"cycle\""
+ },
+ {
+ "range": "d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2,4:2:43-4:12:53",
+ "errmsg": "d2/testdata/d2compiler/TestCompile/fixed-pos-shape-cycle.d2:5:3: position keywords cannot be used inside shape \"cycle\""
+ }
+ ]
+ }
+}
diff --git a/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.exp.json b/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.exp.json
index c4899e323d..e536520196 100644
--- a/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.exp.json
+++ b/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.exp.json
@@ -33,6 +33,14 @@
{
"range": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2,19:0:255-19:14:269",
"errmsg": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:20:1: edge from sequence diagram \"seq\" cannot enter itself"
+ },
+ {
+ "range": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2,24:0:306-24:16:322",
+ "errmsg": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:25:1: edge from cycle diagram \"cycle\" cannot enter itself"
+ },
+ {
+ "range": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2,25:0:327-25:18:345",
+ "errmsg": "d2/testdata/d2compiler/TestCompile/parent_graph_edge_to_descendant.d2:26:1: edge from cycle diagram \"cycle\" cannot enter itself"
}
]
}