diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md
index 9126e3255..0f7d02121 100644
--- a/ci/release/changelogs/next.md
+++ b/ci/release/changelogs/next.md
@@ -3,6 +3,8 @@
#### Features 🚀
+- Circle arrowheads are now supported. [#634](https://github.com/terrastruct/d2/pull/634)
+
- `animated` keyword implemented for connections. [#652](https://github.com/terrastruct/d2/pull/652)
- `border-radius` keyword implemented for squares/rectangles. [#688](https://github.com/terrastruct/d2/pull/688)
diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go
index 29d789296..2960a3cb0 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -111,6 +111,9 @@ func arrowheadDimensions(arrowhead d2target.Arrowhead, strokeWidth float64) (wid
case d2target.DiamondArrowhead:
widthMultiplier = 11
heightMultiplier = 9
+ case d2target.FilledCircleArrowhead, d2target.CircleArrowhead:
+ widthMultiplier = 12
+ heightMultiplier = 12
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
widthMultiplier = 14
heightMultiplier = 15
@@ -221,6 +224,43 @@ func arrowheadMarker(isTarget bool, id string, connection d2target.Connection) s
width*0.6, height*7/8,
)
}
+ case d2target.FilledCircleArrowhead:
+ attrs := fmt.Sprintf(`class="connection" fill="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
+ radius := width / 2
+ if isTarget {
+ path = fmt.Sprintf(``,
+ attrs,
+ radius+strokeWidth/2,
+ radius,
+ radius-strokeWidth/2,
+ )
+ } else {
+ path = fmt.Sprintf(``,
+ attrs,
+ radius-strokeWidth/2,
+ radius,
+ radius-strokeWidth/2,
+ )
+ }
+ case d2target.CircleArrowhead:
+ attrs := fmt.Sprintf(`class="connection" fill="white" stroke="%s" stroke-width="%d"`, connection.Stroke, connection.StrokeWidth)
+ radius := width / 2
+ if isTarget {
+ path = fmt.Sprintf(``,
+ attrs,
+ radius+strokeWidth/2,
+ radius,
+ radius-strokeWidth,
+ )
+ } else {
+ path = fmt.Sprintf(``,
+ attrs,
+ radius-strokeWidth/2,
+ radius,
+ radius-strokeWidth,
+ )
+ }
+
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
attrs := fmt.Sprintf(`class="connection" stroke="%s" stroke-width="%d" fill="white"`, connection.Stroke, connection.StrokeWidth)
offset := 4.0 + float64(connection.StrokeWidth*2)
diff --git a/d2target/d2target.go b/d2target/d2target.go
index 962701525..776f777f8 100644
--- a/d2target/d2target.go
+++ b/d2target/d2target.go
@@ -340,6 +340,8 @@ const (
TriangleArrowhead Arrowhead = "triangle"
DiamondArrowhead Arrowhead = "diamond"
FilledDiamondArrowhead Arrowhead = "filled-diamond"
+ CircleArrowhead Arrowhead = "circle"
+ FilledCircleArrowhead Arrowhead = "filled-circle"
// For fat arrows
LineArrowhead Arrowhead = "line"
@@ -357,6 +359,8 @@ var Arrowheads = map[string]struct{}{
string(TriangleArrowhead): {},
string(DiamondArrowhead): {},
string(FilledDiamondArrowhead): {},
+ string(CircleArrowhead): {},
+ string(FilledCircleArrowhead): {},
string(CfOne): {},
string(CfMany): {},
string(CfOneRequired): {},
@@ -370,6 +374,11 @@ func ToArrowhead(arrowheadType string, filled bool) Arrowhead {
return FilledDiamondArrowhead
}
return DiamondArrowhead
+ case string(CircleArrowhead):
+ if filled {
+ return FilledCircleArrowhead
+ }
+ return CircleArrowhead
case string(ArrowArrowhead):
return ArrowArrowhead
case string(CfOne):
diff --git a/e2etests/stable_test.go b/e2etests/stable_test.go
index 90ce50f94..f486264fa 100644
--- a/e2etests/stable_test.go
+++ b/e2etests/stable_test.go
@@ -1762,6 +1762,29 @@ e <--> f: {
target-arrowhead: {
shape: cf-one-required
}
+}`,
+ },
+ {
+ name: "circle_arrowhead",
+ script: `
+a <-> b: circle {
+ source-arrowhead: {
+ shape: circle
+ }
+ target-arrowhead: {
+ shape: circle
+ }
+}
+
+c <-> d: filled-circle {
+ source-arrowhead: {
+ shape: circle
+ style.filled: true
+ }
+ target-arrowhead: {
+ shape: circle
+ style.filled: true
+ }
}`,
},
{
diff --git a/e2etests/testdata/stable/circle_arrowhead/dagre/board.exp.json b/e2etests/testdata/stable/circle_arrowhead/dagre/board.exp.json
new file mode 100644
index 000000000..82f04068a
--- /dev/null
+++ b/e2etests/testdata/stable/circle_arrowhead/dagre/board.exp.json
@@ -0,0 +1,264 @@
+{
+ "name": "",
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 0
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "",
+ "pos": {
+ "x": 0,
+ "y": 247
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "",
+ "pos": {
+ "x": 174,
+ "y": 0
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "d",
+ "type": "",
+ "pos": {
+ "x": 173,
+ "y": 247
+ },
+ "width": 114,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 14,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a <-> b)[0]",
+ "src": "a",
+ "srcArrow": "circle",
+ "srcLabel": "",
+ "dst": "b",
+ "dstArrow": "circle",
+ "dstLabel": "",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "#0D32B2",
+ "label": "circle",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#676C7E",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 36,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "route": [
+ {
+ "x": 56.5,
+ "y": 126
+ },
+ {
+ "x": 56.5,
+ "y": 174.4
+ },
+ {
+ "x": 56.5,
+ "y": 198.7
+ },
+ {
+ "x": 56.5,
+ "y": 247.5
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(c <-> d)[0]",
+ "src": "c",
+ "srcArrow": "filled-circle",
+ "srcLabel": "",
+ "dst": "d",
+ "dstArrow": "filled-circle",
+ "dstLabel": "",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "#0D32B2",
+ "label": "filled-circle",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#676C7E",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 73,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "route": [
+ {
+ "x": 230,
+ "y": 126
+ },
+ {
+ "x": 230,
+ "y": 174.4
+ },
+ {
+ "x": 230,
+ "y": 198.7
+ },
+ {
+ "x": 230,
+ "y": 247.5
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ }
+ ]
+}
diff --git a/e2etests/testdata/stable/circle_arrowhead/dagre/sketch.exp.svg b/e2etests/testdata/stable/circle_arrowhead/dagre/sketch.exp.svg
new file mode 100644
index 000000000..e8e6605e7
--- /dev/null
+++ b/e2etests/testdata/stable/circle_arrowhead/dagre/sketch.exp.svg
@@ -0,0 +1,60 @@
+
+
\ No newline at end of file
diff --git a/e2etests/testdata/stable/circle_arrowhead/elk/board.exp.json b/e2etests/testdata/stable/circle_arrowhead/elk/board.exp.json
new file mode 100644
index 000000000..bb09866a6
--- /dev/null
+++ b/e2etests/testdata/stable/circle_arrowhead/elk/board.exp.json
@@ -0,0 +1,246 @@
+{
+ "name": "",
+ "fontFamily": "SourceSansPro",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "",
+ "pos": {
+ "x": 12,
+ "y": 12
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "a",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "",
+ "pos": {
+ "x": 12,
+ "y": 359
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "b",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "",
+ "pos": {
+ "x": 145,
+ "y": 12
+ },
+ "width": 113,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "c",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 13,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "d",
+ "type": "",
+ "pos": {
+ "x": 145,
+ "y": 359
+ },
+ "width": 114,
+ "height": 126,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "#F7F8FE",
+ "stroke": "#0D32B2",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#0A0F25",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 14,
+ "labelHeight": 26,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a <-> b)[0]",
+ "src": "a",
+ "srcArrow": "circle",
+ "srcLabel": "",
+ "dst": "b",
+ "dstArrow": "circle",
+ "dstLabel": "",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "#0D32B2",
+ "label": "circle",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#676C7E",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 36,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "route": [
+ {
+ "x": 68.5,
+ "y": 138
+ },
+ {
+ "x": 68.5,
+ "y": 359
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(c <-> d)[0]",
+ "src": "c",
+ "srcArrow": "filled-circle",
+ "srcLabel": "",
+ "dst": "d",
+ "dstArrow": "filled-circle",
+ "dstLabel": "",
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "stroke": "#0D32B2",
+ "label": "filled-circle",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "#676C7E",
+ "italic": true,
+ "bold": false,
+ "underline": false,
+ "labelWidth": 73,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "labelPercentage": 0,
+ "route": [
+ {
+ "x": 202,
+ "y": 138
+ },
+ {
+ "x": 202,
+ "y": 359
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ }
+ ]
+}
diff --git a/e2etests/testdata/stable/circle_arrowhead/elk/sketch.exp.svg b/e2etests/testdata/stable/circle_arrowhead/elk/sketch.exp.svg
new file mode 100644
index 000000000..0d9c1f70f
--- /dev/null
+++ b/e2etests/testdata/stable/circle_arrowhead/elk/sketch.exp.svg
@@ -0,0 +1,60 @@
+
+
\ No newline at end of file