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 @@ -hello + .d2-4075137226 .fill-N1{fill:#0A0F25;} + .d2-4075137226 .fill-N2{fill:#676C7E;} + .d2-4075137226 .fill-N3{fill:#9499AB;} + .d2-4075137226 .fill-N4{fill:#CFD2DD;} + .d2-4075137226 .fill-N5{fill:#DEE1EB;} + .d2-4075137226 .fill-N6{fill:#EEF1F8;} + .d2-4075137226 .fill-N7{fill:#FFFFFF;} + .d2-4075137226 .fill-B1{fill:#0D32B2;} + .d2-4075137226 .fill-B2{fill:#0D32B2;} + .d2-4075137226 .fill-B3{fill:#E3E9FD;} + .d2-4075137226 .fill-B4{fill:#E3E9FD;} + .d2-4075137226 .fill-B5{fill:#EDF0FD;} + .d2-4075137226 .fill-B6{fill:#F7F8FE;} + .d2-4075137226 .fill-AA2{fill:#4A6FF3;} + .d2-4075137226 .fill-AA4{fill:#EDF0FD;} + .d2-4075137226 .fill-AA5{fill:#F7F8FE;} + .d2-4075137226 .fill-AB4{fill:#EDF0FD;} + .d2-4075137226 .fill-AB5{fill:#F7F8FE;} + .d2-4075137226 .stroke-N1{stroke:#0A0F25;} + .d2-4075137226 .stroke-N2{stroke:#676C7E;} + .d2-4075137226 .stroke-N3{stroke:#9499AB;} + .d2-4075137226 .stroke-N4{stroke:#CFD2DD;} + .d2-4075137226 .stroke-N5{stroke:#DEE1EB;} + .d2-4075137226 .stroke-N6{stroke:#EEF1F8;} + .d2-4075137226 .stroke-N7{stroke:#FFFFFF;} + .d2-4075137226 .stroke-B1{stroke:#0D32B2;} + .d2-4075137226 .stroke-B2{stroke:#0D32B2;} + .d2-4075137226 .stroke-B3{stroke:#E3E9FD;} + .d2-4075137226 .stroke-B4{stroke:#E3E9FD;} + .d2-4075137226 .stroke-B5{stroke:#EDF0FD;} + .d2-4075137226 .stroke-B6{stroke:#F7F8FE;} + .d2-4075137226 .stroke-AA2{stroke:#4A6FF3;} + .d2-4075137226 .stroke-AA4{stroke:#EDF0FD;} + .d2-4075137226 .stroke-AA5{stroke:#F7F8FE;} + .d2-4075137226 .stroke-AB4{stroke:#EDF0FD;} + .d2-4075137226 .stroke-AB5{stroke:#F7F8FE;} + .d2-4075137226 .background-color-N1{background-color:#0A0F25;} + .d2-4075137226 .background-color-N2{background-color:#676C7E;} + .d2-4075137226 .background-color-N3{background-color:#9499AB;} + .d2-4075137226 .background-color-N4{background-color:#CFD2DD;} + .d2-4075137226 .background-color-N5{background-color:#DEE1EB;} + .d2-4075137226 .background-color-N6{background-color:#EEF1F8;} + .d2-4075137226 .background-color-N7{background-color:#FFFFFF;} + .d2-4075137226 .background-color-B1{background-color:#0D32B2;} + .d2-4075137226 .background-color-B2{background-color:#0D32B2;} + .d2-4075137226 .background-color-B3{background-color:#E3E9FD;} + .d2-4075137226 .background-color-B4{background-color:#E3E9FD;} + .d2-4075137226 .background-color-B5{background-color:#EDF0FD;} + .d2-4075137226 .background-color-B6{background-color:#F7F8FE;} + .d2-4075137226 .background-color-AA2{background-color:#4A6FF3;} + .d2-4075137226 .background-color-AA4{background-color:#EDF0FD;} + .d2-4075137226 .background-color-AA5{background-color:#F7F8FE;} + .d2-4075137226 .background-color-AB4{background-color:#EDF0FD;} + .d2-4075137226 .background-color-AB5{background-color:#F7F8FE;} + .d2-4075137226 .color-N1{color:#0A0F25;} + .d2-4075137226 .color-N2{color:#676C7E;} + .d2-4075137226 .color-N3{color:#9499AB;} + .d2-4075137226 .color-N4{color:#CFD2DD;} + .d2-4075137226 .color-N5{color:#DEE1EB;} + .d2-4075137226 .color-N6{color:#EEF1F8;} + .d2-4075137226 .color-N7{color:#FFFFFF;} + .d2-4075137226 .color-B1{color:#0D32B2;} + .d2-4075137226 .color-B2{color:#0D32B2;} + .d2-4075137226 .color-B3{color:#E3E9FD;} + .d2-4075137226 .color-B4{color:#E3E9FD;} + .d2-4075137226 .color-B5{color:#EDF0FD;} + .d2-4075137226 .color-B6{color:#F7F8FE;} + .d2-4075137226 .color-AA2{color:#4A6FF3;} + .d2-4075137226 .color-AA4{color:#EDF0FD;} + .d2-4075137226 .color-AA5{color:#F7F8FE;} + .d2-4075137226 .color-AB4{color:#EDF0FD;} + .d2-4075137226 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-4075137226);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-4075137226);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-4075137226);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-4075137226);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-4075137226);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-4075137226);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-4075137226);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-4075137226);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]>hello 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" } ] }