d2/d2layouts/d2grid/layout.go

1007 lines
35 KiB
Go
Raw Normal View History

2023-04-01 00:18:17 +00:00
package d2grid
import (
2023-05-10 00:19:33 +00:00
"bytes"
2023-04-01 00:18:17 +00:00
"context"
2023-04-29 01:23:00 +00:00
"fmt"
"math"
2023-04-01 00:18:17 +00:00
"oss.terrastruct.com/d2/d2graph"
2023-05-30 18:59:39 +00:00
"oss.terrastruct.com/d2/d2target"
2023-04-01 00:18:17 +00:00
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/util-go/go2"
)
2023-04-03 18:36:01 +00:00
const (
CONTAINER_PADDING = 60
DEFAULT_GAP = 40
2023-04-03 18:36:01 +00:00
)
2023-04-01 00:18:17 +00:00
// Layout runs the grid layout on containers with rows/columns
// Note: children are not allowed edges or descendants
2023-09-22 04:00:02 +00:00
// 1. Run grid layout on the graph root
// 2. Set the resulting dimensions to the graph root
func Layout(ctx context.Context, g *d2graph.Graph) error {
2023-09-16 05:07:06 +00:00
obj := g.Root
gd, err := layoutGrid(g, obj)
if err != nil {
return err
}
2023-11-09 00:17:58 +00:00
if obj.HasLabel() && obj.LabelPosition == nil {
obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
}
if obj.Icon != nil && obj.IconPosition == nil {
obj.IconPosition = go2.Pointer(label.InsideTopLeft.String())
}
2023-09-16 05:07:06 +00:00
if obj.Box != nil {
// CONTAINER_PADDING is default, but use gap value if set
horizontalPadding, verticalPadding := CONTAINER_PADDING, CONTAINER_PADDING
if obj.GridGap != nil || obj.HorizontalGap != nil {
horizontalPadding = gd.horizontalGap
}
if obj.GridGap != nil || obj.VerticalGap != nil {
verticalPadding = gd.verticalGap
}
contentWidth, contentHeight := gd.width, gd.height
2023-09-16 05:07:06 +00:00
2023-11-09 00:17:58 +00:00
var labelPosition, iconPosition label.Position
if obj.LabelPosition != nil {
labelPosition = label.FromString(*obj.LabelPosition)
}
if obj.IconPosition != nil {
iconPosition = label.FromString(*obj.IconPosition)
}
2023-09-16 05:07:06 +00:00
// compute how much space the label and icon occupy
2023-11-09 01:12:12 +00:00
_, padding := obj.Spacing()
2023-09-16 05:07:06 +00:00
2023-11-09 04:29:07 +00:00
var labelWidth, labelHeight float64
if obj.LabelDimensions.Width > 0 {
labelWidth = float64(obj.LabelDimensions.Width) + 2*label.PADDING
}
if obj.LabelDimensions.Height > 0 {
labelHeight = float64(obj.LabelDimensions.Height) + 2*label.PADDING
}
if labelWidth > 0 {
switch labelPosition {
case label.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight,
label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight,
label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight,
label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight:
overflow := labelWidth - contentWidth
2023-11-09 04:29:07 +00:00
if overflow > 0 {
padding.Left += overflow / 2
padding.Right += overflow / 2
}
}
}
if labelHeight > 0 {
switch labelPosition {
case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,
label.InsideMiddleLeft, label.InsideMiddleCenter, label.InsideMiddleRight,
label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
overflow := labelHeight - contentHeight
2023-11-09 04:29:07 +00:00
if overflow > 0 {
padding.Top += overflow / 2
padding.Bottom += overflow / 2
}
}
}
// configure spacing for default label+icon
2023-11-09 01:12:12 +00:00
if iconPosition == label.InsideTopLeft && labelPosition == label.InsideTopCenter {
2023-09-16 05:07:06 +00:00
// . ├────┤───────├────┤
// . icon label icon
// with an icon in top left we need 2x the space to fit the label in the center
2023-11-09 01:12:12 +00:00
iconSize := float64(d2target.MAX_ICON_SIZE) + 2*label.PADDING
padding.Left = math.Max(padding.Left, iconSize)
padding.Right = math.Max(padding.Right, iconSize)
minWidth := 2*iconSize + float64(obj.LabelDimensions.Width) + 2*label.PADDING
overflow := minWidth - contentWidth
2023-11-09 04:29:07 +00:00
if overflow > 0 {
2023-11-09 01:12:12 +00:00
padding.Left = math.Max(padding.Left, overflow/2)
padding.Right = math.Max(padding.Right, overflow/2)
2023-09-16 05:07:06 +00:00
}
}
2023-11-16 02:25:18 +00:00
padding.Top = math.Max(padding.Top, float64(verticalPadding))
padding.Bottom = math.Max(padding.Bottom, float64(verticalPadding))
padding.Left = math.Max(padding.Left, float64(horizontalPadding))
padding.Right = math.Max(padding.Right, float64(horizontalPadding))
totalWidth := padding.Left + contentWidth + padding.Right
totalHeight := padding.Top + contentHeight + padding.Bottom
obj.SizeToContent(totalWidth, totalHeight, 0, 0)
// compute where the grid should be placed inside shape
2023-11-16 02:25:18 +00:00
s := obj.ToShape()
innerTL := s.GetInsidePlacement(totalWidth, totalHeight, 0, 0)
// depending on the shape innerBox may be larger than totalWidth, totalHeight
// if this is the case, we want to center the cells within the larger innerBox
innerBox := s.GetInnerBox()
var resizeDx, resizeDy float64
if innerBox.Width > totalWidth {
resizeDx = (innerBox.Width - totalWidth) / 2
}
if innerBox.Height > totalHeight {
resizeDy = (innerBox.Height - totalHeight) / 2
}
2023-11-16 02:25:18 +00:00
// move from horizontalPadding,verticalPadding to innerTL.X+padding.Left, innerTL.Y+padding.Top
2023-11-16 04:53:21 +00:00
// and if innerBox is larger than content dimensions, adjust to center within innerBox
dx := -float64(horizontalPadding) + innerTL.X + padding.Left + resizeDx
dy := -float64(verticalPadding) + innerTL.Y + padding.Top + resizeDy
2023-09-16 05:07:06 +00:00
if dx != 0 || dy != 0 {
gd.shift(dx, dy)
}
}
// simple straight line edge routing between grid objects
for _, e := range g.Edges {
if !e.Src.Parent.IsDescendantOf(obj) && !e.Dst.Parent.IsDescendantOf(obj) {
continue
}
// if edge is within grid, remove it from outer layout
gd.edges = append(gd.edges, e)
if e.Src.Parent != obj || e.Dst.Parent != obj {
continue
}
// if edge is grid child, use simple routing
e.Route = []*geo.Point{e.Src.Center(), e.Dst.Center()}
e.TraceToShape(e.Route, 0, 1)
if e.Label.Value != "" {
2023-07-17 21:21:36 +00:00
e.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
2023-09-16 05:07:06 +00:00
}
}
if g.Root.IsGridDiagram() && len(g.Root.ChildrenArray) != 0 {
g.Root.TopLeft = geo.NewPoint(0, 0)
}
2023-09-22 00:45:52 +00:00
if g.RootLevel > 0 {
horizontalPadding, verticalPadding := CONTAINER_PADDING, CONTAINER_PADDING
if obj.GridGap != nil || obj.HorizontalGap != nil {
horizontalPadding = gd.horizontalGap
}
if obj.GridGap != nil || obj.VerticalGap != nil {
verticalPadding = gd.verticalGap
}
2023-09-16 05:07:06 +00:00
2023-09-22 00:45:52 +00:00
// shift the grid from (0, 0)
gd.shift(
obj.TopLeft.X+float64(horizontalPadding),
obj.TopLeft.Y+float64(verticalPadding),
)
}
2023-09-16 05:07:06 +00:00
return nil
}
2023-04-05 18:11:31 +00:00
func layoutGrid(g *d2graph.Graph, obj *d2graph.Object) (*gridDiagram, error) {
gd := newGridDiagram(obj)
2023-04-05 03:02:22 +00:00
// position labels and icons
2023-04-06 21:30:45 +00:00
for _, o := range gd.objects {
positionedLabel := false
if o.Icon != nil && o.IconPosition == nil {
if len(o.ChildrenArray) > 0 {
2023-07-17 21:21:36 +00:00
o.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
// don't overwrite position if nested graph layout positioned label/icon
if o.LabelPosition == nil {
2023-07-17 21:21:36 +00:00
o.LabelPosition = go2.Pointer(label.OutsideTopRight.String())
positionedLabel = true
}
} else {
2023-07-17 21:21:36 +00:00
o.IconPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}
if !positionedLabel && o.HasLabel() && o.LabelPosition == nil {
if len(o.ChildrenArray) > 0 {
2023-07-17 21:21:36 +00:00
o.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else if o.HasOutsideBottomLabel() {
2023-07-17 21:21:36 +00:00
o.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
} else if o.Icon != nil {
2023-07-17 21:21:36 +00:00
o.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
} else {
2023-07-17 21:21:36 +00:00
o.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
2023-04-05 03:02:22 +00:00
}
}
// to handle objects with outside labels, we adjust their dimensions before layout and
// after layout, we remove the label adjustment and reposition TopLeft if needed
revertAdjustments := gd.sizeForOutsideLabels()
if gd.rows != 0 && gd.columns != 0 {
gd.layoutEvenly(g, obj)
} else {
gd.layoutDynamic(g, obj)
}
revertAdjustments()
2023-04-05 18:11:31 +00:00
return gd, nil
2023-04-05 03:02:22 +00:00
}
2023-04-05 18:11:31 +00:00
func (gd *gridDiagram) layoutEvenly(g *d2graph.Graph, obj *d2graph.Object) {
2023-04-06 21:30:45 +00:00
// layout objects in a grid with these 2 properties:
// all objects in the same row should have the same height
// all objects in the same column should have the same width
2023-04-05 03:02:22 +00:00
2023-04-06 21:30:45 +00:00
getObject := func(rowIndex, columnIndex int) *d2graph.Object {
2023-04-05 03:02:22 +00:00
var index int
2023-04-06 21:30:45 +00:00
if gd.rowDirected {
2023-04-05 18:11:31 +00:00
index = rowIndex*gd.columns + columnIndex
2023-04-05 03:02:22 +00:00
} else {
2023-04-05 18:11:31 +00:00
index = columnIndex*gd.rows + rowIndex
2023-04-05 03:02:22 +00:00
}
2023-04-06 21:30:45 +00:00
if index < len(gd.objects) {
return gd.objects[index]
2023-04-05 03:02:22 +00:00
}
return nil
}
2023-04-05 18:11:31 +00:00
rowHeights := make([]float64, 0, gd.rows)
colWidths := make([]float64, 0, gd.columns)
for i := 0; i < gd.rows; i++ {
2023-04-05 03:02:22 +00:00
rowHeight := 0.
2023-04-05 18:11:31 +00:00
for j := 0; j < gd.columns; j++ {
2023-04-06 21:30:45 +00:00
o := getObject(i, j)
if o == nil {
2023-04-05 03:02:22 +00:00
break
}
2023-04-06 21:30:45 +00:00
rowHeight = math.Max(rowHeight, o.Height)
2023-04-05 03:02:22 +00:00
}
rowHeights = append(rowHeights, rowHeight)
}
2023-04-05 18:11:31 +00:00
for j := 0; j < gd.columns; j++ {
2023-04-05 03:02:22 +00:00
columnWidth := 0.
2023-04-05 18:11:31 +00:00
for i := 0; i < gd.rows; i++ {
2023-04-06 21:30:45 +00:00
o := getObject(i, j)
if o == nil {
2023-04-05 03:02:22 +00:00
break
}
2023-04-06 21:30:45 +00:00
columnWidth = math.Max(columnWidth, o.Width)
2023-04-05 03:02:22 +00:00
}
colWidths = append(colWidths, columnWidth)
}
horizontalGap := float64(gd.horizontalGap)
verticalGap := float64(gd.verticalGap)
2023-04-11 18:32:35 +00:00
2023-04-05 03:02:22 +00:00
cursor := geo.NewPoint(0, 0)
2023-04-06 21:30:45 +00:00
if gd.rowDirected {
2023-04-05 18:11:31 +00:00
for i := 0; i < gd.rows; i++ {
for j := 0; j < gd.columns; j++ {
2023-04-06 21:30:45 +00:00
o := getObject(i, j)
if o == nil {
2023-04-05 03:02:22 +00:00
break
}
2023-04-06 21:30:45 +00:00
o.Width = colWidths[j]
o.Height = rowHeights[i]
2023-05-09 01:38:41 +00:00
o.MoveWithDescendantsTo(cursor.X, cursor.Y)
2023-04-11 18:32:35 +00:00
cursor.X += o.Width + horizontalGap
2023-04-05 03:02:22 +00:00
}
cursor.X = 0
2023-04-11 18:32:35 +00:00
cursor.Y += rowHeights[i] + verticalGap
2023-04-05 03:02:22 +00:00
}
} else {
2023-04-05 18:11:31 +00:00
for j := 0; j < gd.columns; j++ {
for i := 0; i < gd.rows; i++ {
2023-04-06 21:30:45 +00:00
o := getObject(i, j)
if o == nil {
2023-04-05 03:02:22 +00:00
break
}
2023-04-06 21:30:45 +00:00
o.Width = colWidths[j]
o.Height = rowHeights[i]
2023-05-09 01:38:41 +00:00
o.MoveWithDescendantsTo(cursor.X, cursor.Y)
2023-04-11 18:32:35 +00:00
cursor.Y += o.Height + verticalGap
2023-04-05 03:02:22 +00:00
}
2023-04-11 18:32:35 +00:00
cursor.X += colWidths[j] + horizontalGap
2023-04-05 03:02:22 +00:00
cursor.Y = 0
}
}
var totalWidth, totalHeight float64
for _, w := range colWidths {
2023-04-11 18:32:35 +00:00
totalWidth += w + horizontalGap
2023-04-05 03:02:22 +00:00
}
for _, h := range rowHeights {
2023-04-11 18:32:35 +00:00
totalHeight += h + verticalGap
2023-04-05 03:02:22 +00:00
}
2023-04-11 18:32:35 +00:00
totalWidth -= horizontalGap
totalHeight -= verticalGap
2023-04-05 18:11:31 +00:00
gd.width = totalWidth
gd.height = totalHeight
2023-04-05 03:02:22 +00:00
}
2023-04-05 18:11:31 +00:00
func (gd *gridDiagram) layoutDynamic(g *d2graph.Graph, obj *d2graph.Object) {
2023-04-06 21:30:45 +00:00
// assume we have the following objects to layout:
// . ┌A──────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐
// . └───────────────┘ │ │ │ │ │ │ │ │
// . │ │ └──────────┘ │ │ │ │
// . │ │ │ │ └─────────────────┘
// . └───┘ │ │
// . └─────────┘
2023-04-06 21:30:45 +00:00
// Note: if the grid is row dominant, all objects should be the same height (same width if column dominant)
// . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐
// . ├ ─ ─ ─ ─ ─ ─ ─┤ │ │ │ │ │ │ │ │
// . │ │ │ │ ├ ─ ─ ─ ─ ─┤ │ │ │ │
// . │ │ │ │ │ │ │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤
// . │ │ ├ ─ ┤ │ │ │ │ │ │
// . └──────────────┘ └───┘ └──────────┘ └─────────┘ └─────────────────┘
horizontalGap := float64(gd.horizontalGap)
verticalGap := float64(gd.verticalGap)
2023-04-11 18:32:35 +00:00
// we want to split up the total width across the N rows or columns as evenly as possible
var totalWidth, totalHeight float64
2023-04-06 21:30:45 +00:00
for _, o := range gd.objects {
totalWidth += o.Width
totalHeight += o.Height
}
2023-04-11 18:32:35 +00:00
totalWidth += horizontalGap * float64(len(gd.objects)-gd.rows)
totalHeight += verticalGap * float64(len(gd.objects)-gd.columns)
var layout [][]*d2graph.Object
2023-04-06 21:30:45 +00:00
if gd.rowDirected {
2023-04-05 18:11:31 +00:00
targetWidth := totalWidth / float64(gd.rows)
layout = gd.getBestLayout(targetWidth, false)
} else {
2023-04-05 18:11:31 +00:00
targetHeight := totalHeight / float64(gd.columns)
layout = gd.getBestLayout(targetHeight, true)
}
2023-04-03 18:36:01 +00:00
cursor := geo.NewPoint(0, 0)
var maxY, maxX float64
2023-04-06 21:30:45 +00:00
if gd.rowDirected {
2023-04-29 00:05:20 +00:00
// measure row widths
rowWidths := []float64{}
for _, row := range layout {
2023-04-29 00:05:20 +00:00
x := 0.
2023-04-06 21:30:45 +00:00
for _, o := range row {
2023-04-29 00:05:20 +00:00
x += o.Width + horizontalGap
}
2023-04-29 00:05:20 +00:00
rowWidth := x - horizontalGap
rowWidths = append(rowWidths, rowWidth)
maxX = math.Max(maxX, rowWidth)
2023-04-03 18:36:01 +00:00
}
// TODO if object is a nested grid, consider growing descendants according to the inner grid layout
2023-04-06 21:30:45 +00:00
// then expand thinnest objects 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
var widest float64
2023-04-06 21:30:45 +00:00
for _, o := range row {
widest = math.Max(widest, o.Width)
}
2023-04-29 00:05:20 +00:00
diffs := make([]float64, len(row))
totalDiff := 0.
for i, o := range row {
diffs[i] = widest - o.Width
totalDiff += diffs[i]
}
if totalDiff > 0 {
// expand smaller nodes up to the size of the larger ones with delta
// percentage diff
for i := range diffs {
diffs[i] /= totalDiff
}
growth := math.Min(delta, totalDiff)
// expand smaller objects to fill remaining space
for i, o := range row {
o.Width += diffs[i] * growth
}
}
2023-04-29 00:05:20 +00:00
if delta > totalDiff {
growth := (delta - totalDiff) / float64(len(row))
for _, o := range row {
o.Width += growth
}
}
}
2023-04-29 00:05:20 +00:00
// if we have 2 rows, then each row's objects should have the same height
// . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┬ maxHeight(A,B,C)
// . ├ ─ ─ ─ ─ ─ ─ ─┤ │ │ │ │ │
// . │ │ │ │ ├ ─ ─ ─ ─ ─┤ │
// . │ │ │ │ │ │ │
// . └──────────────┘ └───┘ └──────────┘ ┴
// . ┌D────────┐ ┌E────────────────┐ ┬ maxHeight(D,E)
// . │ │ │ │ │
// . │ │ │ │ │
// . │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ │
// . │ │ │ │ │
// . └─────────┘ └─────────────────┘ ┴
for _, row := range layout {
rowHeight := 0.
for _, o := range row {
2023-05-09 01:38:41 +00:00
o.MoveWithDescendantsTo(cursor.X, cursor.Y)
2023-04-29 00:05:20 +00:00
cursor.X += o.Width + horizontalGap
rowHeight = math.Max(rowHeight, o.Height)
}
// set all objects in row to the same height
for _, o := range row {
o.Height = rowHeight
}
// new row
cursor.X = 0
cursor.Y += rowHeight + verticalGap
}
2023-10-06 01:48:51 +00:00
maxY = cursor.Y - verticalGap
} else {
2023-04-29 00:05:20 +00:00
// measure column heights
2023-04-04 18:38:33 +00:00
colHeights := []float64{}
for _, column := range layout {
2023-04-29 00:05:20 +00:00
y := 0.
2023-04-06 21:30:45 +00:00
for _, o := range column {
2023-04-29 00:05:20 +00:00
y += o.Height + verticalGap
}
2023-04-29 00:05:20 +00:00
colHeight := y - verticalGap
2023-04-04 18:38:33 +00:00
colHeights = append(colHeights, colHeight)
maxY = math.Max(maxY, colHeight)
}
2023-04-29 00:05:20 +00:00
2023-04-06 21:30:45 +00:00
// then expand shortest objects to make each column the same height
// . ├maxWidth(A,B)─┤ ├maxW(C,D)─┤ ├maxWidth(E)──────┤
// . ┌A─────────────┐ ┌C─────────┐ ┌E────────────────┐
// . ├ ─ ─ ─ ─ ─ ─ ┤ │ │ │ │
// . │ │ └──────────┘ │ │
// . └──────────────┘ ┌D─────────┐ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤
// . ┌B─────────────┐ │ │ │ │
// . │ │ │ │ │ │
// . │ │ │ │ │ │
// . │ │ │ │ │ │
// . └──────────────┘ └──────────┘ └─────────────────┘
2023-04-04 18:38:33 +00:00
for i, column := range layout {
colHeight := colHeights[i]
if colHeight == maxY {
continue
}
delta := maxY - colHeight
var tallest float64
2023-04-06 21:30:45 +00:00
for _, o := range column {
tallest = math.Max(tallest, o.Height)
2023-04-04 18:38:33 +00:00
}
2023-04-29 00:05:20 +00:00
diffs := make([]float64, len(column))
totalDiff := 0.
for i, o := range column {
diffs[i] = tallest - o.Height
totalDiff += diffs[i]
}
if totalDiff > 0 {
// expand smaller nodes up to the size of the larger ones with delta
// percentage diff
for i := range diffs {
diffs[i] /= totalDiff
}
growth := math.Min(delta, totalDiff)
// expand smaller objects to fill remaining space
for i, o := range column {
o.Height += diffs[i] * growth
2023-04-04 18:38:33 +00:00
}
}
2023-04-29 00:05:20 +00:00
if delta > totalDiff {
growth := (delta - totalDiff) / float64(len(column))
for _, o := range column {
o.Height += growth
2023-04-04 18:38:33 +00:00
}
}
}
2023-04-29 00:05:20 +00:00
// if we have 3 columns, then each column's objects should have the same width
// . ├maxWidth(A,B)─┤ ├maxW(C,D)─┤ ├maxWidth(E)──────┤
// . ┌A─────────────┐ ┌C─────────┐ ┌E────────────────┐
// . └──────────────┘ │ │ │ │
// . ┌B──┬──────────┐ └──────────┘ │ │
// . │ │ ┌D────────┬┐ └─────────────────┘
// . │ │ │ │ │
// . │ │ │ ││
// . └───┴──────────┘ │ │
// . │ ││
// . └─────────┴┘
for _, column := range layout {
colWidth := 0.
for _, o := range column {
2023-05-09 01:38:41 +00:00
o.MoveWithDescendantsTo(cursor.X, cursor.Y)
2023-04-29 00:05:20 +00:00
cursor.Y += o.Height + verticalGap
colWidth = math.Max(colWidth, o.Width)
}
// set all objects in column to the same width
for _, o := range column {
o.Width = colWidth
}
// new column
cursor.Y = 0
cursor.X += colWidth + horizontalGap
}
maxX = cursor.X - horizontalGap
2023-04-03 18:36:01 +00:00
}
2023-04-05 18:11:31 +00:00
gd.width = maxX
gd.height = maxY
2023-04-03 18:36:01 +00:00
}
2023-04-06 21:30:45 +00:00
// generate the best layout of objects aiming for each row to be the targetSize width
// if columns is true, each column aims to have the targetSize height
func (gd *gridDiagram) getBestLayout(targetSize float64, columns bool) [][]*d2graph.Object {
debug := false
var nCuts int
if columns {
nCuts = gd.columns - 1
} else {
nCuts = gd.rows - 1
}
if nCuts == 0 {
2023-06-08 19:24:11 +00:00
return GenLayout(gd.objects, nil)
}
var bestLayout [][]*d2graph.Object
bestDist := math.MaxFloat64
2023-05-10 00:19:33 +00:00
fastIsBest := false
// try fast layout algorithm as a baseline
2023-05-10 00:19:33 +00:00
if fastLayout := gd.fastLayout(targetSize, nCuts, columns); fastLayout != nil {
dist := getDistToTarget(fastLayout, targetSize, float64(gd.horizontalGap), float64(gd.verticalGap), columns)
if debug {
fmt.Printf("fast dist %v dist per row %v\n", dist, dist/(float64(nCuts)+1))
}
if dist == 0 {
2023-05-10 00:19:33 +00:00
return fastLayout
}
bestDist = dist
2023-05-10 00:19:33 +00:00
bestLayout = fastLayout
fastIsBest = true
}
var gap float64
if columns {
gap = float64(gd.verticalGap)
} else {
gap = float64(gd.horizontalGap)
}
getSize := func(o *d2graph.Object) float64 {
if columns {
return o.Height
} else {
return o.Width
}
}
sizes := []float64{}
for _, obj := range gd.objects {
size := getSize(obj)
sizes = append(sizes, size)
}
sd := stddev(sizes)
if debug {
fmt.Printf("sizes (%d): %v\n", len(sizes), sizes)
2023-05-10 00:19:33 +00:00
fmt.Printf("std dev: %v; targetSize %v\n", sd, targetSize)
}
skipCount := 0
count := 0
// quickly eliminate bad row groupings
startingCache := make(map[int]bool)
2023-04-29 01:23:00 +00:00
// Note: we want a low threshold to explore good options within attemptLimit,
// but the best option may require a few rows that are far from the target size.
2023-05-10 00:19:33 +00:00
okThreshold := STARTING_THRESHOLD
rowOk := func(row []*d2graph.Object, starting bool) (ok bool) {
if starting {
2023-04-29 01:23:00 +00:00
// we can cache results from starting positions since they repeat and don't change
// with starting=true it will always be the 1st N objects based on len(row)
if ok, has := startingCache[len(row)]; has {
return ok
}
defer func() {
2023-04-29 01:23:00 +00:00
// cache result before returning
startingCache[len(row)] = ok
}()
}
2023-04-29 01:23:00 +00:00
rowSize := 0.
for _, obj := range row {
rowSize += getSize(obj)
}
if len(row) > 1 {
rowSize += gap * float64(len(row)-1)
2023-04-29 01:23:00 +00:00
// if multiple nodes are too big, it isn't ok. but a single node can't shrink so only check here
if rowSize > okThreshold*targetSize {
skipCount++
2023-09-20 18:51:43 +00:00
// there may even be too many to skip
return skipCount >= SKIP_LIMIT
}
}
2023-04-29 01:23:00 +00:00
// row is too small to be good overall
if rowSize < targetSize/okThreshold {
skipCount++
2023-09-20 18:51:43 +00:00
return skipCount >= SKIP_LIMIT
}
return true
}
// get all options for where to place these cuts, preferring later cuts over earlier cuts
2023-04-06 21:30:45 +00:00
// with 5 objects and 2 cuts we have these options:
// . A B C │ D │ E <- these cuts would produce: ┌A─┐ ┌B─┐ ┌C─┐
// . A B │ C D │ E └──┘ └──┘ └──┘
// . A │ B C D │ E ┌D───────────┐
// . A B │ C │ D E └────────────┘
// . A │ B C │ D E ┌E───────────┐
// . A │ B │ C D E └────────────┘
// of these divisions, find the layout with rows closest to the targetSize
tryDivision := func(division []int) bool {
2023-06-08 19:24:11 +00:00
layout := GenLayout(gd.objects, division)
dist := getDistToTarget(layout, targetSize, float64(gd.horizontalGap), float64(gd.verticalGap), columns)
if dist < bestDist {
bestLayout = layout
bestDist = dist
2023-05-10 00:19:33 +00:00
fastIsBest = false
} else if fastIsBest && dist == bestDist {
// prefer ordered search solution to fast layout solution
bestLayout = layout
fastIsBest = false
}
2023-04-26 18:31:27 +00:00
count++
2023-04-29 01:23:00 +00:00
// with few objects we can try all options to get best result but this won't scale, so only try up to 100k options
2023-05-10 00:19:33 +00:00
return count >= ATTEMPT_LIMIT || skipCount >= SKIP_LIMIT
}
// try number of different okThresholds depending on std deviation of sizes
2023-05-10 00:19:33 +00:00
thresholdAttempts := int(math.Ceil(sd))
if thresholdAttempts < MIN_THRESHOLD_ATTEMPTS {
thresholdAttempts = MIN_THRESHOLD_ATTEMPTS
} else if thresholdAttempts > MAX_THRESHOLD_ATTEMPTS {
thresholdAttempts = MAX_THRESHOLD_ATTEMPTS
}
for i := 0; i < thresholdAttempts || bestLayout == nil; i++ {
count = 0.
skipCount = 0.
iterDivisions(gd.objects, nCuts, tryDivision, rowOk)
2023-05-10 00:19:33 +00:00
okThreshold += THRESHOLD_STEP_SIZE
2023-04-29 01:23:00 +00:00
if debug {
fmt.Printf("count %d, skip count %d, bestDist %v increasing ok threshold to %v\n", count, skipCount, bestDist, okThreshold)
2023-04-29 01:23:00 +00:00
}
startingCache = make(map[int]bool)
if skipCount == 0 {
// threshold isn't skipping anything so increasing it won't help
break
}
2023-05-10 00:19:33 +00:00
// okThreshold isn't high enough yet, we skipped every option so don't count it
if count == 0 && thresholdAttempts < MAX_THRESHOLD_ATTEMPTS {
thresholdAttempts++
}
2023-04-29 01:23:00 +00:00
}
2023-04-29 01:23:00 +00:00
if debug {
2023-05-10 00:19:33 +00:00
fmt.Printf("best layout: %v\n", layoutString(bestLayout, sizes))
}
return bestLayout
}
func sum(values []float64) float64 {
s := 0.
for _, v := range values {
s += v
}
return s
}
func avg(values []float64) float64 {
return sum(values) / float64(len(values))
}
func variance(values []float64) float64 {
mean := avg(values)
total := 0.
for _, value := range values {
dev := mean - value
total += dev * dev
}
return total / float64(len(values))
}
func stddev(values []float64) float64 {
return math.Sqrt(variance(values))
}
func (gd *gridDiagram) fastLayout(targetSize float64, nCuts int, columns bool) (layout [][]*d2graph.Object) {
var gap float64
if columns {
gap = float64(gd.verticalGap)
} else {
gap = float64(gd.horizontalGap)
}
debt := 0.
fastDivision := make([]int, 0, nCuts)
rowSize := 0.
for i := 0; i < len(gd.objects); i++ {
o := gd.objects[i]
var size float64
if columns {
size = o.Height
} else {
size = o.Width
}
if rowSize == 0 {
2023-06-08 19:24:11 +00:00
// if a single object meets the target size, end the row here
if size > targetSize-debt {
2023-06-08 19:24:11 +00:00
// cut row with just this object
2023-06-08 20:02:34 +00:00
fastDivision = append(fastDivision, i)
2023-04-29 01:23:00 +00:00
// we build up a debt of distance past the target size across rows
newDebt := size - targetSize
debt += newDebt
} else {
rowSize += size
}
continue
}
2023-04-29 01:23:00 +00:00
// debt is paid by decreasing threshold to start new row and ending below targetSize
2023-06-08 19:24:11 +00:00
if rowSize+gap+(size)/2. > targetSize-debt {
// start a new row before this object since it is mostly past the target size
// . size
// ├...row─┼gap┼───┼───┤
// ├──targetSize──┤ (debt=0)
fastDivision = append(fastDivision, i-1)
newDebt := rowSize - targetSize
debt += newDebt
rowSize = size
} else {
rowSize += gap + size
}
}
if len(fastDivision) == nCuts {
2023-06-08 19:24:11 +00:00
layout = GenLayout(gd.objects, fastDivision)
}
return layout
}
2023-05-10 00:19:33 +00:00
func layoutString(layout [][]*d2graph.Object, sizes []float64) string {
buf := &bytes.Buffer{}
i := 0
fmt.Fprintf(buf, "[\n")
for _, r := range layout {
vals := sizes[i : i+len(r)]
fmt.Fprintf(buf, "%v:\t%v\n", sum(vals), vals)
i += len(r)
}
fmt.Fprintf(buf, "]\n")
return buf.String()
}
2023-04-26 18:31:27 +00:00
// process current division, return true to stop iterating
type iterDivision func(division []int) (done bool)
type checkCut func(objects []*d2graph.Object, starting bool) (ok bool)
2023-04-26 18:31:27 +00:00
2023-04-06 21:30:45 +00:00
// get all possible divisions of objects by the number of cuts
func iterDivisions(objects []*d2graph.Object, nCuts int, f iterDivision, check checkCut) {
2023-04-06 21:30:45 +00:00
if len(objects) < 2 || nCuts == 0 {
2023-04-26 00:19:34 +00:00
return
}
2023-04-26 18:31:27 +00:00
done := false
2023-04-06 21:30:45 +00:00
// we go in this order to prefer extra objects in starting rows rather than later ones
lastObj := len(objects) - 1
// with objects=[A, B, C, D, E]; nCuts=2
// d:depth; i:index; n:nCuts;
// ┌────┬───┬───┬─────────────────────┬────────────┐
// │ d │ i │ n │ objects │ cuts │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ 0 │ 4 │ 2 │ [A B C D | E] │ │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 3 │ 1 │ [A B C | D] │ + | E] │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 2 │ 1 │ [A B | C D] │ + | E] │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 1 │ 1 │ [A | B C D] │ + | E] │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ 0 │ 3 │ 2 │ [A B C | D E] │ │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 2 │ 1 │ [A B | C] │ + | D E] │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 1 │ 1 │ [A | B C] │ + | D E] │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ 0 │ 2 │ 2 │ [A B | C D E] │ │
// ├────┼───┼───┼─────────────────────┼────────────┤
// │ └1 │ 1 │ 1 │ [A | B] │ + | C D E] │
// └────┴───┴───┴─────────────────────┴────────────┘
2023-04-06 21:30:45 +00:00
for index := lastObj; index >= nCuts; index-- {
if !check(objects[index:], false) {
// optimization: if current cut gives a bad grouping, don't recurse
continue
}
if nCuts > 1 {
2023-04-26 18:31:27 +00:00
iterDivisions(objects[:index], nCuts-1, func(inner []int) bool {
done = f(append(inner, index-1))
return done
}, check)
} else {
if !check(objects[:index], true) {
// e.g. [A B C | D] if [A,B,C] is bad, skip it
continue
}
2023-04-26 18:31:27 +00:00
done = f([]int{index - 1})
}
if done {
return
}
}
}
2023-04-06 21:30:45 +00:00
// generate a grid of objects from the given cut indices
2023-06-08 19:24:11 +00:00
// each cut index applies after the object at that index
// e.g. [0 1 2 3 4 5 6 7] with cutIndices [0, 2, 6] => [[0], [1, 2], [3,4,5,6], [7]]
func GenLayout(objects []*d2graph.Object, cutIndices []int) [][]*d2graph.Object {
layout := make([][]*d2graph.Object, len(cutIndices)+1)
2023-04-06 21:30:45 +00:00
objIndex := 0
for i := 0; i <= len(cutIndices); i++ {
var stop int
if i < len(cutIndices) {
stop = cutIndices[i]
} else {
2023-04-06 21:30:45 +00:00
stop = len(objects) - 1
}
2023-04-26 00:19:34 +00:00
if stop >= objIndex {
layout[i] = make([]*d2graph.Object, 0, stop-objIndex+1)
}
2023-04-06 21:30:45 +00:00
for ; objIndex <= stop; objIndex++ {
layout[i] = append(layout[i], objects[objIndex])
}
}
return layout
}
2023-04-11 18:32:35 +00:00
func getDistToTarget(layout [][]*d2graph.Object, targetSize float64, horizontalGap, verticalGap float64, columns bool) float64 {
totalDelta := 0.
for _, row := range layout {
rowSize := 0.
2023-04-06 21:30:45 +00:00
for _, o := range row {
if columns {
2023-04-11 18:32:35 +00:00
rowSize += o.Height + verticalGap
} else {
2023-04-11 18:32:35 +00:00
rowSize += o.Width + horizontalGap
}
}
2023-06-08 20:02:34 +00:00
if len(row) > 0 {
if columns {
rowSize -= verticalGap
} else {
rowSize -= horizontalGap
}
}
totalDelta += math.Abs(rowSize - targetSize)
}
return totalDelta
}
2023-09-27 00:42:42 +00:00
func (gd *gridDiagram) sizeForOutsideLabels() (revert func()) {
2023-10-17 03:37:56 +00:00
margins := make(map[*d2graph.Object]geo.Spacing)
for _, o := range gd.objects {
2023-09-27 00:38:06 +00:00
margin := o.GetMargin()
2023-10-17 03:37:56 +00:00
margins[o] = margin
2023-09-27 00:42:42 +00:00
o.Height += margin.Top + margin.Bottom
o.Width += margin.Left + margin.Right
}
2023-10-17 03:37:56 +00:00
// Example: a single column with 3 shapes and
// `x.label: long label {near: outside-bottom-left}`
// `y.label: outsider {near: outside-right-center}`
// . ┌───────────────────┐
// . │ widest shape here │
// . └───────────────────┘
// . ┌───┐
// . │ x │
// . └───┘
// . long label
// . ├─────────┤ x's new width
// . ├─mr──┤ margin.right added to width during layout
// . ┌───┐
// . │ y │ outsider
// . └───┘
// . ├─────────────┤ y's new width
// . ├───mr────┤ margin.right added to width during layout
// BEFORE LAYOUT
// . ┌───────────────────┐
// . │ widest shape here │
// . └───────────────────┘
// . ┌─────────┐
// . │ x │
// . └─────────┘
// . ┌─────────────┐
// . │ y │
// . └─────────────┘
// AFTER LAYOUT
// . ┌───────────────────┐
// . │ widest shape here │
// . └───────────────────┘
// . ┌───────────────────┐
// . │ x │
// . └───────────────────┘
// . ┌───────────────────┐
// . │ y │
// . └───────────────────┘
// CLEANUP 1/2
// . ┌───────────────────┐
// . │ widest shape here │
// . └───────────────────┘
// . ┌─────────────┐
// . │ x │
// . └─────────────┘
// . long label ├─mr──┤ remove margin we added
// . ┌─────────┐
// . │ y │ outsider
// . └─────────┘
// . ├───mr────┤ remove margin we added
// CLEANUP 2/2
// . ┌───────────────────┐
// . │ widest shape here │
// . └───────────────────┘
// . ┌───────────────────┐
// . │ x │
// . └───────────────────┘
// . long label ├─mr──┤ we removed too much so add back margin we subtracted, then subtract new margin
// . ┌─────────┐
// . │ y │ outsider
// . └─────────┘
// . ├───mr────┤ margin.right is still needed
return func() {
for _, o := range gd.objects {
2023-10-17 03:37:56 +00:00
m, has := margins[o]
2023-09-27 00:05:32 +00:00
if !has {
continue
}
2023-10-17 03:37:56 +00:00
dy := m.Top + m.Bottom
dx := m.Left + m.Right
o.Height -= dy
o.Width -= dx
2023-10-17 03:37:56 +00:00
// less margin may be needed if layout grew the object
// compute the new margin after removing the old margin we added
margin := o.GetMargin()
2023-10-17 03:37:56 +00:00
marginX := margin.Left + margin.Right
marginY := margin.Top + margin.Bottom
if marginX < dx {
// layout grew width and now we need less of a margin (but we subtracted too much)
// add back dx and subtract the new amount
o.Width += dx - marginX
}
if marginY < dy {
o.Height += dy - marginY
}
2023-09-27 00:38:06 +00:00
if margin.Left > 0 || margin.Top > 0 {
o.MoveWithDescendants(margin.Left, margin.Top)
}
}
}
}