From c958269b03f2b9731a788e4b112482836ff89768 Mon Sep 17 00:00:00 2001 From: Gavin Nishizawa Date: Mon, 3 Apr 2023 21:38:08 -0700 Subject: [PATCH] more dynamic grid sizing according to node sizes --- d2graph/grid.go | 2 +- d2layouts/d2grid/grid.go | 91 ++++---- d2layouts/d2grid/layout.go | 251 ++++++++++++++++++++--- e2etests/testdata/files/dagger_grid.d2 | 80 ++++++-- e2etests/testdata/files/teleport_grid.d2 | 2 +- 5 files changed, 315 insertions(+), 111 deletions(-) diff --git a/d2graph/grid.go b/d2graph/grid.go index b746f11a3..1ff029bba 100644 --- a/d2graph/grid.go +++ b/d2graph/grid.go @@ -1,6 +1,6 @@ package d2graph func (obj *Object) IsGrid() bool { - return obj != nil && obj.Attributes != nil && len(obj.ChildrenArray) != 0 && + return obj != nil && obj.Attributes != nil && (obj.Attributes.Rows != nil || obj.Attributes.Columns != nil) } diff --git a/d2layouts/d2grid/grid.go b/d2layouts/d2grid/grid.go index 4a743c60f..22cc23afd 100644 --- a/d2layouts/d2grid/grid.go +++ b/d2layouts/d2grid/grid.go @@ -1,7 +1,6 @@ package d2grid import ( - "math" "strconv" "oss.terrastruct.com/d2/d2graph" @@ -13,6 +12,8 @@ type grid struct { rows int columns int + rowDominant bool + cellWidth float64 cellHeight float64 width float64 @@ -29,64 +30,38 @@ func newGrid(root *d2graph.Object) *grid { } // compute exact row/column count based on values entered - if g.rows == 0 { - // set rows based on number of columns - 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++ - } + if g.columns == 0 { + g.rowDominant = true + } else if g.rows == 0 { + g.rowDominant = false } else { - // rows and columns specified (add more rows if needed) + // if keyword rows is first, rows are primary, columns secondary. + if root.Attributes.Rows.MapKey.Range.Before(root.Attributes.Columns.MapKey.Range) { + g.rowDominant = true + } + + // rows and columns specified, but we want to continue naturally if user enters more nodes + // e.g. 2 rows, 3 columns specified + g node added: │ with 3 columns, 2 rows: + // . original add row add column │ original add row add column + // . ┌───────┐ ┌───────┐ ┌─────────┐ │ ┌───────┐ ┌───────┐ ┌─────────┐ + // . │ a b c │ │ a b c │ │ a b c d │ │ │ a c e │ │ a d g │ │ a c e g │ + // . │ d e f │ │ d e f │ │ e f g │ │ │ b d f │ │ b e │ │ b d f │ + // . └───────┘ │ g │ └─────────┘ │ └───────┘ │ c f │ └─────────┘ + // . └───────┘ ▲ │ └───────┘ ▲ + // . ▲ └─existing nodes modified │ ▲ └─existing nodes preserved + // . └─existing rows preserved │ └─existing rows modified capacity := g.rows * g.columns for capacity < len(g.nodes) { - g.rows++ - capacity += g.columns + if g.rowDominant { + g.rows++ + capacity += g.columns + } else { + g.columns++ + capacity += g.rows + } } } - // if we have the following nodes for a 2 row, 3 column grid - // . ┌A──────────────────┐ ┌B─────┐ ┌C────────────┐ ┌D───────────┐ ┌E───────────────────┐ - // . │ │ │ │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ │ │ │ - // . └───────────────────┘ │ │ │ │ │ │ │ │ - // . │ │ └─────────────┘ │ │ │ │ - // . │ │ │ │ └────────────────────┘ - // . └──────┘ │ │ - // . └────────────┘ - // Then we must get the max width and max height to determine the grid cell size - // . maxWidth├────────────────────┤ - // . ┌A───────────────────┐ ┌B───────────────────┐ ┌C───────────────────┐ ┬maxHeight - // . │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ - // . │ │ │ │ │ │ │ - // . └────────────────────┘ └────────────────────┘ └────────────────────┘ ┴ - // . ┌D───────────────────┐ ┌E───────────────────┐ - // . │ │ │ │ - // . │ │ │ │ - // . │ │ │ │ - // . │ │ │ │ - // . │ │ │ │ - // . │ │ │ │ - // . └────────────────────┘ └────────────────────┘ - var maxWidth, maxHeight float64 - for _, n := range g.nodes { - maxWidth = math.Max(maxWidth, n.Width) - maxHeight = math.Max(maxHeight, n.Height) - } - g.cellWidth = maxWidth - g.cellHeight = maxHeight - g.width = maxWidth + (float64(g.columns)-1)*(maxWidth+HORIZONTAL_PAD) - g.height = maxHeight + (float64(g.rows)-1)*(maxHeight+VERTICAL_PAD) - return &g } @@ -96,3 +71,13 @@ func (g *grid) shift(dx, dy float64) { obj.TopLeft.Y += dy } } + +func (g *grid) cleanup(obj *d2graph.Object, graph *d2graph.Graph) { + obj.Children = make(map[string]*d2graph.Object) + obj.ChildrenArray = make([]*d2graph.Object, 0) + for _, child := range g.nodes { + obj.Children[child.ID] = child + obj.ChildrenArray = append(obj.ChildrenArray, child) + } + graph.Objects = append(graph.Objects, g.nodes...) +} diff --git a/d2layouts/d2grid/layout.go b/d2layouts/d2grid/layout.go index e9d68bccd..755146535 100644 --- a/d2layouts/d2grid/layout.go +++ b/d2layouts/d2grid/layout.go @@ -2,6 +2,7 @@ package d2grid import ( "context" + "math" "sort" "oss.terrastruct.com/d2/d2graph" @@ -33,7 +34,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, layout d2graph.LayoutGraph) d return err } - if g.Root.IsGrid() { + if g.Root.IsGrid() && len(g.Root.ChildrenArray) != 0 { g.Root.TopLeft = geo.NewPoint(0, 0) } else if err := layout(ctx, g); err != nil { return err @@ -93,20 +94,207 @@ func withoutGrids(ctx context.Context, g *d2graph.Graph) (idToGrid map[string]*g func layoutGrid(g *d2graph.Graph, obj *d2graph.Object) (*grid, error) { grid := newGrid(obj) + // assume we have the following nodes to layout: + // . ┌A──────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐ + // . └───────────────┘ │ │ │ │ │ │ │ │ + // . │ │ └──────────┘ │ │ │ │ + // . │ │ │ │ └─────────────────┘ + // . └───┘ │ │ + // . └─────────┘ + // Note: if the grid is row dominant, all nodes should be the same height (same width if column dominant) + // . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐ + // . ├ ─ ─ ─ ─ ─ ─ ─┤ │ │ │ │ │ │ │ │ + // . │ │ │ │ ├ ─ ─ ─ ─ ─┤ │ │ │ │ + // . │ │ │ │ │ │ │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ + // . │ │ ├ ─ ┤ │ │ │ │ │ │ + // . └──────────────┘ └───┘ └──────────┘ └─────────┘ └─────────────────┘ - // position nodes - cursor := geo.NewPoint(0, 0) - for i := 0; i < grid.rows; i++ { - for j := 0; j < grid.columns; j++ { - n := grid.nodes[i*grid.columns+j] - n.Width = grid.cellWidth - n.Height = grid.cellHeight - n.TopLeft = cursor.Copy() - cursor.X += grid.cellWidth + HORIZONTAL_PAD - } - cursor.X = 0 - cursor.Y += float64(grid.cellHeight) + VERTICAL_PAD + // we want to split up the total width across the N rows or columns as evenly as possible + var totalWidth, totalHeight float64 + for _, n := range grid.nodes { + totalWidth += n.Width + totalHeight += n.Height } + totalWidth += HORIZONTAL_PAD * float64(len(grid.nodes)-1) + totalHeight += VERTICAL_PAD * float64(len(grid.nodes)-1) + + layout := [][]int{{}} + if grid.rowDominant { + targetWidth := totalWidth / float64(grid.rows) + rowWidth := 0. + rowIndex := 0 + for i, n := range grid.nodes { + layout[rowIndex] = append(layout[rowIndex], i) + rowWidth += n.Width + HORIZONTAL_PAD + // add a new row if we pass the target width and there are more nodes + if rowWidth > targetWidth && i < len(grid.nodes)-1 { + layout = append(layout, []int{}) + rowIndex++ + rowWidth = 0 + } + } + } else { + targetHeight := totalHeight / float64(grid.columns) + columnHeight := 0. + columnIndex := 0 + for i, n := range grid.nodes { + layout[columnIndex] = append(layout[columnIndex], i) + columnHeight += n.Height + VERTICAL_PAD + if columnHeight > targetHeight && i < len(grid.nodes)-1 { + layout = append(layout, []int{}) + columnIndex++ + columnHeight = 0 + } + } + } + + cursor := geo.NewPoint(0, 0) + var maxY, maxX float64 + if grid.rowDominant { + // if we have 2 rows, then each row's nodes should have the same height + // . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┬ maxHeight(A,B,C) + // . ├ ─ ─ ─ ─ ─ ─ ─┤ │ │ │ │ │ + // . │ │ │ │ ├ ─ ─ ─ ─ ─┤ │ + // . │ │ │ │ │ │ │ + // . └──────────────┘ └───┘ └──────────┘ ┴ + // . ┌D────────┐ ┌E────────────────┐ ┬ maxHeight(D,E) + // . │ │ │ │ │ + // . │ │ │ │ │ + // . │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ │ + // . │ │ │ │ │ + // . └─────────┘ └─────────────────┘ ┴ + rowWidths := []float64{} + for _, row := range layout { + rowHeight := 0. + for _, nodeIndex := range row { + n := grid.nodes[nodeIndex] + n.TopLeft = cursor.Copy() + cursor.X += n.Width + HORIZONTAL_PAD + rowHeight = math.Max(rowHeight, n.Height) + } + rowWidth := cursor.X - HORIZONTAL_PAD + rowWidths = append(rowWidths, rowWidth) + maxX = math.Max(maxX, rowWidth) + + // set all nodes in row to the same height + for _, nodeIndex := range row { + n := grid.nodes[nodeIndex] + n.Height = rowHeight + } + + // new row + cursor.X = 0 + cursor.Y += rowHeight + VERTICAL_PAD + } + maxY = cursor.Y - VERTICAL_PAD + + // then expand thinnest nodes to make each row the same width + // . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┬ maxHeight(A,B,C) + // . │ │ │ │ │ │ │ + // . │ │ │ │ │ │ │ + // . │ │ │ │ │ │ │ + // . └──────────────┘ └───┘ └──────────┘ ┴ + // . ┌D────────┬────┐ ┌E────────────────┐ ┬ maxHeight(D,E) + // . │ │ │ │ │ + // . │ │ │ │ │ │ + // . │ │ │ │ │ + // . │ │ │ │ │ │ + // . └─────────┴────┘ └─────────────────┘ ┴ + for i, row := range layout { + rowWidth := rowWidths[i] + if rowWidth == maxX { + continue + } + delta := maxX - rowWidth + nodes := []*d2graph.Object{} + var widest float64 + for _, nodeIndex := range row { + n := grid.nodes[nodeIndex] + widest = math.Max(widest, n.Width) + nodes = append(nodes, n) + } + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].Width < nodes[j].Width + }) + // expand smaller nodes to fill remaining space + for _, n := range nodes { + if n.Width < widest { + var index int + for i, nodeIndex := range row { + if n == grid.nodes[nodeIndex] { + index = i + break + } + } + grow := math.Min(widest-n.Width, delta) + n.Width += grow + // shift following nodes + for i := index + 1; i < len(row); i++ { + grid.nodes[row[i]].TopLeft.X += grow + } + delta -= grow + if delta <= 0 { + break + } + } + } + if delta > 0 { + grow := delta / float64(len(row)) + for i := len(row) - 1; i >= 0; i-- { + n := grid.nodes[row[i]] + n.TopLeft.X += grow * float64(i) + n.Width += grow + delta -= grow + } + } + } + } else { + // if we have 3 columns, then each column's nodes should have the same width + // . ├maxWidth(A,B)─┤ ├maxW(C,D)─┤ ├maxWidth(E)──────┤ + // . ┌A─────────────┐ ┌C─────────┐ ┌E────────────────┐ + // . └──────────────┘ │ │ │ │ + // . ┌B──┬──────────┐ └──────────┘ │ │ + // . │ │ ┌D────────┬┐ └─────────────────┘ + // . │ │ │ │ │ + // . │ │ │ ││ + // . └───┴──────────┘ │ │ + // . │ ││ + // . └─────────┴┘ + for _, column := range layout { + columnWidth := 0. + for _, nodeIndex := range column { + n := grid.nodes[nodeIndex] + n.TopLeft = cursor.Copy() + cursor.Y += n.Height + VERTICAL_PAD + columnWidth = math.Max(columnWidth, n.Width) + } + maxY = math.Max(maxY, cursor.Y-VERTICAL_PAD) + // set all nodes in column to the same width + for _, nodeIndex := range column { + n := grid.nodes[nodeIndex] + n.Width = columnWidth + } + + // new column + cursor.Y = 0 + cursor.X += columnWidth + HORIZONTAL_PAD + } + maxX = cursor.X - HORIZONTAL_PAD + // then expand shortest nodes to make each column the same height + // . ├maxWidth(A,B)─┤ ├maxW(C,D)─┤ ├maxWidth(E)──────┤ + // . ┌A─────────────┐ ┌C─────────┐ ┌E────────────────┐ + // . ├ ─ ─ ─ ─ ─ ─ ┤ │ │ │ │ + // . │ │ └──────────┘ │ │ + // . └──────────────┘ ┌D─────────┐ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ + // . ┌B─────────────┐ │ │ │ │ + // . │ │ │ │ │ │ + // . │ │ │ │ │ │ + // . │ │ │ │ │ │ + // . └──────────────┘ └──────────┘ └─────────────────┘ + // TODO see rows + } + grid.width = maxX + grid.height = maxY // position labels and icons for _, n := range grid.nodes { @@ -125,37 +313,32 @@ func layoutGrid(g *d2graph.Graph, obj *d2graph.Object) (*grid, error) { // - 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 +func cleanup(graph *d2graph.Graph, grids map[string]*grid, objectsOrder map[string]int) { + defer func() { + sort.SliceStable(graph.Objects, func(i, j int) bool { + return objectsOrder[graph.Objects[i].AbsID()] < objectsOrder[graph.Objects[j].AbsID()] + }) + }() + + if graph.Root.IsGrid() { + grid, exists := grids[graph.Root.AbsID()] + if exists { + grid.cleanup(graph.Root, graph) + return + } } - for _, obj := range objects { + + for _, obj := range graph.Objects { grid, exists := grids[obj.AbsID()] if !exists { continue } obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter)) - // shift the grid from (0, 0) grid.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 grid.nodes { - obj.Children[child.ID] = child - obj.ChildrenArray = append(obj.ChildrenArray, child) - } - - g.Objects = append(g.Objects, grid.nodes...) + grid.cleanup(obj, graph) } - - sort.SliceStable(g.Objects, func(i, j int) bool { - return objectsOrder[g.Objects[i].AbsID()] < objectsOrder[g.Objects[j].AbsID()] - }) } diff --git a/e2etests/testdata/files/dagger_grid.d2 b/e2etests/testdata/files/dagger_grid.d2 index 66ea0432f..b3db8ac9a 100644 --- a/e2etests/testdata/files/dagger_grid.d2 +++ b/e2etests/testdata/files/dagger_grid.d2 @@ -1,25 +1,61 @@ -todo root level grid: { - rows: 4 +rows: 4 +style.fill: black - flow: { - width: 800 - height: 200 +flow: "" { + width: 800 + height: 200 + style: { + fill: cornflowerblue } - - dagger engine: { - width: 800 - } - - any docker compatible runtime: { - width: 800 - } - - any ci: { - width: 800 - } - - windows - linux - macos - kubernetes +} + +DAGGER ENGINE: { + width: 800 + style: { + fill: beige + stroke: darkcyan + font-color: blue + stroke-width: 8 + } +} + +ANY DOCKER COMPATIBLE RUNTIME: { + width: 800 + style: { + fill: lightcyan + stroke: darkcyan + font-color: black + stroke-width: 8 + } + icon: https://icons.terrastruct.com/dev%2Fdocker.svg +} + +ANY CI: { + style: { + fill: gold + stroke: maroon + font-color: maroon + stroke-width: 8 + } +} + +WINDOWS.style: { + font-color: white + fill: darkcyan + stroke: black +} +LINUX.style: { + font-color: white + fill: darkcyan + stroke: black +} +MACOS.style: { + font-color: white + fill: darkcyan + stroke: black +} +KUBERNETES.style: { + font-color: white + fill: darkcyan + stroke: black } diff --git a/e2etests/testdata/files/teleport_grid.d2 b/e2etests/testdata/files/teleport_grid.d2 index fc8e6476e..ca6b0acac 100644 --- a/e2etests/testdata/files/teleport_grid.d2 +++ b/e2etests/testdata/files/teleport_grid.d2 @@ -9,7 +9,7 @@ teleport -> identity provider teleport <- identity provider users: "" { - rows: 2 + columns: 1 Engineers: { shape: circle