diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 2b477d515..b3e28446a 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -9,14 +9,16 @@ Hope everyone is enjoying the holidays this week!
#### Features ๐
- `sketch` flag renders the diagram to look like it was sketched by hand. [#492](https://github.com/terrastruct/d2/pull/492)
+- `near` now takes constants like `top-center`, particularly useful for diagram titles. See [docs](https://d2lang.com/tour/text#near-a-constant) for more. [#525](https://github.com/terrastruct/d2/pull/525)
#### Improvements ๐งน
- Improved label placements for shapes with images and icons to avoid overlapping labels. [#474](https://github.com/terrastruct/d2/pull/474)
-- Themes are applied to sql_table and class shapes. [#521](https://github.com/terrastruct/d2/pull/521)
+- Themes are applied to `sql_table` and `class` shapes. [#521](https://github.com/terrastruct/d2/pull/521)
- `class` shapes use monospaced font. [#521](https://github.com/terrastruct/d2/pull/521)
- Sequence diagram edge group labels have more reasonable padding. [#512](https://github.com/terrastruct/d2/pull/512)
- ELK layout engine preserves order of nodes. [#282](https://github.com/terrastruct/d2/issues/282)
+- Markdown headings set font-family explicitly, so that external stylesheets with more specific targeting don't override it. [#525](https://github.com/terrastruct/d2/pull/525)
#### Bugfixes โ๏ธ
diff --git a/d2compiler/compile.go b/d2compiler/compile.go
index 9ca656098..9ca373d81 100644
--- a/d2compiler/compile.go
+++ b/d2compiler/compile.go
@@ -850,9 +850,14 @@ func (c *compiler) validateKeys(obj *d2graph.Object, m *d2ast.Map) {
func (c *compiler) validateNear(g *d2graph.Graph) {
for _, obj := range g.Objects {
if obj.Attributes.NearKey != nil {
- _, ok := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
- if !ok {
- c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "near key %#v does not exist. It must be the absolute path to a shape.", d2format.Format(obj.Attributes.NearKey))
+ _, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
+ _, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
+ if !isKey && !isConst {
+ c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "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
+ }
+ if !isKey && isConst && obj.Parent != g.Root {
+ c.errorf(obj.Attributes.NearKey.GetRange().Start, obj.Attributes.NearKey.GetRange().End, "constant near keys can only be set on root level shapes")
continue
}
}
diff --git a/d2compiler/compile_test.go b/d2compiler/compile_test.go
index afe9ceec4..d6459f34d 100644
--- a/d2compiler/compile_test.go
+++ b/d2compiler/compile_test.go
@@ -1266,6 +1266,28 @@ x -> y: {
}
},
},
+ {
+ name: "near_constant",
+
+ text: `x.near: top-center
+`,
+ },
+ {
+ name: "near_bad_constant",
+
+ text: `x.near: txop-center
+`,
+ expErr: `d2/testdata/d2compiler/TestCompile/near_bad_constant.d2:1:1: 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: "nested_near_constant",
+
+ text: `x.y.near: top-center
+`,
+ expErr: `d2/testdata/d2compiler/TestCompile/nested_near_constant.d2:1:1: constant near keys can only be set on root level shapes
+`,
+ },
{
name: "reserved_icon_near_style",
@@ -1312,7 +1334,7 @@ y
expErr: `d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:3:9: bad icon url "::????:::%%orange": parse "::????:::%%orange": missing protocol scheme
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:4:18: expected "opacity" to be a number between 0.0 and 1.0
d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:5:18: expected "opacity" to be a number between 0.0 and 1.0
-d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "y" does not exist. It must be the absolute path to a shape.
+d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:1:1: near key "y" 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
`,
},
{
diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go
index e60aa17c0..ebaf0274c 100644
--- a/d2graph/d2graph.go
+++ b/d2graph/d2graph.go
@@ -1189,6 +1189,22 @@ var StyleKeywords = map[string]struct{}{
"filled": {},
}
+// TODO maybe autofmt should allow other values, and transform them to conform
+// e.g. left-center becomes center-left
+var NearConstantsArray = []string{
+ "top-left",
+ "top-center",
+ "top-right",
+
+ "center-left",
+ "center-right",
+
+ "bottom-left",
+ "bottom-center",
+ "bottom-right",
+}
+var NearConstants map[string]struct{}
+
func init() {
for k, v := range StyleKeywords {
ReservedKeywords[k] = v
@@ -1196,4 +1212,8 @@ func init() {
for k, v := range ReservedKeywordHolders {
ReservedKeywords[k] = v
}
+ NearConstants = make(map[string]struct{}, len(NearConstantsArray))
+ for _, k := range NearConstantsArray {
+ NearConstants[k] = struct{}{}
+ }
}
diff --git a/d2layouts/d2near/layout.go b/d2layouts/d2near/layout.go
new file mode 100644
index 000000000..99e8a10e1
--- /dev/null
+++ b/d2layouts/d2near/layout.go
@@ -0,0 +1,146 @@
+// d2near applies near keywords when they're constants
+// Intended to be run as the last stage of layout after the diagram has already undergone layout
+package d2near
+
+import (
+ "context"
+ "math"
+ "strings"
+
+ "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/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 {
+ return nil
+ }
+
+ // 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 {
+ if processCenters == strings.Contains(d2graph.Key(obj.Attributes.NearKey)[0], "center") {
+ obj.TopLeft = geo.NewPoint(place(obj))
+ }
+ }
+ for _, obj := range constantNears {
+ 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)
+ obj.Parent.Children[obj.ID] = obj
+ obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray, obj)
+ }
+ }
+ }
+
+ // These shapes skipped core layout, which means they also skipped label placements
+ for _, obj := range constantNears {
+ if obj.Attributes.Shape.Value == d2target.ShapeImage {
+ 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
+}
+
+// place returns the position of obj, taking into consideration its near value and the diagram
+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] {
+ case "top-left":
+ return tl.X - obj.Width - pad, tl.Y - obj.Height - pad
+ case "top-center":
+ return tl.X + w/2 - obj.Width/2, tl.Y - obj.Height - pad
+ case "top-right":
+ return br.X + pad, tl.Y - obj.Height - pad
+ case "center-left":
+ return tl.X - obj.Width - pad, tl.Y + h/2 - obj.Height/2
+ case "center-right":
+ return br.X + pad, tl.Y + h/2 - obj.Height/2
+ case "bottom-left":
+ return tl.X - obj.Width - pad, br.Y + pad
+ case "bottom-center":
+ return br.X - w/2 - obj.Width/2, br.Y + pad
+ case "bottom-right":
+ return br.X + pad, br.Y + pad
+ }
+ return 0, 0
+}
+
+// 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) {
+ for i := 0; i < len(g.Objects); i++ {
+ obj := g.Objects[i]
+ if obj.Attributes.NearKey == nil {
+ continue
+ }
+ _, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
+ if isKey {
+ continue
+ }
+ _, 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:]...)
+ i--
+ delete(obj.Parent.Children, obj.ID)
+ for i := 0; i < len(obj.Parent.ChildrenArray); i++ {
+ if obj.Parent.ChildrenArray[i] == obj {
+ obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)
+ break
+ }
+ }
+ }
+ }
+ return nears
+}
+
+// boundingBox gets the center of the graph as defined by shapes
+// The bounds taking into consideration only shapes gives more of a feeling of true center
+// It differs from d2target.BoundingBox which needs to include every visible thing
+func boundingBox(g *d2graph.Graph) (tl, br *geo.Point) {
+ if len(g.Objects) == 0 {
+ return geo.NewPoint(0, 0), geo.NewPoint(0, 0)
+ }
+ x1 := math.Inf(1)
+ y1 := math.Inf(1)
+ x2 := math.Inf(-1)
+ y2 := math.Inf(-1)
+
+ for _, obj := range g.Objects {
+ if obj.Attributes.NearKey != nil {
+ // Top left should not be MORE top than top-center
+ // But it should go more left if top-center label extends beyond bounds of diagram
+ switch d2graph.Key(obj.Attributes.NearKey)[0] {
+ case "top-center", "bottom-center":
+ x1 = math.Min(x1, obj.TopLeft.X)
+ x2 = math.Max(x2, obj.TopLeft.X+obj.Width)
+ case "center-left", "center-right":
+ y1 = math.Min(y1, obj.TopLeft.Y)
+ y2 = math.Max(y2, obj.TopLeft.Y+obj.Height)
+ }
+ } else {
+ x1 = math.Min(x1, obj.TopLeft.X)
+ y1 = math.Min(y1, obj.TopLeft.Y)
+ x2 = math.Max(x2, obj.TopLeft.X+obj.Width)
+ y2 = math.Max(y2, obj.TopLeft.Y+obj.Height)
+ }
+ }
+
+ return geo.NewPoint(x1, y1), geo.NewPoint(x2, y2)
+}
diff --git a/d2layouts/d2sequence/layout.go b/d2layouts/d2sequence/layout.go
index 55192ceab..1747c859b 100644
--- a/d2layouts/d2sequence/layout.go
+++ b/d2layouts/d2sequence/layout.go
@@ -69,6 +69,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, layout func(ctx context.Conte
layoutEdges, edgeOrder := getLayoutEdges(g, edgesToRemove)
g.Edges = layoutEdges
layoutObjects, objectOrder := getLayoutObjects(g, objectsToRemove)
+ // TODO this isn't a proper deletion because the objects still appear as children of the object
g.Objects = layoutObjects
if g.Root.IsSequenceDiagram() {
diff --git a/d2lib/d2.go b/d2lib/d2.go
index be71a1536..936124b9f 100644
--- a/d2lib/d2.go
+++ b/d2lib/d2.go
@@ -9,6 +9,7 @@ import (
"oss.terrastruct.com/d2/d2compiler"
"oss.terrastruct.com/d2/d2exporter"
"oss.terrastruct.com/d2/d2graph"
+ "oss.terrastruct.com/d2/d2layouts/d2near"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2target"
@@ -48,9 +49,20 @@ func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target
return nil, nil, err
}
- if layout, err := getLayout(opts); err != nil {
+ coreLayout, err := getLayout(opts)
+ if err != nil {
return nil, nil, err
- } else if err := d2sequence.Layout(ctx, g, layout); err != nil {
+ }
+
+ constantNears := d2near.WithoutConstantNears(ctx, g)
+
+ err = d2sequence.Layout(ctx, g, coreLayout)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ err = d2near.Layout(ctx, g, constantNears)
+ if err != nil {
return nil, nil, err
}
}
diff --git a/d2renderers/d2sketch/testdata/twitter/sketch.exp.svg b/d2renderers/d2sketch/testdata/twitter/sketch.exp.svg
index 471e8fa0a..f93b93c1f 100644
--- a/d2renderers/d2sketch/testdata/twitter/sketch.exp.svg
+++ b/d2renderers/d2sketch/testdata/twitter/sketch.exp.svg
@@ -282,6 +282,7 @@ width="3454" height="2449" viewBox="-100 -100 3454 2449">xy
The top of the mountain
+
JoeDonald
Cats, no less liquid than their shadows, offer no angles to the wind.