From d139eeadadeadaa9d72cf97cfa5f3a5cf895e4d7 Mon Sep 17 00:00:00 2001 From: Gavin Nishizawa Date: Fri, 31 Mar 2023 17:18:17 -0700 Subject: [PATCH] layout with grids --- d2compiler/compile.go | 26 ++ d2compiler/compile_test.go | 20 ++ d2exporter/export_test.go | 3 +- d2graph/d2graph.go | 8 + d2graph/grid.go | 6 + d2layouts/d2grid/layout.go | 223 ++++++++++++++++++ d2layouts/d2sequence/layout.go | 2 +- d2lib/d2.go | 7 +- d2oracle/edit.go | 10 + testdata/d2compiler/TestCompile/grid.exp.json | 180 ++++++++++++++ .../TestCompile/grid_negative.exp.json | 12 + 11 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 d2graph/grid.go create mode 100644 d2layouts/d2grid/layout.go create mode 100644 testdata/d2compiler/TestCompile/grid.exp.json create mode 100644 testdata/d2compiler/TestCompile/grid_negative.exp.json diff --git a/d2compiler/compile.go b/d2compiler/compile.go index 28288795d..4dd2badbb 100644 --- a/d2compiler/compile.go +++ b/d2compiler/compile.go @@ -362,6 +362,32 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) { } attrs.Constraint.Value = scalar.ScalarString() attrs.Constraint.MapKey = f.LastPrimaryKey() + case "rows": + v, err := strconv.Atoi(scalar.ScalarString()) + if err != nil { + c.errorf(scalar, "non-integer rows %#v: %s", scalar.ScalarString(), err) + return + } + if v < 0 { + c.errorf(scalar, "rows must be a non-negative integer: %#v", scalar.ScalarString()) + return + } + attrs.Rows = &d2graph.Scalar{} + attrs.Rows.Value = scalar.ScalarString() + attrs.Rows.MapKey = f.LastPrimaryKey() + case "columns": + v, err := strconv.Atoi(scalar.ScalarString()) + if err != nil { + c.errorf(scalar, "non-integer columns %#v: %s", scalar.ScalarString(), err) + return + } + if v < 0 { + c.errorf(scalar, "columns must be a non-negative integer: %#v", scalar.ScalarString()) + return + } + attrs.Columns = &d2graph.Scalar{} + attrs.Columns.Value = scalar.ScalarString() + attrs.Columns.MapKey = f.LastPrimaryKey() } if attrs.Link != nil && attrs.Tooltip != nil { diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go index 53e33e22f..7dc218cae 100644 --- a/d2compiler/compile_test.go +++ b/d2compiler/compile_test.go @@ -2268,6 +2268,26 @@ obj { `, expErr: `d2/testdata/d2compiler/TestCompile/near_near_const.d2:7:8: near keys cannot be set to an object with a constant near key`, }, + { + name: "grid", + text: `hey: { + rows: 200 + columns: 230 +} +`, + assertions: func(t *testing.T, g *d2graph.Graph) { + tassert.Equal(t, "200", g.Objects[0].Attributes.Rows.Value) + }, + }, + { + name: "grid_negative", + text: `hey: { + rows: 200 + columns: -200 +} +`, + expErr: `d2/testdata/d2compiler/TestCompile/grid_negative.d2:3:11: columns must be a non-negative integer: "-200"`, + }, } for _, tc := range testCases { diff --git a/d2exporter/export_test.go b/d2exporter/export_test.go index effcc07a8..bc6f47010 100644 --- a/d2exporter/export_test.go +++ b/d2exporter/export_test.go @@ -16,6 +16,7 @@ import ( "oss.terrastruct.com/d2/d2compiler" "oss.terrastruct.com/d2/d2exporter" "oss.terrastruct.com/d2/d2layouts/d2dagrelayout" + "oss.terrastruct.com/d2/d2layouts/d2grid" "oss.terrastruct.com/d2/d2layouts/d2sequence" "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/lib/geo" @@ -231,7 +232,7 @@ func run(t *testing.T, tc testCase) { err = g.SetDimensions(nil, ruler, nil) assert.JSON(t, nil, err) - err = d2sequence.Layout(ctx, g, d2dagrelayout.DefaultLayout) + err = d2sequence.Layout(ctx, g, d2grid.Layout(ctx, g, d2dagrelayout.DefaultLayout)) if err != nil { t.Fatal(err) } diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index a9c370620..42f42977a 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -1,6 +1,7 @@ package d2graph import ( + "context" "errors" "fmt" "math" @@ -67,6 +68,8 @@ func (g *Graph) RootBoard() *Graph { return g } +type LayoutGraph func(context.Context, *Graph) error + // TODO consider having different Scalar types // Right now we'll hold any types in Value and just convert, e.g. floats type Scalar struct { @@ -129,6 +132,9 @@ type Attributes struct { Direction Scalar `json:"direction"` Constraint Scalar `json:"constraint"` + + Rows *Scalar `json:"rows,omitempty"` + Columns *Scalar `json:"columns,omitempty"` } // TODO references at the root scope should have their Scope set to root graph AST @@ -1534,6 +1540,8 @@ var SimpleReservedKeywords = map[string]struct{}{ "direction": {}, "top": {}, "left": {}, + "rows": {}, + "columns": {}, } // ReservedKeywordHolders are reserved keywords that are meaningless on its own and exist solely to hold a set of reserved keywords diff --git a/d2graph/grid.go b/d2graph/grid.go new file mode 100644 index 000000000..b746f11a3 --- /dev/null +++ b/d2graph/grid.go @@ -0,0 +1,6 @@ +package d2graph + +func (obj *Object) IsGrid() bool { + return obj != nil && obj.Attributes != nil && len(obj.ChildrenArray) != 0 && + (obj.Attributes.Rows != nil || obj.Attributes.Columns != nil) +} diff --git a/d2layouts/d2grid/layout.go b/d2layouts/d2grid/layout.go new file mode 100644 index 000000000..05f4176f2 --- /dev/null +++ b/d2layouts/d2grid/layout.go @@ -0,0 +1,223 @@ +package d2grid + +import ( + "context" + "math" + "sort" + "strconv" + + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/util-go/go2" +) + +const CONTAINER_PADDING = 60. +const HORIZONTAL_PAD = 40. +const VERTICAL_PAD = 40. + +type grid struct { + root *d2graph.Object + nodes []*d2graph.Object + rows int + columns int + + width float64 + height float64 +} + +func newGrid(root *d2graph.Object) *grid { + g := grid{root: root, nodes: root.ChildrenArray} + if root.Attributes.Rows != nil { + g.rows, _ = strconv.Atoi(root.Attributes.Rows.Value) + } + if root.Attributes.Columns != nil { + g.columns, _ = strconv.Atoi(root.Attributes.Columns.Value) + } + + // compute exact row/column count based on values entered + // TODO consider making this based on node dimensions + if g.rows == 0 { + // set rows based on number of columns + if g.columns == 0 { + // 0,0: put everything in one row + g.rows = 1 + g.columns = len(g.nodes) + } else { + g.rows = len(g.nodes) / g.columns + if len(g.nodes)%g.columns != 0 { + g.rows++ + } + } + } else if g.columns == 0 { + // set columns based on number of rows + g.columns = len(g.nodes) / g.rows + if len(g.nodes)%g.rows != 0 { + g.columns++ + } + } else { + // rows and columns specified (add more rows if needed) + capacity := g.rows * g.columns + for capacity < len(g.nodes) { + g.rows++ + capacity += g.columns + } + } + + return &g +} + +func (g *grid) shift(dx, dy float64) { + for _, obj := range g.nodes { + obj.TopLeft.X += dx + obj.TopLeft.Y += dy + } +} + +// Layout runs the grid layout on containers with rows/columns +// Note: children are not allowed edges or descendants +// +// 1. Traverse graph from root, skip objects with no rows/columns +// 2. Construct a grid with the container children +// 3. Remove the children from the main graph +// 4. Run grid layout +// 5. Set the resulting dimensions to the main graph shape +// 6. Run core layouts (without grid children) +// 7. Put grid children back in correct location +func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) d2graph.LayoutGraph { + return func(ctx context.Context, g *d2graph.Graph) error { + grids, objectOrder, err := withoutGrids(ctx, g) + if err != nil { + return err + } + + if g.Root.IsGrid() { + g.Root.TopLeft = geo.NewPoint(0, 0) + } else if err := layout(ctx, g); err != nil { + return err + } + + cleanup(g, grids, objectOrder) + return nil + } +} + +func layoutGrid(g *d2graph.Graph, obj *d2graph.Object) (*grid, error) { + grid := newGrid(obj) + + // position nodes + cursor := geo.NewPoint(0, 0) + maxWidth := 0. + for i := 0; i < grid.rows; i++ { + maxHeight := 0. + for j := 0; j < grid.columns; j++ { + n := grid.nodes[i*grid.columns+j] + n.TopLeft = cursor.Copy() + cursor.X += n.Width + HORIZONTAL_PAD + maxHeight = math.Max(maxHeight, n.Height) + } + maxWidth = math.Max(maxWidth, cursor.X-HORIZONTAL_PAD) + cursor.X = 0 + cursor.Y += float64(maxHeight) + VERTICAL_PAD + } + grid.width = maxWidth + grid.height = cursor.Y - VERTICAL_PAD + + // position labels and icons + for _, n := range grid.nodes { + if n.Attributes.Icon != nil { + n.LabelPosition = go2.Pointer(string(label.InsideTopCenter)) + n.IconPosition = go2.Pointer(string(label.InsideMiddleCenter)) + } else { + n.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter)) + } + } + + return grid, nil +} + +func withoutGrids(ctx context.Context, g *d2graph.Graph) (idToGrid map[string]*grid, objectOrder map[string]int, err error) { + toRemove := make(map[*d2graph.Object]struct{}) + grids := make(map[string]*grid) + + if len(g.Objects) > 0 { + queue := make([]*d2graph.Object, 1, len(g.Objects)) + queue[0] = g.Root + for len(queue) > 0 { + obj := queue[0] + queue = queue[1:] + if len(obj.ChildrenArray) == 0 { + continue + } + if !obj.IsGrid() { + queue = append(queue, obj.ChildrenArray...) + continue + } + + grid, err := layoutGrid(g, obj) + if err != nil { + return nil, nil, err + } + obj.Children = make(map[string]*d2graph.Object) + obj.ChildrenArray = nil + obj.Box = geo.NewBox(nil, grid.width+CONTAINER_PADDING*2, grid.height+CONTAINER_PADDING*2) + obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter)) + grids[obj.AbsID()] = grid + + for _, node := range grid.nodes { + toRemove[node] = struct{}{} + } + } + } + + objectOrder = make(map[string]int) + layoutObjects := make([]*d2graph.Object, 0, len(toRemove)) + for i, obj := range g.Objects { + objectOrder[obj.AbsID()] = i + if _, exists := toRemove[obj]; !exists { + layoutObjects = append(layoutObjects, obj) + } + } + g.Objects = layoutObjects + + return grids, objectOrder, nil +} + +// cleanup restores the graph after the core layout engine finishes +// - translating the grid to its position placed by the core layout engine +// - restore the children of the grid +// - sorts objects to their original graph order +func cleanup(g *d2graph.Graph, grids map[string]*grid, objectsOrder map[string]int) { + var objects []*d2graph.Object + if g.Root.IsGrid() { + objects = []*d2graph.Object{g.Root} + } else { + objects = g.Objects + } + for _, obj := range objects { + if _, exists := grids[obj.AbsID()]; !exists { + continue + } + obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter)) + sd := grids[obj.AbsID()] + + // shift the grid from (0, 0) + sd.shift( + obj.TopLeft.X+CONTAINER_PADDING, + obj.TopLeft.Y+CONTAINER_PADDING, + ) + + obj.Children = make(map[string]*d2graph.Object) + obj.ChildrenArray = make([]*d2graph.Object, 0) + for _, child := range sd.nodes { + obj.Children[child.ID] = child + obj.ChildrenArray = append(obj.ChildrenArray, child) + } + + g.Objects = append(g.Objects, grids[obj.AbsID()].nodes...) + } + + sort.SliceStable(g.Objects, func(i, j int) bool { + return objectsOrder[g.Objects[i].AbsID()] < objectsOrder[g.Objects[j].AbsID()] + }) +} diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go index 28755f3e0..cfae446c4 100644 --- a/d2layouts/d2sequence/layout.go +++ b/d2layouts/d2sequence/layout.go @@ -78,7 +78,7 @@ func WithoutSequenceDiagrams(ctx context.Context, g *d2graph.Graph) (map[string] // 5. Set the resulting dimensions to the main graph shape // 6. Run core layouts (still without sequence diagram innards) // 7. Put back sequence diagram innards in correct location -func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Context, g *d2graph.Graph) error) error { +func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) error { sequenceDiagrams, objectOrder, edgeOrder, err := WithoutSequenceDiagrams(ctx, g) if err != nil { return err diff --git a/d2lib/d2.go b/d2lib/d2.go index 060204ea9..11638833a 100644 --- a/d2lib/d2.go +++ b/d2lib/d2.go @@ -10,6 +10,7 @@ import ( "oss.terrastruct.com/d2/d2exporter" "oss.terrastruct.com/d2/d2graph" "oss.terrastruct.com/d2/d2layouts/d2dagrelayout" + "oss.terrastruct.com/d2/d2layouts/d2grid" "oss.terrastruct.com/d2/d2layouts/d2near" "oss.terrastruct.com/d2/d2layouts/d2sequence" "oss.terrastruct.com/d2/d2renderers/d2fonts" @@ -70,7 +71,9 @@ func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2ta constantNears := d2near.WithoutConstantNears(ctx, g) - err = d2sequence.Layout(ctx, g, coreLayout) + layoutWithGrids := d2grid.Layout(ctx, g, coreLayout) + + err = d2sequence.Layout(ctx, g, layoutWithGrids) if err != nil { return nil, err } @@ -110,7 +113,7 @@ func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2ta return d, nil } -func getLayout(opts *CompileOptions) (func(context.Context, *d2graph.Graph) error, error) { +func getLayout(opts *CompileOptions) (d2graph.LayoutGraph, error) { if opts.Layout != nil { return opts.Layout, nil } else if os.Getenv("D2_LAYOUT") == "dagre" { diff --git a/d2oracle/edit.go b/d2oracle/edit.go index 668b6d4a0..376634e22 100644 --- a/d2oracle/edit.go +++ b/d2oracle/edit.go @@ -314,6 +314,16 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error { attrs.Left.MapKey.SetScalar(mk.Value.ScalarBox()) return nil } + case "rows": + if attrs.Rows != nil && attrs.Rows.MapKey != nil { + attrs.Rows.MapKey.SetScalar(mk.Value.ScalarBox()) + return nil + } + case "columns": + if attrs.Columns != nil && attrs.Columns.MapKey != nil { + attrs.Columns.MapKey.SetScalar(mk.Value.ScalarBox()) + return nil + } case "source-arrowhead", "target-arrowhead": if reservedKey == "source-arrowhead" { attrs = edge.SrcArrowhead diff --git a/testdata/d2compiler/TestCompile/grid.exp.json b/testdata/d2compiler/TestCompile/grid.exp.json new file mode 100644 index 000000000..8cad38d75 --- /dev/null +++ b/testdata/d2compiler/TestCompile/grid.exp.json @@ -0,0 +1,180 @@ +{ + "graph": { + "name": "", + "isFolderOnly": false, + "ast": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-4:0:34", + "nodes": [ + { + "map_key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-3:1:33", + "key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-0:3:3", + "path": [ + { + "unquoted_string": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-0:3:3", + "value": [ + { + "string": "hey", + "raw_string": "hey" + } + ] + } + } + ] + }, + "primary": {}, + "value": { + "map": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:5:5-3:0:32", + "nodes": [ + { + "map_key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,1:1:8-1:10:17", + "key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,1:1:8-1:5:12", + "path": [ + { + "unquoted_string": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,1:1:8-1:5:12", + "value": [ + { + "string": "rows", + "raw_string": "rows" + } + ] + } + } + ] + }, + "primary": {}, + "value": { + "number": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,1:7:14-1:10:17", + "raw": "200", + "value": "200" + } + } + } + }, + { + "map_key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,2:1:19-2:13:31", + "key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,2:1:19-2:8:26", + "path": [ + { + "unquoted_string": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,2:1:19-2:8:26", + "value": [ + { + "string": "columns", + "raw_string": "columns" + } + ] + } + } + ] + }, + "primary": {}, + "value": { + "number": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,2:10:28-2:13:31", + "raw": "230", + "value": "230" + } + } + } + } + ] + } + } + } + } + ] + }, + "root": { + "id": "", + "id_val": "", + "label_dimensions": { + "width": 0, + "height": 0 + }, + "attributes": { + "label": { + "value": "" + }, + "style": {}, + "near_key": null, + "shape": { + "value": "" + }, + "direction": { + "value": "" + }, + "constraint": { + "value": "" + } + }, + "zIndex": 0 + }, + "edges": null, + "objects": [ + { + "id": "hey", + "id_val": "hey", + "label_dimensions": { + "width": 0, + "height": 0 + }, + "references": [ + { + "key": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-0:3:3", + "path": [ + { + "unquoted_string": { + "range": "d2/testdata/d2compiler/TestCompile/grid.d2,0:0:0-0:3:3", + "value": [ + { + "string": "hey", + "raw_string": "hey" + } + ] + } + } + ] + }, + "key_path_index": 0, + "map_key_edge_index": -1 + } + ], + "attributes": { + "label": { + "value": "hey" + }, + "style": {}, + "near_key": null, + "shape": { + "value": "rectangle" + }, + "direction": { + "value": "" + }, + "constraint": { + "value": "" + }, + "rows": { + "value": "200" + }, + "columns": { + "value": "230" + } + }, + "zIndex": 0 + } + ] + }, + "err": null +} diff --git a/testdata/d2compiler/TestCompile/grid_negative.exp.json b/testdata/d2compiler/TestCompile/grid_negative.exp.json new file mode 100644 index 000000000..ff17efc56 --- /dev/null +++ b/testdata/d2compiler/TestCompile/grid_negative.exp.json @@ -0,0 +1,12 @@ +{ + "graph": null, + "err": { + "ioerr": null, + "errs": [ + { + "range": "d2/testdata/d2compiler/TestCompile/grid_negative.d2,2:10:28-2:14:32", + "errmsg": "d2/testdata/d2compiler/TestCompile/grid_negative.d2:3:11: columns must be a non-negative integer: \"-200\"" + } + ] + } +}