diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index dccf32f20..a3776ba8d 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -1,5 +1,7 @@
#### Features ๐
+- Icons: connections can include icons [#12](https://github.com/terrastruct/d2/issues/12)
+
#### Improvements ๐งน
#### Bugfixes โ๏ธ
diff --git a/d2exporter/export.go b/d2exporter/export.go
index 8b676e554..672bae188 100644
--- a/d2exporter/export.go
+++ b/d2exporter/export.go
@@ -8,6 +8,7 @@ import (
"oss.terrastruct.com/util-go/go2"
+ "oss.terrastruct.com/d2/d2ast"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2parser"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
@@ -15,6 +16,7 @@ import (
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
+ "oss.terrastruct.com/d2/lib/label"
)
func Export(ctx context.Context, g *d2graph.Graph, fontFamily *d2fonts.FontFamily) (*d2target.Diagram, error) {
@@ -335,7 +337,14 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
if edge.Tooltip != nil {
connection.Tooltip = edge.Tooltip.Value
}
- connection.Icon = edge.Icon
+ if edge.Icon != nil {
+ connection.Icon = edge.Icon
+ if edge.IconPosition != nil {
+ connection.IconPosition = (d2ast.LabelPositionsMapping[edge.IconPosition.Value]).String()
+ } else {
+ connection.IconPosition = label.InsideMiddleCenter.String()
+ }
+ }
if edge.Style.Italic != nil {
connection.Italic, _ = strconv.ParseBool(edge.Style.Italic.Value)
diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go
index f4ece3b24..153b7b345 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -587,16 +587,51 @@ func drawConnection(writer io.Writer, diagramHash string, connection d2target.Co
markerEnd = fmt.Sprintf(`marker-end="url(#%s)" `, id)
}
+ if connection.Icon != nil {
+ iconPos := connection.GetIconPosition()
+ if iconPos != nil {
+ fmt.Fprintf(writer, ``,
+ html.EscapeString(connection.Icon.String()),
+ iconPos.X,
+ iconPos.Y,
+ d2target.DEFAULT_ICON_SIZE,
+ d2target.DEFAULT_ICON_SIZE,
+ )
+ }
+ }
+
var labelTL *geo.Point
if connection.Label != "" {
labelTL = connection.GetLabelTopLeft()
labelTL.X = math.Round(labelTL.X)
labelTL.Y = math.Round(labelTL.Y)
+ maskTL := labelTL.Copy()
+ width := connection.LabelWidth
+ height := connection.LabelHeight
+
+ if connection.Icon != nil {
+ width += d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE
+ maskTL.X -= float64(d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE)
+ }
+
if label.FromString(connection.LabelPosition).IsOnEdge() {
- labelMask = makeLabelMask(labelTL, connection.LabelWidth, connection.LabelHeight, 1)
+ labelMask = makeLabelMask(maskTL, width, height, 1)
} else {
- labelMask = makeLabelMask(labelTL, connection.LabelWidth, connection.LabelHeight, 0.75)
+ labelMask = makeLabelMask(maskTL, width, height, 0.75)
+ }
+ } else if connection.Icon != nil {
+ iconPos := connection.GetIconPosition()
+ if iconPos != nil {
+ maskTL := &geo.Point{
+ X: iconPos.X,
+ Y: iconPos.Y,
+ }
+ if label.FromString(connection.IconPosition).IsOnEdge() {
+ labelMask = makeLabelMask(maskTL, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 1)
+ } else {
+ labelMask = makeLabelMask(maskTL, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 0.75)
+ }
}
}
diff --git a/d2target/d2target.go b/d2target/d2target.go
index 54c1f2bb4..266e0f184 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -34,6 +34,8 @@ const (
MIN_ARROWHEAD_STROKE_WIDTH = 2
ARROWHEAD_PADDING = 2.
+
+ CONNECTION_ICON_LABEL_GAP = 8
)
var BorderOffset = geo.NewVector(5, 5)
@@ -609,13 +611,40 @@ type Connection struct {
Route []*geo.Point `json:"route"`
IsCurve bool `json:"isCurve,omitempty"`
- Animated bool `json:"animated"`
- Tooltip string `json:"tooltip"`
- Icon *url.URL `json:"icon"`
+ Animated bool `json:"animated"`
+ Tooltip string `json:"tooltip"`
+ Icon *url.URL `json:"icon"`
+ IconPosition string `json:"iconPosition,omitempty"`
ZIndex int `json:"zIndex"`
}
+func (c *Connection) GetIconPosition() *geo.Point {
+ if c.Icon == nil {
+ return nil
+ }
+
+ if c.Label != "" {
+ labelTL := c.GetLabelTopLeft()
+ if labelTL != nil {
+ // Position icon to the left of the label with a small gap
+ return &geo.Point{
+ X: labelTL.X - CONNECTION_ICON_LABEL_GAP - DEFAULT_ICON_SIZE,
+ Y: labelTL.Y + float64(c.LabelHeight)/2 - DEFAULT_ICON_SIZE/2,
+ }
+ }
+ }
+
+ point, _ := label.FromString(c.IconPosition).GetPointOnRoute(
+ c.Route,
+ float64(c.StrokeWidth),
+ -1,
+ float64(DEFAULT_ICON_SIZE),
+ float64(DEFAULT_ICON_SIZE),
+ )
+ return point
+}
+
func BaseConnection() *Connection {
return &Connection{
SrcArrow: NoArrowhead,
diff --git a/e2etests/testdata/txtar/connection-icons/dagre/board.exp.json b/e2etests/testdata/txtar/connection-icons/dagre/board.exp.json
new file mode 100644
index 000000000..96c74348f
--- /dev/null
+++ b/e2etests/testdata/txtar/connection-icons/dagre/board.exp.json
@@ -0,0 +1,306 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 186,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": 339,
+ "y": 0
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a -> b)[0]",
+ "src": "a",
+ "srcArrow": "none",
+ "dst": "b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "hello",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 33,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 52.5,
+ "y": 33
+ },
+ {
+ "x": 106.0999984741211,
+ "y": 33
+ },
+ {
+ "x": 132.89999389648438,
+ "y": 33
+ },
+ {
+ "x": 186.5,
+ "y": 33
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": {
+ "Scheme": "https",
+ "Opaque": "",
+ "User": null,
+ "Host": "icons.terrastruct.com",
+ "Path": "/essentials/213-alarm.svg",
+ "RawPath": "/essentials%2F213-alarm.svg",
+ "OmitHost": false,
+ "ForceQuery": false,
+ "RawQuery": "",
+ "Fragment": "",
+ "RawFragment": ""
+ },
+ "iconPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0
+ },
+ {
+ "id": "(b -> c)[0]",
+ "src": "b",
+ "srcArrow": "none",
+ "dst": "c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 239,
+ "y": 33
+ },
+ {
+ "x": 279,
+ "y": 33
+ },
+ {
+ "x": 299,
+ "y": 33
+ },
+ {
+ "x": 339,
+ "y": 33
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": {
+ "Scheme": "https",
+ "Opaque": "",
+ "User": null,
+ "Host": "icons.terrastruct.com",
+ "Path": "/essentials/213-alarm.svg",
+ "RawPath": "/essentials%2F213-alarm.svg",
+ "OmitHost": false,
+ "ForceQuery": false,
+ "RawQuery": "",
+ "Fragment": "",
+ "RawFragment": ""
+ },
+ "iconPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/connection-icons/dagre/sketch.exp.svg b/e2etests/testdata/txtar/connection-icons/dagre/sketch.exp.svg
new file mode 100644
index 000000000..5e302b9c2
--- /dev/null
+++ b/e2etests/testdata/txtar/connection-icons/dagre/sketch.exp.svg
@@ -0,0 +1,106 @@
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/connection-icons/elk/board.exp.json b/e2etests/testdata/txtar/connection-icons/elk/board.exp.json
new file mode 100644
index 000000000..18f1b8346
--- /dev/null
+++ b/e2etests/testdata/txtar/connection-icons/elk/board.exp.json
@@ -0,0 +1,288 @@
+{
+ "name": "",
+ "config": {
+ "sketch": false,
+ "themeID": 0,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 12
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 238,
+ "y": 12
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": 361,
+ "y": 12
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 8,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a -> b)[0]",
+ "src": "a",
+ "srcArrow": "none",
+ "dst": "b",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "hello",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 33,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 65,
+ "y": 45
+ },
+ {
+ "x": 238,
+ "y": 45
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": {
+ "Scheme": "https",
+ "Opaque": "",
+ "User": null,
+ "Host": "icons.terrastruct.com",
+ "Path": "/essentials/213-alarm.svg",
+ "RawPath": "/essentials%2F213-alarm.svg",
+ "OmitHost": false,
+ "ForceQuery": false,
+ "RawQuery": "",
+ "Fragment": "",
+ "RawFragment": ""
+ },
+ "iconPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0
+ },
+ {
+ "id": "(b -> c)[0]",
+ "src": "b",
+ "srcArrow": "none",
+ "dst": "c",
+ "dstArrow": "triangle",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "B1",
+ "borderRadius": 10,
+ "label": "",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N2",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "labelPosition": "",
+ "labelPercentage": 0,
+ "link": "",
+ "route": [
+ {
+ "x": 291,
+ "y": 45
+ },
+ {
+ "x": 361,
+ "y": 45
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": {
+ "Scheme": "https",
+ "Opaque": "",
+ "User": null,
+ "Host": "icons.terrastruct.com",
+ "Path": "/essentials/213-alarm.svg",
+ "RawPath": "/essentials%2F213-alarm.svg",
+ "OmitHost": false,
+ "ForceQuery": false,
+ "RawQuery": "",
+ "Fragment": "",
+ "RawFragment": ""
+ },
+ "iconPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0
+ }
+ ],
+ "root": {
+ "id": "",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 0,
+ "height": 0,
+ "opacity": 0,
+ "strokeDash": 0,
+ "strokeWidth": 0,
+ "borderRadius": 0,
+ "fill": "N7",
+ "stroke": "",
+ "animated": false,
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "",
+ "fontSize": 0,
+ "fontFamily": "",
+ "language": "",
+ "color": "",
+ "italic": false,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 0,
+ "labelHeight": 0,
+ "zIndex": 0,
+ "level": 0
+ }
+}
diff --git a/e2etests/testdata/txtar/connection-icons/elk/sketch.exp.svg b/e2etests/testdata/txtar/connection-icons/elk/sketch.exp.svg
new file mode 100644
index 000000000..be28cc05d
--- /dev/null
+++ b/e2etests/testdata/txtar/connection-icons/elk/sketch.exp.svg
@@ -0,0 +1,106 @@
+abc hello
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt
index a7ee56453..fcc6a7619 100644
--- a/e2etests/txtar.txt
+++ b/e2etests/txtar.txt
@@ -766,3 +766,12 @@ asdf:{
shape:sQl_table
zxcv
}
+
+-- connection-icons --
+direction: right
+a -> b: hello {
+ icon: https://icons.terrastruct.com/essentials%2F213-alarm.svg
+}
+b -> c: {
+ icon: https://icons.terrastruct.com/essentials%2F213-alarm.svg
+}