package d2gridimport ()const (CONTAINER_PADDING = 60DEFAULT_GAP = 40)// Layout runs the grid layout on containers with rows/columns// Note: children are not allowed edges or descendants// 1. Run grid layout on the graph root// 2. Set the resulting dimensions to the graph rootfunc ( context.Context, *d2graph.Graph) error { := .Root , := layoutGrid(, )if != nil {return }if .HasLabel() && .LabelPosition == nil { .LabelPosition = go2.Pointer(label.InsideTopCenter.String()) }if .Icon != nil && .IconPosition == nil { .IconPosition = go2.Pointer(label.InsideTopLeft.String()) }if .Box != nil {// CONTAINER_PADDING is default, but use gap value if set , := CONTAINER_PADDING, CONTAINER_PADDINGif .GridGap != nil || .HorizontalGap != nil { = .horizontalGap }if .GridGap != nil || .VerticalGap != nil { = .verticalGap } , := .width, .heightvar , label.Positionif .LabelPosition != nil { = label.FromString(*.LabelPosition) }if .IconPosition != nil { = label.FromString(*.IconPosition) }// compute how much space the label and icon occupy , := .Spacing()var , float64if .LabelDimensions.Width > 0 { = float64(.LabelDimensions.Width) + 2*label.PADDING }if .LabelDimensions.Height > 0 { = float64(.LabelDimensions.Height) + 2*label.PADDING }if > 0 {switch {caselabel.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight,label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight,label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight,label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight: := - if > 0 { .Left += / 2 .Right += / 2 } } }if > 0 {switch {caselabel.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom,label.InsideMiddleLeft, label.InsideMiddleCenter, label.InsideMiddleRight,label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom: := - if > 0 { .Top += / 2 .Bottom += / 2 } } }// configure spacing for default label+iconif == label.InsideTopLeft && == label.InsideTopCenter {// . ├────┤───────├────┤ // . icon label icon // with an icon in top left we need 2x the space to fit the label in the center := float64(d2target.MAX_ICON_SIZE) + 2*label.PADDING .Left = math.Max(.Left, ) .Right = math.Max(.Right, ) := 2* + float64(.LabelDimensions.Width) + 2*label.PADDING := - if > 0 { .Left = math.Max(.Left, /2) .Right = math.Max(.Right, /2) } } .Top = math.Max(.Top, float64()) .Bottom = math.Max(.Bottom, float64()) .Left = math.Max(.Left, float64()) .Right = math.Max(.Right, float64()) := .Left + + .Right := .Top + + .Bottom .SizeToContent(, , 0, 0)// compute where the grid should be placed inside shape := .ToShape() := .GetInsidePlacement(, , 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 := .GetInnerBox()var , float64if .Width > { = (.Width - ) / 2 }if .Height > { = (.Height - ) / 2 }// move from horizontalPadding,verticalPadding to innerTL.X+padding.Left, innerTL.Y+padding.Top // and if innerBox is larger than content dimensions, adjust to center within innerBox := -float64() + .X + .Left + := -float64() + .Y + .Top + if != 0 || != 0 { .shift(, ) } }// simple straight line edge routing between grid objectsfor , := range .Edges {if !.Src.Parent.IsDescendantOf() && !.Dst.Parent.IsDescendantOf() {continue }// if edge is within grid, remove it from outer layout .edges = append(.edges, )if .Src.Parent != || .Dst.Parent != {continue }// if edge is grid child, use simple routing .Route = []*geo.Point{.Src.Center(), .Dst.Center()} .TraceToShape(.Route, 0, 1)if .Label.Value != "" { .LabelPosition = go2.Pointer(label.InsideMiddleCenter.String()) } }if .Root.IsGridDiagram() && len(.Root.ChildrenArray) != 0 { .Root.TopLeft = geo.NewPoint(0, 0) }if .RootLevel > 0 { , := CONTAINER_PADDING, CONTAINER_PADDINGif .GridGap != nil || .HorizontalGap != nil { = .horizontalGap }if .GridGap != nil || .VerticalGap != nil { = .verticalGap }// shift the grid from (0, 0) .shift( .TopLeft.X+float64(), .TopLeft.Y+float64(), ) }returnnil}func layoutGrid( *d2graph.Graph, *d2graph.Object) (*gridDiagram, error) { := newGridDiagram()// position labels and iconsfor , := range .objects { := falseif .Icon != nil && .IconPosition == nil {iflen(.ChildrenArray) > 0 { .IconPosition = go2.Pointer(label.OutsideTopLeft.String())// don't overwrite position if nested graph layout positioned label/iconif .LabelPosition == nil { .LabelPosition = go2.Pointer(label.OutsideTopRight.String()) = true } } else { .IconPosition = go2.Pointer(label.InsideMiddleCenter.String()) } }if ! && .HasLabel() && .LabelPosition == nil {iflen(.ChildrenArray) > 0 { .LabelPosition = go2.Pointer(label.OutsideTopCenter.String()) } elseif .HasOutsideBottomLabel() { .LabelPosition = go2.Pointer(label.OutsideBottomCenter.String()) } elseif .Icon != nil { .LabelPosition = go2.Pointer(label.InsideTopCenter.String()) } else { .LabelPosition = go2.Pointer(label.InsideMiddleCenter.String()) } } }// 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 := .sizeForOutsideLabels()if .rows != 0 && .columns != 0 { .layoutEvenly(, ) } else { .layoutDynamic(, ) } ()return , nil}func ( *gridDiagram) ( *d2graph.Graph, *d2graph.Object) {// 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 := func(, int) *d2graph.Object {varintif .rowDirected { = *.columns + } else { = *.rows + }if < len(.objects) {return .objects[] }returnnil } := make([]float64, 0, .rows) := make([]float64, 0, .columns)for := 0; < .rows; ++ { := 0.for := 0; < .columns; ++ { := (, )if == nil {break } = math.Max(, .Height) } = append(, ) }for := 0; < .columns; ++ { := 0.for := 0; < .rows; ++ { := (, )if == nil {break } = math.Max(, .Width) } = append(, ) } := float64(.horizontalGap) := float64(.verticalGap) := geo.NewPoint(0, 0)if .rowDirected {for := 0; < .rows; ++ {for := 0; < .columns; ++ { := (, )if == nil {break } .Width = [] .Height = [] .MoveWithDescendantsTo(.X, .Y) .X += .Width + } .X = 0 .Y += [] + } } else {for := 0; < .columns; ++ {for := 0; < .rows; ++ { := (, )if == nil {break } .Width = [] .Height = [] .MoveWithDescendantsTo(.X, .Y) .Y += .Height + } .X += [] + .Y = 0 } }var , float64for , := range { += + }for , := range { += + } -= -= .width = .height = }func ( *gridDiagram) ( *d2graph.Graph, *d2graph.Object) {// assume we have the following objects to layout: // . ┌A──────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐ // . └───────────────┘ │ │ │ │ │ │ │ │ // . │ │ └──────────┘ │ │ │ │ // . │ │ │ │ └─────────────────┘ // . └───┘ │ │ // . └─────────┘ // Note: if the grid is row dominant, all objects should be the same height (same width if column dominant) // . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┌D────────┐ ┌E────────────────┐ // . ├ ─ ─ ─ ─ ─ ─ ─┤ │ │ │ │ │ │ │ │ // . │ │ │ │ ├ ─ ─ ─ ─ ─┤ │ │ │ │ // . │ │ │ │ │ │ │ │ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ // . │ │ ├ ─ ┤ │ │ │ │ │ │ // . └──────────────┘ └───┘ └──────────┘ └─────────┘ └─────────────────┘ := float64(.horizontalGap) := float64(.verticalGap)// we want to split up the total width across the N rows or columns as evenly as possiblevar , float64for , := range .objects { += .Width += .Height } += * float64(len(.objects)-.rows) += * float64(len(.objects)-.columns)var [][]*d2graph.Objectif .rowDirected { := / float64(.rows) = .getBestLayout(, false) } else { := / float64(.columns) = .getBestLayout(, true) } := geo.NewPoint(0, 0)var , float64if .rowDirected {// measure row widths := []float64{}for , := range { := 0.for , := range { += .Width + } := - = append(, ) = math.Max(, ) }// TODO if object is a nested grid, consider growing descendants according to the inner grid layout// then expand thinnest objects to make each row the same width // . ┌A─────────────┐ ┌B──┐ ┌C─────────┐ ┬ maxHeight(A,B,C) // . │ │ │ │ │ │ │ // . │ │ │ │ │ │ │ // . │ │ │ │ │ │ │ // . └──────────────┘ └───┘ └──────────┘ ┴ // . ┌D────────┬────┐ ┌E────────────────┐ ┬ maxHeight(D,E) // . │ │ │ │ │ // . │ │ │ │ │ │ // . │ │ │ │ │ // . │ │ │ │ │ │ // . └─────────┴────┘ └─────────────────┘ ┴for , := range { := []if == {continue } := - varfloat64for , := range { = math.Max(, .Width) } := make([]float64, len()) := 0.for , := range { [] = - .Width += [] }if > 0 {// expand smaller nodes up to the size of the larger ones with delta // percentage difffor := range { [] /= } := math.Min(, )// expand smaller objects to fill remaining spacefor , := range { .Width += [] * } }if > { := ( - ) / float64(len())for , := range { .Width += } } }// 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 , := range { := 0.for , := range { .MoveWithDescendantsTo(.X, .Y) .X += .Width + = math.Max(, .Height) }// set all objects in row to the same heightfor , := range { .Height = }// new row .X = 0 .Y += + } = .Y - } else {// measure column heights := []float64{}for , := range { := 0.for , := range { += .Height + } := - = append(, ) = math.Max(, ) }// then expand shortest objects to make each column the same height // . ├maxWidth(A,B)─┤ ├maxW(C,D)─┤ ├maxWidth(E)──────┤ // . ┌A─────────────┐ ┌C─────────┐ ┌E────────────────┐ // . ├ ─ ─ ─ ─ ─ ─ ┤ │ │ │ │ // . │ │ └──────────┘ │ │ // . └──────────────┘ ┌D─────────┐ ├ ─ ─ ─ ─ ─ ─ ─ ─ ┤ // . ┌B─────────────┐ │ │ │ │ // . │ │ │ │ │ │ // . │ │ │ │ │ │ // . │ │ │ │ │ │ // . └──────────────┘ └──────────┘ └─────────────────┘for , := range { := []if == {continue } := - varfloat64for , := range { = math.Max(, .Height) } := make([]float64, len()) := 0.for , := range { [] = - .Height += [] }if > 0 {// expand smaller nodes up to the size of the larger ones with delta // percentage difffor := range { [] /= } := math.Min(, )// expand smaller objects to fill remaining spacefor , := range { .Height += [] * } }if > { := ( - ) / float64(len())for , := range { .Height += } } }// 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 , := range { := 0.for , := range { .MoveWithDescendantsTo(.X, .Y) .Y += .Height + = math.Max(, .Width) }// set all objects in column to the same widthfor , := range { .Width = }// new column .Y = 0 .X += + } = .X - } .width = .height = }// 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 heightfunc ( *gridDiagram) ( float64, bool) [][]*d2graph.Object { := falsevarintif { = .columns - 1 } else { = .rows - 1 }if == 0 {returnGenLayout(.objects, nil) }var [][]*d2graph.Object := math.MaxFloat64 := false// try fast layout algorithm as a baselineif := .fastLayout(, , ); != nil { := getDistToTarget(, , float64(.horizontalGap), float64(.verticalGap), )if {fmt.Printf("fast dist %v dist per row %v\n", , /(float64()+1)) }if == 0 {return } = = = true }varfloat64if { = float64(.verticalGap) } else { = float64(.horizontalGap) } := func( *d2graph.Object) float64 {if {return .Height } else {return .Width } } := []float64{}for , := range .objects { := () = append(, ) } := stddev()if {fmt.Printf("sizes (%d): %v\n", len(), )fmt.Printf("std dev: %v; targetSize %v\n", , ) } := 0 := 0// quickly eliminate bad row groupings := make(map[int]bool)// 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. := STARTING_THRESHOLD := func( []*d2graph.Object, bool) ( bool) {if {// 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 , := [len()]; {return }deferfunc() {// cache result before returning [len()] = }() } := 0.for , := range { += () }iflen() > 1 { += * float64(len()-1)// if multiple nodes are too big, it isn't ok. but a single node can't shrink so only check hereif > * { ++// there may even be too many to skipreturn >= SKIP_LIMIT } }// row is too small to be good overallif < / { ++return >= SKIP_LIMIT }returntrue }// get all options for where to place these cuts, preferring later cuts over earlier cuts // 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 := func( []int) bool { := GenLayout(.objects, ) := getDistToTarget(, , float64(.horizontalGap), float64(.verticalGap), )if < { = = = false } elseif && == {// prefer ordered search solution to fast layout solution = = false } ++// with few objects we can try all options to get best result but this won't scale, so only try up to 100k optionsreturn >= ATTEMPT_LIMIT || >= SKIP_LIMIT }// try number of different okThresholds depending on std deviation of sizes := int(math.Ceil())if < MIN_THRESHOLD_ATTEMPTS { = MIN_THRESHOLD_ATTEMPTS } elseif > MAX_THRESHOLD_ATTEMPTS { = MAX_THRESHOLD_ATTEMPTS }for := 0; < || == nil; ++ { = 0. = 0.iterDivisions(.objects, , , ) += THRESHOLD_STEP_SIZEif {fmt.Printf("count %d, skip count %d, bestDist %v increasing ok threshold to %v\n", , , , ) } = make(map[int]bool)if == 0 {// threshold isn't skipping anything so increasing it won't helpbreak }// okThreshold isn't high enough yet, we skipped every option so don't count itif == 0 && < MAX_THRESHOLD_ATTEMPTS { ++ } }if {fmt.Printf("best layout: %v\n", layoutString(, )) }return}func sum( []float64) float64 { := 0.for , := range { += }return}func avg( []float64) float64 {returnsum() / float64(len())}func variance( []float64) float64 { := avg() := 0.for , := range { := - += * }return / float64(len())}func stddev( []float64) float64 {returnmath.Sqrt(variance())}func ( *gridDiagram) ( float64, int, bool) ( [][]*d2graph.Object) {varfloat64if { = float64(.verticalGap) } else { = float64(.horizontalGap) } := 0. := make([]int, 0, ) := 0.for := 0; < len(.objects); ++ { := .objects[]varfloat64if { = .Height } else { = .Width }if == 0 {// if a single object meets the target size, end the row hereif > - {// cut row with just this object = append(, )// we build up a debt of distance past the target size across rows := - += } else { += }continue }// debt is paid by decreasing threshold to start new row and ending below targetSizeif ++()/2. > - {// start a new row before this object since it is mostly past the target size // . size // ├...row─┼gap┼───┼───┤ // ├──targetSize──┤ (debt=0) = append(, -1) := - += = } else { += + } }iflen() == { = GenLayout(.objects, ) }return}func layoutString( [][]*d2graph.Object, []float64) string { := &bytes.Buffer{} := 0fmt.Fprintf(, "[\n")for , := range { := [ : +len()]fmt.Fprintf(, "%v:\t%v\n", sum(), ) += len() }fmt.Fprintf(, "]\n")return .String()}// process current division, return true to stop iteratingtype iterDivision func(division []int) (done bool)type checkCut func(objects []*d2graph.Object, starting bool) (ok bool)// get all possible divisions of objects by the number of cutsfunc iterDivisions( []*d2graph.Object, int, iterDivision, checkCut) {iflen() < 2 || == 0 {return } := false// we go in this order to prefer extra objects in starting rows rather than later ones := len() - 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] │ // └────┴───┴───┴─────────────────────┴────────────┘for := ; >= ; -- {if !([:], false) {// optimization: if current cut gives a bad grouping, don't recursecontinue }if > 1 { ([:], -1, func( []int) bool { = (append(, -1))return }, ) } else {if !([:], true) {// e.g. [A B C | D] if [A,B,C] is bad, skip itcontinue } = ([]int{ - 1}) }if {return } }}// generate a grid of objects from the given cut indices// 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 ( []*d2graph.Object, []int) [][]*d2graph.Object { := make([][]*d2graph.Object, len()+1) := 0for := 0; <= len(); ++ {varintif < len() { = [] } else { = len() - 1 }if >= { [] = make([]*d2graph.Object, 0, -+1) }for ; <= ; ++ { [] = append([], []) } }return}func getDistToTarget( [][]*d2graph.Object, float64, , float64, bool) float64 { := 0.for , := range { := 0.for , := range {if { += .Height + } else { += .Width + } }iflen() > 0 {if { -= } else { -= } } += math.Abs( - ) }return}func ( *gridDiagram) () ( func()) { := make(map[*d2graph.Object]geo.Spacing)for , := range .objects { := .GetMargin() [] = .Height += .Top + .Bottom .Width += .Left + .Right }// 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 neededreturnfunc() {for , := range .objects { , := []if ! {continue } := .Top + .Bottom := .Left + .Right .Height -= .Width -= // less margin may be needed if layout grew the object // compute the new margin after removing the old margin we added := .GetMargin() := .Left + .Right := .Top + .Bottomif < {// layout grew width and now we need less of a margin (but we subtracted too much) // add back dx and subtract the new amount .Width += - }if < { .Height += - }if .Left > 0 || .Top > 0 { .MoveWithDescendants(.Left, .Top) } } }}
The pages are generated with Goldsv0.8.2. (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu.
PR and bug reports are welcome and can be submitted to the issue list.
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds.