diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 083a5db15..80566ebb2 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -1,5 +1,6 @@
#### Features 🚀
+- Container with constant key near attribute now can have descendant objects and connections [#1071](https://github.com/terrastruct/d2/pull/1071)
- Multi-board SVG outputs with internal links go to their output paths [#1116](https://github.com/terrastruct/d2/pull/1116)
- New grid layout to place nodes in rows and columns [#1122](https://github.com/terrastruct/d2/pull/1122)
diff --git a/d2compiler/compile.go b/d2compiler/compile.go
index c3d721bc7..3050a19f8 100644
--- a/d2compiler/compile.go
+++ b/d2compiler/compile.go
@@ -768,31 +768,35 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
}
}
} else if isConst {
- is := false
- for _, e := range g.Edges {
- if e.Src == obj || e.Dst == obj {
- is = true
- break
- }
- }
- if is {
- c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on connected shapes")
- continue
- }
if obj.Parent != g.Root {
c.errorf(obj.Attributes.NearKey, "constant near keys can only be set on root level shapes")
continue
}
- if len(obj.ChildrenArray) > 0 {
- c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on shapes with children")
- continue
- }
} else {
c.errorf(obj.Attributes.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
continue
}
}
}
+
+ for _, edge := range g.Edges {
+ srcNearContainer := edge.Src.OuterNearContainer()
+ dstNearContainer := edge.Dst.OuterNearContainer()
+
+ var isSrcNearConst, isDstNearConst bool
+
+ if srcNearContainer != nil {
+ _, isSrcNearConst = d2graph.NearConstants[d2graph.Key(srcNearContainer.Attributes.NearKey)[0]]
+ }
+ if dstNearContainer != nil {
+ _, isDstNearConst = d2graph.NearConstants[d2graph.Key(dstNearContainer.Attributes.NearKey)[0]]
+ }
+
+ if (isSrcNearConst || isDstNearConst) && srcNearContainer != dstNearContainer {
+ c.errorf(edge.References[0].Edge, "cannot connect objects from within a container, that has near constant set, to objects outside that container")
+ }
+ }
+
}
func (c *compiler) validateEdges(g *d2graph.Graph) {
diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go
index 89b8b04f2..b50629d94 100644
--- a/d2compiler/compile_test.go
+++ b/d2compiler/compile_test.go
@@ -1558,25 +1558,27 @@ d2/testdata/d2compiler/TestCompile/near-invalid.d2:14:9: near keys cannot be set
`,
expErr: `d2/testdata/d2compiler/TestCompile/near_bad_constant.d2:1:9: near key "txop-center" must be the absolute path to a shape or one of the following constants: top-left, top-center, top-right, center-left, center-right, bottom-left, bottom-center, bottom-right`,
},
- {
- name: "near_bad_container",
-
- text: `x: {
- near: top-center
- y
-}
-`,
- expErr: `d2/testdata/d2compiler/TestCompile/near_bad_container.d2:2:9: constant near keys cannot be set on shapes with children`,
- },
{
name: "near_bad_connected",
- text: `x: {
- near: top-center
-}
-x -> y
-`,
- expErr: `d2/testdata/d2compiler/TestCompile/near_bad_connected.d2:2:9: constant near keys cannot be set on connected shapes`,
+ text: `
+ x: {
+ near: top-center
+ }
+ x -> y
+ `,
+ expErr: `d2/testdata/d2compiler/TestCompile/near_bad_connected.d2:5:5: cannot connect objects from within a container, that has near constant set, to objects outside that container`,
+ },
+ {
+ name: "near_descendant_connect_to_outside",
+ text: `
+ x: {
+ near: top-left
+ y
+ }
+ x.y -> z
+ `,
+ expErr: "d2/testdata/d2compiler/TestCompile/near_descendant_connect_to_outside.d2:6:5: cannot connect objects from within a container, that has near constant set, to objects outside that container",
},
{
name: "nested_near_constant",
diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go
index be2892e97..64f1aaebf 100644
--- a/d2graph/d2graph.go
+++ b/d2graph/d2graph.go
@@ -974,6 +974,16 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
return &dims, nil
}
+func (obj *Object) OuterNearContainer() *Object {
+ for obj != nil {
+ if obj.Attributes.NearKey != nil {
+ return obj
+ }
+ obj = obj.Parent
+ }
+ return nil
+}
+
type Edge struct {
Index int `json:"index"`
diff --git a/d2layouts/d2near/layout.go b/d2layouts/d2near/layout.go
index 695fda827..07cfef4d1 100644
--- a/d2layouts/d2near/layout.go
+++ b/d2layouts/d2near/layout.go
@@ -10,47 +10,62 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
- "oss.terrastruct.com/util-go/go2"
)
const pad = 20
// Layout finds the shapes which are assigned constant near keywords and places them.
-func Layout(ctx context.Context, g *d2graph.Graph, constantNears []*d2graph.Object) error {
- if len(constantNears) == 0 {
+func Layout(ctx context.Context, g *d2graph.Graph, constantNearGraphs []*d2graph.Graph) error {
+ if len(constantNearGraphs) == 0 {
return nil
}
+ for _, tempGraph := range constantNearGraphs {
+ tempGraph.Root.ChildrenArray[0].Parent = g.Root
+ for _, obj := range tempGraph.Objects {
+ obj.Graph = g
+ }
+ }
+
// Imagine the graph has two long texts, one at top center and one at top left.
// Top left should go left enough to not collide with center.
// So place the center ones first, then the later ones will consider them for bounding box
for _, processCenters := range []bool{true, false} {
- for _, obj := range constantNears {
+ for _, tempGraph := range constantNearGraphs {
+ obj := tempGraph.Root.ChildrenArray[0]
if processCenters == strings.Contains(d2graph.Key(obj.Attributes.NearKey)[0], "-center") {
+ prevX, prevY := obj.TopLeft.X, obj.TopLeft.Y
obj.TopLeft = geo.NewPoint(place(obj))
+ dx, dy := obj.TopLeft.X-prevX, obj.TopLeft.Y-prevY
+
+ for _, subObject := range tempGraph.Objects {
+ // `obj` already been replaced above by `place(obj)`
+ if subObject == obj {
+ continue
+ }
+ subObject.TopLeft.X += dx
+ subObject.TopLeft.Y += dy
+ }
+ for _, subEdge := range tempGraph.Edges {
+ for _, point := range subEdge.Route {
+ point.X += dx
+ point.Y += dy
+ }
+ }
}
}
- for _, obj := range constantNears {
+ for _, tempGraph := range constantNearGraphs {
+ obj := tempGraph.Root.ChildrenArray[0]
if processCenters == strings.Contains(d2graph.Key(obj.Attributes.NearKey)[0], "-center") {
// The z-index for constant nears does not matter, as it will not collide
- g.Objects = append(g.Objects, obj)
+ g.Objects = append(g.Objects, tempGraph.Objects...)
obj.Parent.Children[strings.ToLower(obj.ID)] = obj
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray, obj)
+ g.Edges = append(g.Edges, tempGraph.Edges...)
}
}
}
- // These shapes skipped core layout, which means they also skipped label placements
- for _, obj := range constantNears {
- if obj.HasOutsideBottomLabel() {
- obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
- } else if obj.Attributes.Icon != nil {
- obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
- } else {
- obj.LabelPosition = go2.Pointer(string(label.InsideMiddleCenter))
- }
- }
-
return nil
}
@@ -59,30 +74,66 @@ func place(obj *d2graph.Object) (float64, float64) {
tl, br := boundingBox(obj.Graph)
w := br.X - tl.X
h := br.Y - tl.Y
- switch d2graph.Key(obj.Attributes.NearKey)[0] {
+
+ nearKeyStr := d2graph.Key(obj.Attributes.NearKey)[0]
+ var x, y float64
+ switch nearKeyStr {
case "top-left":
- return tl.X - obj.Width - pad, tl.Y - obj.Height - pad
+ x, y = tl.X-obj.Width-pad, tl.Y-obj.Height-pad
+ break
case "top-center":
- return tl.X + w/2 - obj.Width/2, tl.Y - obj.Height - pad
+ x, y = tl.X+w/2-obj.Width/2, tl.Y-obj.Height-pad
+ break
case "top-right":
- return br.X + pad, tl.Y - obj.Height - pad
+ x, y = br.X+pad, tl.Y-obj.Height-pad
+ break
case "center-left":
- return tl.X - obj.Width - pad, tl.Y + h/2 - obj.Height/2
+ x, y = tl.X-obj.Width-pad, tl.Y+h/2-obj.Height/2
+ break
case "center-right":
- return br.X + pad, tl.Y + h/2 - obj.Height/2
+ x, y = br.X+pad, tl.Y+h/2-obj.Height/2
+ break
case "bottom-left":
- return tl.X - obj.Width - pad, br.Y + pad
+ x, y = tl.X-obj.Width-pad, br.Y+pad
+ break
case "bottom-center":
- return br.X - w/2 - obj.Width/2, br.Y + pad
+ x, y = br.X-w/2-obj.Width/2, br.Y+pad
+ break
case "bottom-right":
- return br.X + pad, br.Y + pad
+ x, y = br.X+pad, br.Y+pad
+ break
}
- return 0, 0
+
+ if obj.LabelPosition != nil && !strings.Contains(*obj.LabelPosition, "INSIDE") {
+ if strings.Contains(*obj.LabelPosition, "_TOP_") {
+ // label is on the top, and container is placed on the bottom
+ if strings.Contains(nearKeyStr, "bottom") {
+ y += float64(*obj.LabelHeight)
+ }
+ } else if strings.Contains(*obj.LabelPosition, "_LEFT_") {
+ // label is on the left, and container is placed on the right
+ if strings.Contains(nearKeyStr, "right") {
+ x += float64(*obj.LabelWidth)
+ }
+ } else if strings.Contains(*obj.LabelPosition, "_RIGHT_") {
+ // label is on the right, and container is placed on the left
+ if strings.Contains(nearKeyStr, "left") {
+ x -= float64(*obj.LabelWidth)
+ }
+ } else if strings.Contains(*obj.LabelPosition, "_BOTTOM_") {
+ // label is on the bottom, and container is placed on the top
+ if strings.Contains(nearKeyStr, "top") {
+ y -= float64(*obj.LabelHeight)
+ }
+ }
+ }
+
+ return x, y
}
// WithoutConstantNears plucks out the graph objects which have "near" set to a constant value
// This is to be called before layout engines so they don't take part in regular positioning
-func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (nears []*d2graph.Object) {
+func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (constantNearGraphs []*d2graph.Graph) {
for i := 0; i < len(g.Objects); i++ {
obj := g.Objects[i]
if obj.Attributes.NearKey == nil {
@@ -94,8 +145,20 @@ func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (nears []*d2gra
}
_, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
if isConst {
- nears = append(nears, obj)
- g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
+ descendantObjects, edges := pluckObjAndEdges(g, obj)
+
+ tempGraph := d2graph.NewGraph()
+ tempGraph.Root.ChildrenArray = []*d2graph.Object{obj}
+ tempGraph.Root.Children[strings.ToLower(obj.ID)] = obj
+
+ for _, descendantObj := range descendantObjects {
+ descendantObj.Graph = tempGraph
+ }
+ tempGraph.Objects = descendantObjects
+ tempGraph.Edges = edges
+
+ constantNearGraphs = append(constantNearGraphs, tempGraph)
+
i--
delete(obj.Parent.Children, strings.ToLower(obj.ID))
for i := 0; i < len(obj.Parent.ChildrenArray); i++ {
@@ -104,9 +167,38 @@ func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (nears []*d2gra
break
}
}
+
+ obj.Parent = tempGraph.Root
}
}
- return nears
+ return constantNearGraphs
+}
+
+func pluckObjAndEdges(g *d2graph.Graph, obj *d2graph.Object) (descendantsObjects []*d2graph.Object, edges []*d2graph.Edge) {
+ for i := 0; i < len(g.Edges); i++ {
+ edge := g.Edges[i]
+ if edge.Src == obj || edge.Dst == obj {
+ edges = append(edges, edge)
+ g.Edges = append(g.Edges[:i], g.Edges[i+1:]...)
+ i--
+ }
+ }
+
+ for i := 0; i < len(g.Objects); i++ {
+ temp := g.Objects[i]
+ if temp.AbsID() == obj.AbsID() {
+ descendantsObjects = append(descendantsObjects, obj)
+ g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
+ for _, child := range obj.ChildrenArray {
+ subObjects, subEdges := pluckObjAndEdges(g, child)
+ descendantsObjects = append(descendantsObjects, subObjects...)
+ edges = append(edges, subEdges...)
+ }
+ break
+ }
+ }
+
+ return descendantsObjects, edges
}
// boundingBox gets the center of the graph as defined by shapes
@@ -134,6 +226,9 @@ func boundingBox(g *d2graph.Graph) (tl, br *geo.Point) {
y2 = math.Max(y2, obj.TopLeft.Y+obj.Height)
}
} else {
+ if obj.OuterNearContainer() != nil {
+ continue
+ }
x1 = math.Min(x1, obj.TopLeft.X)
y1 = math.Min(y1, obj.TopLeft.Y)
x2 = math.Max(x2, obj.TopLeft.X+obj.Width)
diff --git a/d2lib/d2.go b/d2lib/d2.go
index 11638833a..5b497b594 100644
--- a/d2lib/d2.go
+++ b/d2lib/d2.go
@@ -69,7 +69,14 @@ func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2ta
return nil, err
}
- constantNears := d2near.WithoutConstantNears(ctx, g)
+ constantNearGraphs := d2near.WithoutConstantNears(ctx, g)
+
+ // run core layout for constantNears
+ for _, tempGraph := range constantNearGraphs {
+ if err = coreLayout(ctx, tempGraph); err != nil {
+ return nil, err
+ }
+ }
layoutWithGrids := d2grid.Layout(ctx, g, coreLayout)
@@ -78,7 +85,7 @@ func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2ta
return nil, err
}
- err = d2near.Layout(ctx, g, constantNears)
+ err = d2near.Layout(ctx, g, constantNearGraphs)
if err != nil {
return nil, err
}
diff --git a/d2renderers/d2sketch/testdata/root-fill/sketch.exp.svg b/d2renderers/d2sketch/testdata/root-fill/sketch.exp.svg
index 912cc89c8..8d6c9227a 100644
--- a/d2renderers/d2sketch/testdata/root-fill/sketch.exp.svg
+++ b/d2renderers/d2sketch/testdata/root-fill/sketch.exp.svg
@@ -1,16 +1,16 @@
-
\ No newline at end of file
diff --git a/e2etests/stable_test.go b/e2etests/stable_test.go
index 274ec7e1b..34f5f8b96 100644
--- a/e2etests/stable_test.go
+++ b/e2etests/stable_test.go
@@ -12,6 +12,68 @@ var testMarkdown string
func testStable(t *testing.T) {
tcs := []testCase{
+ {
+ name: "legend_with_near_key",
+ script: `
+ direction: right
+
+ x -> y: {
+ style.stroke: green
+ }
+
+ y -> z: {
+ style.stroke: red
+ }
+
+ legend: {
+ near: bottom-center
+ color1: foo {
+ shape: text
+ style.font-color: green
+ }
+
+ color2: bar {
+ shape: text
+ style.font-color: red
+ }
+ }
+ `,
+ },
+ {
+ name: "near_keys_for_container",
+ script: `
+ x: {
+ near: top-left
+ a -> b
+ c -> d
+ }
+ y: {
+ near: top-right
+ a -> b
+ c -> d
+ }
+ z: {
+ near: bottom-center
+ a -> b
+ c -> d
+ }
+
+ a: {
+ near: top-center
+ b: {
+ c
+ }
+ }
+ b: {
+ near: bottom-right
+ a: {
+ c: {
+ d
+ }
+ }
+ }
+ `,
+ },
{
name: "class_and_sqlTable_border_radius",
script: `
diff --git a/e2etests/testdata/regression/unconnected/dagre/board.exp.json b/e2etests/testdata/regression/unconnected/dagre/board.exp.json
index b878f6fe1..6567131b6 100644
--- a/e2etests/testdata/regression/unconnected/dagre/board.exp.json
+++ b/e2etests/testdata/regression/unconnected/dagre/board.exp.json
@@ -531,7 +531,6 @@
"underline": false,
"labelWidth": 639,
"labelHeight": 51,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
diff --git a/e2etests/testdata/regression/unconnected/dagre/sketch.exp.svg b/e2etests/testdata/regression/unconnected/dagre/sketch.exp.svg
index 33b8cfb2e..908e225ea 100644
--- a/e2etests/testdata/regression/unconnected/dagre/sketch.exp.svg
+++ b/e2etests/testdata/regression/unconnected/dagre/sketch.exp.svg
@@ -1,16 +1,16 @@
-xyThe top of the mountain
Cats, no less liquid than their shadows, offer no angles to the wind.
If we can't fix it, it ain't broke.
Dieters live life in the fasting lane.
-
JoeDonaldi am top lefti am top righti am bottom lefti am bottom right
+JoeDonaldi am top lefti am top righti am bottom lefti am bottom right
\ No newline at end of file
diff --git a/e2etests/testdata/stable/constant_near_stress/elk/board.exp.json b/e2etests/testdata/stable/constant_near_stress/elk/board.exp.json
index 12a29e999..340654717 100644
--- a/e2etests/testdata/stable/constant_near_stress/elk/board.exp.json
+++ b/e2etests/testdata/stable/constant_near_stress/elk/board.exp.json
@@ -122,7 +122,6 @@
"underline": false,
"labelWidth": 162,
"labelHeight": 21,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
@@ -163,7 +162,6 @@
"underline": false,
"labelWidth": 943,
"labelHeight": 131,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
@@ -286,7 +284,6 @@
"underline": false,
"labelWidth": 80,
"labelHeight": 21,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
@@ -327,7 +324,6 @@
"underline": false,
"labelWidth": 90,
"labelHeight": 21,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
@@ -368,7 +364,6 @@
"underline": false,
"labelWidth": 107,
"labelHeight": 21,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
@@ -409,7 +404,6 @@
"underline": false,
"labelWidth": 117,
"labelHeight": 21,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
diff --git a/e2etests/testdata/stable/constant_near_stress/elk/sketch.exp.svg b/e2etests/testdata/stable/constant_near_stress/elk/sketch.exp.svg
index 28a2fd326..d0fa21159 100644
--- a/e2etests/testdata/stable/constant_near_stress/elk/sketch.exp.svg
+++ b/e2etests/testdata/stable/constant_near_stress/elk/sketch.exp.svg
@@ -1,16 +1,16 @@
-xyThe top of the mountain
Cats, no less liquid than their shadows, offer no angles to the wind.
If we can't fix it, it ain't broke.
Dieters live life in the fasting lane.
-
JoeDonaldi am top lefti am top righti am bottom lefti am bottom right
+JoeDonaldi am top lefti am top righti am bottom lefti am bottom right
\ No newline at end of file
diff --git a/e2etests/testdata/stable/constant_near_title/dagre/board.exp.json b/e2etests/testdata/stable/constant_near_title/dagre/board.exp.json
index e1edce8c6..ecde77955 100644
--- a/e2etests/testdata/stable/constant_near_title/dagre/board.exp.json
+++ b/e2etests/testdata/stable/constant_near_title/dagre/board.exp.json
@@ -245,7 +245,6 @@
"underline": false,
"labelWidth": 266,
"labelHeight": 51,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
diff --git a/e2etests/testdata/stable/constant_near_title/dagre/sketch.exp.svg b/e2etests/testdata/stable/constant_near_title/dagre/sketch.exp.svg
index 6a993aab3..aba3b1358 100644
--- a/e2etests/testdata/stable/constant_near_title/dagre/sketch.exp.svg
+++ b/e2etests/testdata/stable/constant_near_title/dagre/sketch.exp.svg
@@ -1,16 +1,16 @@
-poll the peopleresultsunfavorablefavorablewill of the people
A winning strategy
-
+
\ No newline at end of file
diff --git a/e2etests/testdata/stable/constant_near_title/elk/board.exp.json b/e2etests/testdata/stable/constant_near_title/elk/board.exp.json
index 30d2251bd..5ce0d864e 100644
--- a/e2etests/testdata/stable/constant_near_title/elk/board.exp.json
+++ b/e2etests/testdata/stable/constant_near_title/elk/board.exp.json
@@ -245,7 +245,6 @@
"underline": false,
"labelWidth": 266,
"labelHeight": 51,
- "labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
diff --git a/e2etests/testdata/stable/constant_near_title/elk/sketch.exp.svg b/e2etests/testdata/stable/constant_near_title/elk/sketch.exp.svg
index c7b55c66b..59adac5f9 100644
--- a/e2etests/testdata/stable/constant_near_title/elk/sketch.exp.svg
+++ b/e2etests/testdata/stable/constant_near_title/elk/sketch.exp.svg
@@ -1,16 +1,16 @@
-poll the peopleresultsunfavorablefavorablewill of the people