From 20c2c592ebe8f66f9a51fe59753d1cdc5552d2dc Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Sun, 16 Feb 2025 21:49:49 -0700 Subject: [PATCH] d2svg: implement connection icons --- ci/release/changelogs/next.md | 2 + d2exporter/export.go | 11 +- d2renderers/d2svg/d2svg.go | 39 ++- d2target/d2target.go | 35 +- .../connection-icons/dagre/board.exp.json | 306 ++++++++++++++++++ .../connection-icons/dagre/sketch.exp.svg | 106 ++++++ .../txtar/connection-icons/elk/board.exp.json | 288 +++++++++++++++++ .../txtar/connection-icons/elk/sketch.exp.svg | 106 ++++++ e2etests/txtar.txt | 9 + 9 files changed, 896 insertions(+), 6 deletions(-) create mode 100644 e2etests/testdata/txtar/connection-icons/dagre/board.exp.json create mode 100644 e2etests/testdata/txtar/connection-icons/dagre/sketch.exp.svg create mode 100644 e2etests/testdata/txtar/connection-icons/elk/board.exp.json create mode 100644 e2etests/testdata/txtar/connection-icons/elk/sketch.exp.svg 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 @@ +abc hello + + + + + + + \ 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 +}