Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ci/release/changelogs/next.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#### Features 🚀

- labels: `icon-top` and `icon-bottom` `label.near` values position the label beside the icon [#XXXX](https://github.com/terrastruct/d2/pull/XXXX)
- exports: gif exports work with `animate: true` keyword [#2663](https://github.com/terrastruct/d2/pull/2663)

#### Improvements 🧹
Expand Down
6 changes: 6 additions & 0 deletions d2ast/keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ var LabelPositionsArray = []string{
"border-bottom-left",
"border-bottom-center",
"border-bottom-right",

"icon-top",
"icon-bottom",
}
var LabelPositions map[string]struct{}

Expand Down Expand Up @@ -198,6 +201,9 @@ var LabelPositionsMapping = map[string]label.Position{
"border-bottom-left": label.BorderBottomLeft,
"border-bottom-center": label.BorderBottomCenter,
"border-bottom-right": label.BorderBottomRight,

"icon-top": label.IconTop,
"icon-bottom": label.IconBottom,
}

var FillPatterns = []string{
Expand Down
36 changes: 36 additions & 0 deletions d2graph/d2graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -1914,6 +1914,8 @@ func (obj *Object) SpacingOpt(labelPadding, iconPadding float64, maxIconSize boo
padding.Left = labelWidth
case label.InsideMiddleRight:
padding.Right = labelWidth
case label.IconTop, label.IconBottom:
// Handled after icon section — label is positioned relative to icon
}
}

Expand Down Expand Up @@ -1947,6 +1949,40 @@ func (obj *Object) SpacingOpt(labelPadding, iconPadding float64, maxIconSize boo
}
}

// For icon-relative label positions, compute combined icon+label margin
if obj.HasLabel() && obj.HasIcon() && obj.LabelPosition != nil && obj.IconPosition != nil {
labelPos := label.FromString(*obj.LabelPosition)
if labelPos.IsIconRelative() {
iconPos := label.FromString(*obj.IconPosition)
iconSz := float64(d2target.MAX_ICON_SIZE + iconPadding)
if !maxIconSize {
iconSz = float64(d2target.GetIconSize(obj.Box, iconPos.String())) + iconPadding
}

var labelWidth, labelHeight float64
if obj.LabelDimensions.Width > 0 {
labelWidth = float64(obj.LabelDimensions.Width) + labelPadding
}
if obj.LabelDimensions.Height > 0 {
labelHeight = float64(obj.LabelDimensions.Height) + labelPadding
}

combinedWidth := iconSz + float64(label.PADDING) + labelWidth
combinedHeight := math.Max(iconSz, labelHeight)

switch iconPos {
case label.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight:
margin.Top = math.Max(margin.Top, combinedHeight)
case label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight:
margin.Bottom = math.Max(margin.Bottom, combinedHeight)
case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom:
margin.Left = math.Max(margin.Left, combinedWidth)
case label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
margin.Right = math.Max(margin.Right, combinedWidth)
}
}
}

dx, dy := obj.GetModifierElementAdjustments()
margin.Right += dx
margin.Top += dy
Expand Down
27 changes: 27 additions & 0 deletions d2graph/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ func (obj *Object) GetMargin() geo.Spacing {
margin.Left = labelWidth
case label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
margin.Right = labelWidth
case label.IconTop, label.IconBottom:
// Handled after icon section — label is positioned relative to icon
}

// if an outside label is larger than the object add margin accordingly
Expand Down Expand Up @@ -350,6 +352,31 @@ func (obj *Object) GetMargin() geo.Spacing {
}
}

// For icon-relative label positions, compute combined icon+label margin
if obj.HasLabel() && obj.HasIcon() && obj.LabelPosition != nil && obj.IconPosition != nil {
labelPos := label.FromString(*obj.LabelPosition)
if labelPos.IsIconRelative() {
iconPos := label.FromString(*obj.IconPosition)
iconSz := float64(d2target.MAX_ICON_SIZE + label.PADDING)
labelWidth := float64(obj.LabelDimensions.Width + label.PADDING)
labelHeight := float64(obj.LabelDimensions.Height + label.PADDING)

combinedWidth := iconSz + float64(label.PADDING) + labelWidth
combinedHeight := math.Max(iconSz, labelHeight)

switch iconPos {
case label.OutsideTopLeft, label.OutsideTopCenter, label.OutsideTopRight:
margin.Top = math.Max(margin.Top, combinedHeight)
case label.OutsideBottomLeft, label.OutsideBottomCenter, label.OutsideBottomRight:
margin.Bottom = math.Max(margin.Bottom, combinedHeight)
case label.OutsideLeftTop, label.OutsideLeftMiddle, label.OutsideLeftBottom:
margin.Left = math.Max(margin.Left, combinedWidth)
case label.OutsideRightTop, label.OutsideRightMiddle, label.OutsideRightBottom:
margin.Right = math.Max(margin.Right, combinedWidth)
}
}
}

dx, dy := obj.GetModifierElementAdjustments()
margin.Right += dx
margin.Top += dy
Expand Down
38 changes: 36 additions & 2 deletions d2renderers/d2svg/d2svg.go
Original file line number Diff line number Diff line change
Expand Up @@ -1987,6 +1987,31 @@ func drawShape(writer, appendixWriter io.Writer, diagramHash string, targetShape
float64(targetShape.LabelHeight),
)

