d2/d2layouts/d2elklayout/layout.go

1160 lines
35 KiB
Go

// d2elklayout is a wrapper around the Javascript port of ELK.
//
// Coordinates are relative to parents.
// See https://www.eclipse.org/elk/documentation/tooldevelopers/graphdatastructure/coordinatesystem.html
package d2elklayout
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/dop251/goja"
"oss.terrastruct.com/util-go/xdefer"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
)
//go:embed elk.js
var elkJS string
//go:embed setup.js
var setupJS string
type ELKNode struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Children []*ELKNode `json:"children,omitempty"`
Ports []*ELKPort `json:"ports,omitempty"`
Labels []*ELKLabel `json:"labels,omitempty"`
LayoutOptions *elkOpts `json:"layoutOptions,omitempty"`
}
type PortSide string
const (
South PortSide = "SOUTH"
North PortSide = "NORTH"
East PortSide = "EAST"
West PortSide = "WEST"
)
type Direction string
const (
Down Direction = "DOWN"
Up Direction = "UP"
Right Direction = "RIGHT"
Left Direction = "LEFT"
)
type ELKPort struct {
ID string `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
LayoutOptions *elkOpts `json:"layoutOptions,omitempty"`
}
type ELKLabel struct {
Text string `json:"text"`
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
LayoutOptions *elkOpts `json:"layoutOptions,omitempty"`
}
type ELKPoint struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type ELKEdgeSection struct {
Start ELKPoint `json:"startPoint"`
End ELKPoint `json:"endPoint"`
BendPoints []ELKPoint `json:"bendPoints,omitempty"`
}
type ELKEdge struct {
ID string `json:"id"`
Sources []string `json:"sources"`
Targets []string `json:"targets"`
Sections []ELKEdgeSection `json:"sections,omitempty"`
Labels []*ELKLabel `json:"labels,omitempty"`
Container string `json:"container"`
}
type ELKGraph struct {
ID string `json:"id"`
LayoutOptions *elkOpts `json:"layoutOptions"`
Children []*ELKNode `json:"children,omitempty"`
Edges []*ELKEdge `json:"edges,omitempty"`
}
type ConfigurableOpts struct {
Algorithm string `json:"elk.algorithm,omitempty"`
NodeSpacing int `json:"spacing.nodeNodeBetweenLayers,omitempty"`
Padding string `json:"elk.padding,omitempty"`
EdgeNodeSpacing int `json:"spacing.edgeNodeBetweenLayers,omitempty"`
SelfLoopSpacing int `json:"elk.spacing.nodeSelfLoop"`
}
var DefaultOpts = ConfigurableOpts{
Algorithm: "layered",
NodeSpacing: 70.0,
Padding: "[top=50,left=50,bottom=50,right=50]",
EdgeNodeSpacing: 40.0,
SelfLoopSpacing: 50.0,
}
var port_spacing = 40.
var edge_node_spacing = 40
type elkOpts struct {
EdgeNode int `json:"elk.spacing.edgeNode,omitempty"`
FixedAlignment string `json:"elk.layered.nodePlacement.bk.fixedAlignment,omitempty"`
Thoroughness int `json:"elk.layered.thoroughness,omitempty"`
EdgeEdgeBetweenLayersSpacing int `json:"elk.layered.spacing.edgeEdgeBetweenLayers,omitempty"`
Direction Direction `json:"elk.direction"`
HierarchyHandling string `json:"elk.hierarchyHandling,omitempty"`
InlineEdgeLabels bool `json:"elk.edgeLabels.inline,omitempty"`
ForceNodeModelOrder bool `json:"elk.layered.crossingMinimization.forceNodeModelOrder,omitempty"`
ConsiderModelOrder string `json:"elk.layered.considerModelOrder.strategy,omitempty"`
CycleBreakingStrategy string `json:"elk.layered.cycleBreaking.strategy,omitempty"`
SelfLoopDistribution string `json:"elk.layered.edgeRouting.selfLoopDistribution,omitempty"`
NodeSizeConstraints string `json:"elk.nodeSize.constraints,omitempty"`
ContentAlignment string `json:"elk.contentAlignment,omitempty"`
NodeSizeMinimum string `json:"elk.nodeSize.minimum,omitempty"`
PortSide PortSide `json:"elk.port.side,omitempty"`
PortConstraints string `json:"elk.portConstraints,omitempty"`
ConfigurableOpts
}
func DefaultLayout(ctx context.Context, g *d2graph.Graph) (err error) {
return Layout(ctx, g, nil)
}
func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err error) {
if opts == nil {
opts = &DefaultOpts
}
defer xdefer.Errorf(&err, "failed to ELK layout")
vm := goja.New()
console := vm.NewObject()
if err := vm.Set("console", console); err != nil {
return err
}
if _, err := vm.RunString(elkJS); err != nil {
return err
}
if _, err := vm.RunString(setupJS); err != nil {
return err
}
elkGraph := &ELKGraph{
ID: "",
LayoutOptions: &elkOpts{
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
EdgeNode: edge_node_spacing,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
Algorithm: opts.Algorithm,
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
},
},
}
if elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
// +5 for a tiny bit of padding
elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(elkGraph.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(g.Root, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch g.Root.Direction.Value {
case "down":
elkGraph.LayoutOptions.Direction = Down
case "up":
elkGraph.LayoutOptions.Direction = Up
case "right":
elkGraph.LayoutOptions.Direction = Right
case "left":
elkGraph.LayoutOptions.Direction = Left
default:
elkGraph.LayoutOptions.Direction = Down
}
// set label and icon positions for ELK
for _, obj := range g.Objects {
positionLabelsIcons(obj)
}
adjustments := make(map[*d2graph.Object]geo.Spacing)
elkNodes := make(map[*d2graph.Object]*ELKNode)
elkEdges := make(map[*d2graph.Edge]*ELKEdge)
// BFS
var walk func(*d2graph.Object, *d2graph.Object, func(*d2graph.Object, *d2graph.Object))
walk = func(obj, parent *d2graph.Object, fn func(*d2graph.Object, *d2graph.Object)) {
if obj.Parent != nil {
fn(obj, parent)
}
for _, ch := range obj.ChildrenArray {
walk(ch, obj, fn)
}
}
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
incoming := 0.
outgoing := 0.
for _, e := range g.Edges {
if e.Src == obj {
outgoing++
}
if e.Dst == obj {
incoming++
}
}
if incoming >= 2 || outgoing >= 2 {
switch g.Root.Direction.Value {
case "right", "left":
if obj.Attributes.HeightAttr == nil {
obj.Height = math.Max(obj.Height, math.Max(incoming, outgoing)*port_spacing)
}
default:
if obj.Attributes.WidthAttr == nil {
obj.Width = math.Max(obj.Width, math.Max(incoming, outgoing)*port_spacing)
}
}
}
if obj.HasLabel() && obj.HasIcon() {
// this gives shapes extra height for their label if they also have an icon
obj.Height += float64(obj.LabelDimensions.Height + label.PADDING)
}
margin, _ := obj.SpacingOpt(label.PADDING, label.PADDING, false)
width := margin.Left + obj.Width + margin.Right
height := margin.Top + obj.Height + margin.Bottom
adjustments[obj] = margin
n := &ELKNode{
ID: obj.AbsID(),
Width: width,
Height: height,
}
if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &elkOpts{
ForceNodeModelOrder: true,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
HierarchyHandling: "INCLUDE_CHILDREN",
FixedAlignment: "BALANCED",
EdgeNode: edge_node_spacing,
ConsiderModelOrder: "NODES_AND_EDGES",
CycleBreakingStrategy: "GREEDY_MODEL_ORDER",
NodeSizeConstraints: "MINIMUM_SIZE",
ContentAlignment: "H_CENTER V_CENTER",
ConfigurableOpts: ConfigurableOpts{
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
SelfLoopSpacing: opts.SelfLoopSpacing,
Padding: opts.Padding,
},
}
if n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing == DefaultOpts.SelfLoopSpacing {
n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing = go2.Max(n.LayoutOptions.ConfigurableOpts.SelfLoopSpacing, childrenMaxSelfLoop(obj, g.Root.Direction.Value == "down" || g.Root.Direction.Value == "" || g.Root.Direction.Value == "up")/2+5)
}
switch elkGraph.LayoutOptions.Direction {
case Down, Up:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width)))
case Right, Left:
n.LayoutOptions.NodeSizeMinimum = fmt.Sprintf("(%d, %d)", int(math.Ceil(width)), int(math.Ceil(height)))
}
} else {
n.LayoutOptions = &elkOpts{
SelfLoopDistribution: "EQUALLY",
}
}
if obj.IsContainer() {
padding := parsePadding(opts.Padding)
padding = adjustPadding(obj, width, height, padding)
n.LayoutOptions.Padding = padding.String()
}
if obj.HasLabel() {
n.Labels = append(n.Labels, &ELKLabel{
Text: obj.Label.Value,
Width: float64(obj.LabelDimensions.Width),
Height: float64(obj.LabelDimensions.Height),
})
}
if parent == g.Root {
elkGraph.Children = append(elkGraph.Children, n)
} else {
elkNodes[parent].Children = append(elkNodes[parent].Children, n)
}
if obj.SQLTable != nil {
n.LayoutOptions.PortConstraints = "FIXED_POS"
columns := obj.SQLTable.Columns
colHeight := n.Height / float64(len(columns)+1)
n.Ports = make([]*ELKPort, 0, len(columns)*2)
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Left:
srcSide, dstSide = West, East
default:
srcSide, dstSide = East, West
}
for i, col := range columns {
n.Ports = append(n.Ports, &ELKPort{
ID: srcPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: srcSide},
})
n.Ports = append(n.Ports, &ELKPort{
ID: dstPortID(obj, col.Name.Label),
Y: float64(i+1)*colHeight + colHeight/2,
LayoutOptions: &elkOpts{PortSide: dstSide},
})
}
}
elkNodes[obj] = n
})
var srcSide, dstSide PortSide
switch elkGraph.LayoutOptions.Direction {
case Up:
srcSide, dstSide = North, South
default:
srcSide, dstSide = South, North
}
ports := map[struct {
obj *d2graph.Object
side PortSide
}][]*ELKPort{}
for ei, edge := range g.Edges {
var src, dst string
switch {
case edge.SrcTableColumnIndex != nil:
src = srcPortID(edge.Src, edge.Src.SQLTable.Columns[*edge.SrcTableColumnIndex].Name.Label)
case edge.Src.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", srcPortID(edge.Src, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: srcSide},
}
src = p.ID
elkNodes[edge.Src].Ports = append(elkNodes[edge.Src].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Src, srcSide}
ports[k] = append(ports[k], p)
default:
src = edge.Src.AbsID()
}
switch {
case edge.DstTableColumnIndex != nil:
dst = dstPortID(edge.Dst, edge.Dst.SQLTable.Columns[*edge.DstTableColumnIndex].Name.Label)
case edge.Dst.SQLTable != nil:
p := &ELKPort{
ID: fmt.Sprintf("%s.%d", dstPortID(edge.Dst, "__root__"), ei),
LayoutOptions: &elkOpts{PortSide: dstSide},
}
dst = p.ID
elkNodes[edge.Dst].Ports = append(elkNodes[edge.Dst].Ports, p)
k := struct {
obj *d2graph.Object
side PortSide
}{edge.Dst, dstSide}
ports[k] = append(ports[k], p)
default:
dst = edge.Dst.AbsID()
}
e := &ELKEdge{
ID: edge.AbsID(),
Sources: []string{src},
Targets: []string{dst},
}
if edge.Label.Value != "" {
e.Labels = append(e.Labels, &ELKLabel{
Text: edge.Label.Value,
Width: float64(edge.LabelDimensions.Width),
Height: float64(edge.LabelDimensions.Height),
LayoutOptions: &elkOpts{
InlineEdgeLabels: true,
},
})
}
elkGraph.Edges = append(elkGraph.Edges, e)
elkEdges[edge] = e
}
for k, ports := range ports {
width := elkNodes[k.obj].Width
spacing := width / float64(len(ports)+1)
for i, p := range ports {
p.X = float64(i+1) * spacing
}
}
raw, err := json.Marshal(elkGraph)
if err != nil {
return err
}
loadScript := fmt.Sprintf(`var graph = %s`, raw)
if _, err := vm.RunString(loadScript); err != nil {
return err
}
val, err := vm.RunString(`elk.layout(graph)
.then(s => s)
.catch(err => err.message)
`)
if err != nil {
return err
}
p := val.Export()
if err != nil {
return err
}
promise := p.(*goja.Promise)
for promise.State() == goja.PromiseStatePending {
if err := ctx.Err(); err != nil {
return err
}
continue
}
if promise.State() == goja.PromiseStateRejected {
return errors.New("ELK: something went wrong")
}
result := promise.Result().Export()
var jsonOut map[string]interface{}
switch out := result.(type) {
case string:
return fmt.Errorf("ELK layout error: %s", out)
case map[string]interface{}:
jsonOut = out
default:
return fmt.Errorf("ELK unexpected return: %v", out)
}
jsonBytes, err := json.Marshal(jsonOut)
if err != nil {
return err
}
err = json.Unmarshal(jsonBytes, &elkGraph)
if err != nil {
return err
}
byID := make(map[string]*d2graph.Object)
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
n := elkNodes[obj]
parentX := 0.0
parentY := 0.0
if parent != nil && parent != g.Root {
parentX = parent.TopLeft.X
parentY = parent.TopLeft.Y
}
obj.TopLeft = geo.NewPoint(parentX+n.X, parentY+n.Y)
obj.Width = math.Ceil(n.Width)
obj.Height = math.Ceil(n.Height)
byID[obj.AbsID()] = obj
})
for _, edge := range g.Edges {
e := elkEdges[edge]
parentX := 0.0
parentY := 0.0
if e.Container != "" {
parentX = byID[e.Container].TopLeft.X
parentY = byID[e.Container].TopLeft.Y
}
var points []*geo.Point
for _, s := range e.Sections {
points = append(points, &geo.Point{
X: parentX + s.Start.X,
Y: parentY + s.Start.Y,
})
for _, bp := range s.BendPoints {
points = append(points, &geo.Point{
X: parentX + bp.X,
Y: parentY + bp.Y,
})
}
points = append(points, &geo.Point{
X: parentX + s.End.X,
Y: parentY + s.End.Y,
})
}
edge.Route = points
}
objEdges := make(map[*d2graph.Object][]*d2graph.Edge)
for _, e := range g.Edges {
objEdges[e.Src] = append(objEdges[e.Src], e)
if e.Dst != e.Src {
objEdges[e.Dst] = append(objEdges[e.Dst], e)
}
}
for _, obj := range g.Objects {
if margin, has := adjustments[obj]; has {
edges := objEdges[obj]
// also move edges with the shrinking sides
if margin.Left > 0 {
for _, e := range edges {
l := len(e.Route)
if e.Src == obj && e.Route[0].X == obj.TopLeft.X {
e.Route[0].X += margin.Left
}
if e.Dst == obj && e.Route[l-1].X == obj.TopLeft.X {
e.Route[l-1].X += margin.Left
}
}
obj.TopLeft.X += margin.Left
obj.ShiftDescendants(margin.Left/2, 0)
obj.Width -= margin.Left
}
if margin.Right > 0 {
for _, e := range edges {
l := len(e.Route)
if e.Src == obj && e.Route[0].X == obj.TopLeft.X+obj.Width {
e.Route[0].X -= margin.Right
}
if e.Dst == obj && e.Route[l-1].X == obj.TopLeft.X+obj.Width {
e.Route[l-1].X -= margin.Right
}
}
obj.ShiftDescendants(-margin.Right/2, 0)
obj.Width -= margin.Right
}
if margin.Top > 0 {
for _, e := range edges {
l := len(e.Route)
if e.Src == obj && e.Route[0].Y == obj.TopLeft.Y {
e.Route[0].Y += margin.Top
}
if e.Dst == obj && e.Route[l-1].Y == obj.TopLeft.Y {
e.Route[l-1].Y += margin.Top
}
}
obj.TopLeft.Y += margin.Top
obj.ShiftDescendants(0, margin.Top/2)
obj.Height -= margin.Top
}
if margin.Bottom > 0 {
for _, e := range edges {
l := len(e.Route)
if e.Src == obj && e.Route[0].Y == obj.TopLeft.Y+obj.Height {
e.Route[0].Y -= margin.Bottom
}
if e.Dst == obj && e.Route[l-1].Y == obj.TopLeft.Y+obj.Height {
e.Route[l-1].Y -= margin.Bottom
}
}
obj.ShiftDescendants(0, -margin.Bottom/2)
obj.Height -= margin.Bottom
}
}
}
for _, edge := range g.Edges {
points := edge.Route
startIndex, endIndex := 0, len(points)-1
start := points[startIndex]
end := points[endIndex]
var originalSrcTL, originalDstTL *geo.Point
// if the edge passes through 3d/multiple, use the offset box for tracing to border
if srcDx, srcDy := edge.Src.GetModifierElementAdjustments(); srcDx != 0 || srcDy != 0 {
if start.X > edge.Src.TopLeft.X+srcDx &&
start.Y < edge.Src.TopLeft.Y+edge.Src.Height-srcDy {
originalSrcTL = edge.Src.TopLeft.Copy()
edge.Src.TopLeft.X += srcDx
edge.Src.TopLeft.Y -= srcDy
}
}
if dstDx, dstDy := edge.Dst.GetModifierElementAdjustments(); dstDx != 0 || dstDy != 0 {
if end.X > edge.Dst.TopLeft.X+dstDx &&
end.Y < edge.Dst.TopLeft.Y+edge.Dst.Height-dstDy {
originalDstTL = edge.Dst.TopLeft.Copy()
edge.Dst.TopLeft.X += dstDx
edge.Dst.TopLeft.Y -= dstDy
}
}
startIndex, endIndex = edge.TraceToShape(points, startIndex, endIndex)
points = points[startIndex : endIndex+1]
if edge.Label.Value != "" {
edge.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
edge.Route = points
// undo 3d/multiple offset
if originalSrcTL != nil {
edge.Src.TopLeft.X = originalSrcTL.X
edge.Src.TopLeft.Y = originalSrcTL.Y
}
if originalDstTL != nil {
edge.Dst.TopLeft.X = originalDstTL.X
edge.Dst.TopLeft.Y = originalDstTL.Y
}
}
deleteBends(g)
return nil
}
func srcPortID(obj *d2graph.Object, column string) string {
return fmt.Sprintf("%s.%s.src", obj.AbsID(), column)
}
func dstPortID(obj *d2graph.Object, column string) string {
return fmt.Sprintf("%s.%s.dst", obj.AbsID(), column)
}
// deleteBends is a shim for ELK to delete unnecessary bends
// see https://github.com/terrastruct/d2/issues/1030
func deleteBends(g *d2graph.Graph) {
// Get rid of S-shapes at the source and the target
// TODO there might be value in repeating this. removal of an S shape introducing another S shape that can still be removed
for _, isSource := range []bool{true, false} {
for ei, e := range g.Edges {
if len(e.Route) < 4 {
continue
}
if e.Src == e.Dst {
continue
}
var endpoint *d2graph.Object
var start *geo.Point
var corner *geo.Point
var end *geo.Point
var columnIndex *int
if isSource {
start = e.Route[0]
corner = e.Route[1]
end = e.Route[2]
endpoint = e.Src
columnIndex = e.SrcTableColumnIndex
} else {
start = e.Route[len(e.Route)-1]
corner = e.Route[len(e.Route)-2]
end = e.Route[len(e.Route)-3]
endpoint = e.Dst
columnIndex = e.DstTableColumnIndex
}
isHorizontal := math.Ceil(start.Y) == math.Ceil(corner.Y)
dx, dy := endpoint.GetModifierElementAdjustments()
// Make sure it's still attached
switch {
case columnIndex != nil:
rowHeight := endpoint.Height / float64(len(endpoint.SQLTable.Columns)+1)
rowCenter := endpoint.TopLeft.Y + rowHeight*float64(*columnIndex+1) + rowHeight/2
// for row connections new Y coordinate should be within 1/3 row height from the row center
if math.Abs(end.Y-rowCenter) > rowHeight/3 {
continue
}
case isHorizontal:
if end.Y <= endpoint.TopLeft.Y+10-dy {
continue
}
if end.Y >= endpoint.TopLeft.Y+endpoint.Height-10 {
continue
}
default:
if end.X <= endpoint.TopLeft.X+10 {
continue
}
if end.X >= endpoint.TopLeft.X+endpoint.Width-10+dx {
continue
}
}
var newStart *geo.Point
if isHorizontal {
newStart = geo.NewPoint(start.X, end.Y)
} else {
newStart = geo.NewPoint(end.X, start.Y)
}
endpointShape := shape.NewShape(d2target.DSL_SHAPE_TO_SHAPE_TYPE[strings.ToLower(endpoint.Shape.Value)], endpoint.Box)
newStart = shape.TraceToShapeBorder(endpointShape, newStart, end)
// Check that the new segment doesn't collide with anything new
oldSegment := geo.NewSegment(start, corner)
newSegment := geo.NewSegment(newStart, end)
oldIntersects := countObjectIntersects(g, e.Src, e.Dst, *oldSegment)
newIntersects := countObjectIntersects(g, e.Src, e.Dst, *newSegment)
if newIntersects > oldIntersects {
continue
}
oldCrossingsCount, oldOverlapsCount, oldCloseOverlapsCount, oldTouchingCount := countEdgeIntersects(g, g.Edges[ei], *oldSegment)
newCrossingsCount, newOverlapsCount, newCloseOverlapsCount, newTouchingCount := countEdgeIntersects(g, g.Edges[ei], *newSegment)
if newCrossingsCount > oldCrossingsCount {
continue
}
if newOverlapsCount > oldOverlapsCount {
continue
}
if newCloseOverlapsCount > oldCloseOverlapsCount {
continue
}
if newTouchingCount > oldTouchingCount {
continue
}
// commit
if isSource {
g.Edges[ei].Route = append(
[]*geo.Point{newStart},
e.Route[3:]...,
)
} else {
g.Edges[ei].Route = append(
e.Route[:len(e.Route)-3],
newStart,
)
}
}
}
// Get rid of ladders
// ELK likes to do these for some reason
// . ┌─
// . ┌─┘
// . │
// We want to transform these into L-shapes
points := map[geo.Point]int{}
for _, e := range g.Edges {
for _, p := range e.Route {
points[*p]++
}
}
for ei, e := range g.Edges {
if len(e.Route) < 6 {
continue
}
if e.Src == e.Dst {
continue
}
for i := 1; i < len(e.Route)-3; i++ {
before := e.Route[i-1]
start := e.Route[i]
corner := e.Route[i+1]
end := e.Route[i+2]
after := e.Route[i+3]
if c, _ := points[*corner]; c > 1 {
// If corner is shared with another edge, they merge
continue
}
// S-shape on sources only concerned one segment, since the other was just along the bound of endpoint
// These concern two segments
var newCorner *geo.Point
if math.Ceil(start.X) == math.Ceil(corner.X) {
newCorner = geo.NewPoint(end.X, start.Y)
// not ladder
if (end.X > start.X) != (start.X > before.X) {
continue
}
if (end.Y > start.Y) != (after.Y > end.Y) {
continue
}
} else {
newCorner = geo.NewPoint(start.X, end.Y)
if (end.Y > start.Y) != (start.Y > before.Y) {
continue
}
if (end.X > start.X) != (after.X > end.X) {
continue
}
}
oldS1 := geo.NewSegment(start, corner)
oldS2 := geo.NewSegment(corner, end)
newS1 := geo.NewSegment(start, newCorner)
newS2 := geo.NewSegment(newCorner, end)
// Check that the new segments doesn't collide with anything new
oldIntersects := countObjectIntersects(g, e.Src, e.Dst, *oldS1) + countObjectIntersects(g, e.Src, e.Dst, *oldS2)
newIntersects := countObjectIntersects(g, e.Src, e.Dst, *newS1) + countObjectIntersects(g, e.Src, e.Dst, *newS2)
if newIntersects > oldIntersects {
continue
}
oldCrossingsCount1, oldOverlapsCount1, oldCloseOverlapsCount1, oldTouchingCount1 := countEdgeIntersects(g, g.Edges[ei], *oldS1)
oldCrossingsCount2, oldOverlapsCount2, oldCloseOverlapsCount2, oldTouchingCount2 := countEdgeIntersects(g, g.Edges[ei], *oldS2)
oldCrossingsCount := oldCrossingsCount1 + oldCrossingsCount2
oldOverlapsCount := oldOverlapsCount1 + oldOverlapsCount2
oldCloseOverlapsCount := oldCloseOverlapsCount1 + oldCloseOverlapsCount2
oldTouchingCount := oldTouchingCount1 + oldTouchingCount2
newCrossingsCount1, newOverlapsCount1, newCloseOverlapsCount1, newTouchingCount1 := countEdgeIntersects(g, g.Edges[ei], *newS1)
newCrossingsCount2, newOverlapsCount2, newCloseOverlapsCount2, newTouchingCount2 := countEdgeIntersects(g, g.Edges[ei], *newS2)
newCrossingsCount := newCrossingsCount1 + newCrossingsCount2
newOverlapsCount := newOverlapsCount1 + newOverlapsCount2
newCloseOverlapsCount := newCloseOverlapsCount1 + newCloseOverlapsCount2
newTouchingCount := newTouchingCount1 + newTouchingCount2
if newCrossingsCount > oldCrossingsCount {
continue
}
if newOverlapsCount > oldOverlapsCount {
continue
}
if newCloseOverlapsCount > oldCloseOverlapsCount {
continue
}
if newTouchingCount > oldTouchingCount {
continue
}
// commit
g.Edges[ei].Route = append(append(
e.Route[:i],
newCorner,
),
e.Route[i+3:]...,
)
break
}
}
}
func countObjectIntersects(g *d2graph.Graph, src, dst *d2graph.Object, s geo.Segment) int {
count := 0
for i, o := range g.Objects {
if g.Objects[i] == src || g.Objects[i] == dst {
continue
}
if o.Intersects(s, float64(edge_node_spacing)-1) {
count++
}
}
return count
}
// countEdgeIntersects counts both crossings AND getting too close to a parallel segment
func countEdgeIntersects(g *d2graph.Graph, sEdge *d2graph.Edge, s geo.Segment) (int, int, int, int) {
isHorizontal := math.Ceil(s.Start.Y) == math.Ceil(s.End.Y)
crossingsCount := 0
overlapsCount := 0
closeOverlapsCount := 0
touchingCount := 0
for i, e := range g.Edges {
if g.Edges[i] == sEdge {
continue
}
for i := 0; i < len(e.Route)-1; i++ {
otherS := geo.NewSegment(e.Route[i], e.Route[i+1])
otherIsHorizontal := math.Ceil(otherS.Start.Y) == math.Ceil(otherS.End.Y)
if isHorizontal == otherIsHorizontal {
if s.Overlaps(*otherS, !isHorizontal, 0.) {
if isHorizontal {
if math.Abs(s.Start.Y-otherS.Start.Y) < float64(edge_node_spacing)/2. {
overlapsCount++
if math.Abs(s.Start.Y-otherS.Start.Y) < float64(edge_node_spacing)/4. {
closeOverlapsCount++
if math.Abs(s.Start.Y-otherS.Start.Y) < 1. {
touchingCount++
}
}
}
} else {
if math.Abs(s.Start.X-otherS.Start.X) < float64(edge_node_spacing)/2. {
overlapsCount++
if math.Abs(s.Start.X-otherS.Start.X) < float64(edge_node_spacing)/4. {
closeOverlapsCount++
if math.Abs(s.Start.Y-otherS.Start.Y) < 1. {
touchingCount++
}
}
}
}
}
} else {
if s.Intersects(*otherS) {
crossingsCount++
}
}
}
}
return crossingsCount, overlapsCount, closeOverlapsCount, touchingCount
}
func childrenMaxSelfLoop(parent *d2graph.Object, isWidth bool) int {
max := 0
for _, ch := range parent.Children {
for _, e := range parent.Graph.Edges {
if e.Src == e.Dst && e.Src == ch && e.Label.Value != "" {
if isWidth {
max = go2.Max(max, e.LabelDimensions.Width)
} else {
max = go2.Max(max, e.LabelDimensions.Height)
}
}
}
}
return max
}
type shapePadding struct {
top, left, bottom, right int
}
// parse out values from elk padding string. e.g. "[top=50,left=50,bottom=50,right=50]"
func parsePadding(in string) shapePadding {
reTop := regexp.MustCompile(`top=(\d+)`)
reLeft := regexp.MustCompile(`left=(\d+)`)
reBottom := regexp.MustCompile(`bottom=(\d+)`)
reRight := regexp.MustCompile(`right=(\d+)`)
padding := shapePadding{}
submatches := reTop.FindStringSubmatch(in)
if len(submatches) == 2 {
i, err := strconv.ParseInt(submatches[1], 10, 64)
if err == nil {
padding.top = int(i)
}
}
submatches = reLeft.FindStringSubmatch(in)
if len(submatches) == 2 {
i, err := strconv.ParseInt(submatches[1], 10, 64)
if err == nil {
padding.left = int(i)
}
}
submatches = reBottom.FindStringSubmatch(in)
if len(submatches) == 2 {
i, err := strconv.ParseInt(submatches[1], 10, 64)
if err == nil {
padding.bottom = int(i)
}
}
submatches = reRight.FindStringSubmatch(in)
i, err := strconv.ParseInt(submatches[1], 10, 64)
if len(submatches) == 2 {
if err == nil {
padding.right = int(i)
}
}
return padding
}
func (padding shapePadding) String() string {
return fmt.Sprintf("[top=%d,left=%d,bottom=%d,right=%d]", padding.top, padding.left, padding.bottom, padding.right)
}
func adjustPadding(obj *d2graph.Object, width, height float64, padding shapePadding) shapePadding {
if !obj.IsContainer() {
return padding
}
// compute extra space padding for label/icon
var extraTop, extraBottom, extraLeft, extraRight int
if obj.HasLabel() && obj.LabelPosition != nil {
labelHeight := obj.LabelDimensions.Height + 2*label.PADDING
labelWidth := obj.LabelDimensions.Width + 2*label.PADDING
switch label.FromString(*obj.LabelPosition) {
case label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight:
// Note: for corners we only add height
extraTop = labelHeight
case label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight:
extraBottom = labelHeight
case label.InsideMiddleLeft:
extraLeft = labelWidth
case label.InsideMiddleRight:
extraRight = labelWidth
}
}
if obj.HasIcon() && obj.IconPosition != nil {
iconSize := d2target.MAX_ICON_SIZE + 2*label.PADDING
switch label.FromString(*obj.IconPosition) {
case label.InsideTopLeft, label.InsideTopCenter, label.InsideTopRight:
extraTop = go2.Max(extraTop, iconSize)
case label.InsideBottomLeft, label.InsideBottomCenter, label.InsideBottomRight:
extraBottom = go2.Max(extraBottom, iconSize)
case label.InsideMiddleLeft:
extraLeft = go2.Max(extraLeft, iconSize)
case label.InsideMiddleRight:
extraRight = go2.Max(extraRight, iconSize)
}
}
maxChildWidth, maxChildHeight := math.Inf(-1), math.Inf(-1)
for _, c := range obj.ChildrenArray {
if c.Width > maxChildWidth {
maxChildWidth = c.Width
}
if c.Height > maxChildHeight {
maxChildHeight = c.Height
}
}
// We don't know exactly what the shape dimensions will be after layout, but for more accurate innerBox dimensions,
// we add the maxChildWidth and maxChildHeight with computed additions for the innerBox calculation
width += maxChildWidth + float64(extraLeft+extraRight)
height += maxChildHeight + float64(extraTop+extraBottom)
contentBox := geo.NewBox(geo.NewPoint(0, 0), width, height)
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[obj.Shape.Value]
s := shape.NewShape(shapeType, contentBox)
innerBox := s.GetInnerBox()
// If the shape inner box + label/icon height becomes greater than the default padding, we want to use that
//
// ┌OUTER───────────────────────────┬────────────────────────────────────────────┐
// │ │ │
// │ ┌INNER──────── ┬ ─────────────│───────────────────────────────────────┐ │
// │ │ │Label Padding │ │ │
// │ │ ┌LABEL─ ┴ ─────────────│───────┐┬ ┌ICON── ┬ ────┐ │ │
// │ │ │ │ ││ │ │ │ │ │
// │ │ │ │ ││Label Height │ Icon│ │ │ │
// │ │ │ │ ││ │ Height│ │ │ │
// │ │ └──────────────────────│───────┘┴ │ │ │ │ │
// │ │ │ └────── ┴ ────┘ │ │
// │ │ │ │ │
// │ │ ┴Default ELK Padding │ │
// │ │ ┌CHILD────────────────────────────────────────────────────────┐ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ └─────────────────────────────────────────────────────────────┘ │ │
// │ │ │ │
// │ └─────────────────────────────────────────────────────────────────────┘ │
// │ │
// └─────────────────────────────────────────────────────────────────────────────┘
// estimated shape innerBox padding
innerTop := int(math.Ceil(innerBox.TopLeft.Y))
innerBottom := int(math.Ceil(height - (innerBox.TopLeft.Y + innerBox.Height)))
innerLeft := int(math.Ceil(innerBox.TopLeft.X))
innerRight := int(math.Ceil(width - (innerBox.TopLeft.X + innerBox.Width)))
padding.top = go2.Max(padding.top, innerTop+extraTop)
padding.bottom = go2.Max(padding.bottom, innerBottom+extraBottom)
padding.left = go2.Max(padding.left, innerLeft+extraLeft)
padding.right = go2.Max(padding.right, innerRight+extraRight)
return padding
}
func positionLabelsIcons(obj *d2graph.Object) {
if obj.Icon != nil && obj.IconPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.IconPosition = go2.Pointer(label.InsideTopLeft.String())
if obj.LabelPosition == nil {
obj.LabelPosition = go2.Pointer(label.InsideTopRight.String())
return
}
} else if obj.SQLTable != nil || obj.Class != nil || obj.Language != "" {
obj.IconPosition = go2.Pointer(label.OutsideTopLeft.String())
} else {
obj.IconPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}
if obj.HasLabel() && obj.LabelPosition == nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
} else if obj.HasOutsideBottomLabel() {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
} else if obj.Icon != nil {
obj.LabelPosition = go2.Pointer(label.InsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.InsideMiddleCenter.String())
}
}
if float64(obj.LabelDimensions.Width) > obj.Width || float64(obj.LabelDimensions.Height) > obj.Height {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(label.OutsideTopCenter.String())
} else {
obj.LabelPosition = go2.Pointer(label.OutsideBottomCenter.String())
}
}
}