layout with grids

This commit is contained in:
Gavin Nishizawa 2023-03-31 17:18:17 -07:00
parent 5b22382cfd
commit d139eeadad
No known key found for this signature in database
GPG key ID: AE3B177777CE55CD
11 changed files with 493 additions and 4 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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)
}

View file

@ -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

6
d2graph/grid.go Normal file
View file

@ -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)
}

223
d2layouts/d2grid/layout.go Normal file
View file

@ -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()]
})
}

View file

@ -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

View file

@ -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" {

View file

@ -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

180
testdata/d2compiler/TestCompile/grid.exp.json generated vendored Normal file
View file

@ -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
}

12
testdata/d2compiler/TestCompile/grid_negative.exp.json generated vendored Normal file
View file

@ -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\""
}
]
}
}