diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index f4616390ed..941e51e2b9 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -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 ๐Ÿงน diff --git a/d2ast/keywords.go b/d2ast/keywords.go index 8995961281..d492d5c807 100644 --- a/d2ast/keywords.go +++ b/d2ast/keywords.go @@ -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{} @@ -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{ diff --git a/d2graph/d2graph.go b/d2graph/d2graph.go index dbd935eb8e..95f5b57fc0 100644 --- a/d2graph/d2graph.go +++ b/d2graph/d2graph.go @@ -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 } } @@ -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 diff --git a/d2graph/layout.go b/d2graph/layout.go index d0f5a53dac..088e07acbe 100644 --- a/d2graph/layout.go +++ b/d2graph/layout.go @@ -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 @@ -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 diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 4001a853bd..040527eff1 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -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) @@ -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()) } diff --git a/lib/label/label.go b/lib/label/label.go index e33d24eb50..2eb6cbf620 100644 --- a/lib/label/label.go +++ b/lib/label/label.go @@ -66,6 +66,9 @@ const ( UnlockedTop UnlockedMiddle UnlockedBottom + + IconTop + IconBottom ) func FromString(s string) Position { @@ -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 } @@ -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 "" } @@ -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 @@ -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 @@ -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: @@ -410,6 +435,11 @@ func (position Position) Mirrored() Position { case UnlockedMiddle: return UnlockedMiddle + case IconTop: + return IconTop + case IconBottom: + return IconBottom + default: return Unset } @@ -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