if labelPosition.IsIconRelative() && targetShape.Icon != nil {
iconPosition := label.FromString(targetShape.IconPosition)
var iconBox *geo.Box
if iconPosition.IsOutside() {
iconBox = s.GetBox()
} else {
iconBox = s.GetInnerBox()
}
iconSize := d2target.GetIconSize(iconBox, targetShape.IconPosition)
iconTL := iconPosition.GetPointOnBox(iconBox, label.PADDING, float64(iconSize), float64(iconSize))

isRightSide := strings.Contains(targetShape.IconPosition, "RIGHT")
if isRightSide {
labelTL.X = iconTL.X - label.PADDING - float64(targetShape.LabelWidth)
} else {
labelTL.X = iconTL.X + float64(iconSize) + label.PADDING
}

if labelPosition == label.IconTop {
labelTL.Y = iconTL.Y
} else {
labelTL.Y = iconTL.Y + float64(iconSize) - float64(targetShape.LabelHeight)
}
}

if labelPosition.IsBorder() {
if jsRunner != nil {
labelMask = makeBorderLabelMask(labelPosition, labelTL, targetShape.LabelWidth, targetShape.LabelHeight, box, targetShape.StrokeWidth, 1.0, tl)
Expand Down Expand Up @@ -2174,12 +2199,21 @@ func drawShape(writer, appendixWriter io.Writer, diagramHash string, targetShape
fmt.Fprint(writer, rectEl.Render())
}
textEl := d2themes.NewThemableElement("text", inlineTheme)
textEl.X = labelTL.X + float64(targetShape.LabelWidth)/2
textAnchor := "middle"
if labelPosition.IsIconRelative() && strings.Contains(targetShape.IconPosition, "RIGHT") {
textEl.X = labelTL.X + float64(targetShape.LabelWidth)
textAnchor = "end"
} else if labelPosition.IsIconRelative() {
textEl.X = labelTL.X
textAnchor = "start"
} else {
textEl.X = labelTL.X + float64(targetShape.LabelWidth)/2
}
// text is vertically positioned at its baseline which is at labelTL+FontSize
textEl.Y = labelTL.Y + float64(targetShape.FontSize)
textEl.Fill = targetShape.GetFontColor()
textEl.ClassName = fontClass
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", targetShape.FontSize)
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", textAnchor, targetShape.FontSize)
textEl.Content = RenderText(targetShape.Label, textEl.X, float64(targetShape.LabelHeight))
fmt.Fprint(writer, textEl.Render())
}
Expand Down
39 changes: 37 additions & 2 deletions lib/label/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const (
UnlockedTop
UnlockedMiddle
UnlockedBottom

IconTop
IconBottom
)

func FromString(s string) Position {
Expand Down Expand Up @@ -153,6 +156,11 @@ func FromString(s string) Position {
return UnlockedMiddle
case "UNLOCKED_BOTTOM":
return UnlockedBottom

case "ICON_TOP":
return IconTop
case "ICON_BOTTOM":
return IconBottom
default:
return Unset
}
Expand Down Expand Up @@ -244,6 +252,11 @@ func (position Position) String() string {
case UnlockedBottom:
return "UNLOCKED_BOTTOM"

case IconTop:
return "ICON_TOP"
case IconBottom:
return "ICON_BOTTOM"

default:
return ""
}
Expand All @@ -263,7 +276,9 @@ func (position Position) IsShapePosition() bool {
BorderTopLeft, BorderTopCenter, BorderTopRight,
BorderLeftTop, BorderLeftMiddle, BorderLeftBottom,
BorderRightTop, BorderRightMiddle, BorderRightBottom,
BorderBottomLeft, BorderBottomCenter, BorderBottomRight:
BorderBottomLeft, BorderBottomCenter, BorderBottomRight,

IconTop, IconBottom:
return true
default:
return false
Expand All @@ -287,7 +302,8 @@ func (position Position) IsOutside() bool {
case OutsideTopLeft, OutsideTopCenter, OutsideTopRight,
OutsideBottomLeft, OutsideBottomCenter, OutsideBottomRight,
OutsideLeftTop, OutsideLeftMiddle, OutsideLeftBottom,
OutsideRightTop, OutsideRightMiddle, OutsideRightBottom:
OutsideRightTop, OutsideRightMiddle, OutsideRightBottom,
IconTop, IconBottom:
return true
default:
return false
Expand Down Expand Up @@ -324,6 +340,15 @@ func (position Position) IsOnEdge() bool {
}
}

func (position Position) IsIconRelative() bool {
switch position {
case IconTop, IconBottom:
return true
default:
return false
}
}

func (position Position) Mirrored() Position {
switch position {
case OutsideTopLeft:
Expand Down Expand Up @@ -410,6 +435,11 @@ func (position Position) Mirrored() Position {
case UnlockedMiddle:
return UnlockedMiddle

case IconTop:
return IconTop
case IconBottom:
return IconBottom

default:
return Unset
}
Expand Down Expand Up @@ -529,6 +559,11 @@ func (labelPosition Position) GetPointOnBox(box *geo.Box, padding, width, height
case BorderBottomRight:
p.X += box.Width - width - padding
p.Y += box.Height - height/2

case IconTop, IconBottom:
// Fallback: real positioning is handled by the SVG renderer
// which has access to icon coordinates
p.Y -= padding + height
}

return p
Expand Down