diff --git a/d2renderers/d2sketch/sketch.go b/d2renderers/d2sketch/sketch.go
index 3ab9e8c27..edb8f547c 100644
--- a/d2renderers/d2sketch/sketch.go
+++ b/d2renderers/d2sketch/sketch.go
@@ -321,29 +321,70 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
}
func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) {
- roughness := 0.5
- js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
- paths, err := computeRoughPathData(r, js)
- if err != nil {
- return "", err
- }
- output := ""
animatedClass := ""
if connection.Animated {
animatedClass = " animated-connection"
}
- pathEl := d2themes.NewThemableElement("path")
- pathEl.Fill = color.None
- pathEl.Stroke = connection.Stroke
- pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
- pathEl.Style = connection.CSSStyle()
- pathEl.Attributes = attrs
- for _, p := range paths {
- pathEl.D = p
- output += pathEl.Render()
+ if connection.Animated {
+ // If connection is animated and bidirectional
+ if (connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead) {
+ // There is no pure CSS way to animate bidirectional connections in two directions, so we split it up
+ path1, path2, err := svg.SplitPath(path, 0.5)
+
+ if err != nil {
+ return "", err
+ }
+
+ pathEl1 := d2themes.NewThemableElement("path")
+ pathEl1.D = path1
+ pathEl1.Fill = color.None
+ pathEl1.Stroke = connection.Stroke
+ pathEl1.ClassName = fmt.Sprintf("connection%s", animatedClass)
+ pathEl1.Style = connection.CSSStyle()
+ pathEl1.Style += "animation-direction: reverse;"
+ pathEl1.Attributes = attrs
+
+ pathEl2 := d2themes.NewThemableElement("path")
+ pathEl2.D = path2
+ pathEl2.Fill = color.None
+ pathEl2.Stroke = connection.Stroke
+ pathEl2.ClassName = fmt.Sprintf("connection%s", animatedClass)
+ pathEl2.Style = connection.CSSStyle()
+ pathEl2.Attributes = attrs
+ return pathEl1.Render() + " " + pathEl2.Render(), nil
+ } else {
+ pathEl := d2themes.NewThemableElement("path")
+ pathEl.D = path
+ pathEl.Fill = color.None
+ pathEl.Stroke = connection.Stroke
+ pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
+ pathEl.Style = connection.CSSStyle()
+ pathEl.Attributes = attrs
+ return pathEl.Render(), nil
+ }
+ } else {
+ roughness := 0.5
+ js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
+ paths, err := computeRoughPathData(r, js)
+ if err != nil {
+ return "", err
+ }
+
+ output := ""
+
+ pathEl := d2themes.NewThemableElement("path")
+ pathEl.Fill = color.None
+ pathEl.Stroke = connection.Stroke
+ pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
+ pathEl.Style = connection.CSSStyle()
+ pathEl.Attributes = attrs
+ for _, p := range paths {
+ pathEl.D = p
+ output += pathEl.Render()
+ }
+ return output, nil
}
- return output, nil
}
// TODO cleanup
diff --git a/d2renderers/d2sketch/testdata/animated/sketch.exp.svg b/d2renderers/d2sketch/testdata/animated/sketch.exp.svg
index fecf1bfd8..819d95394 100644
--- a/d2renderers/d2sketch/testdata/animated/sketch.exp.svg
+++ b/d2renderers/d2sketch/testdata/animated/sketch.exp.svg
@@ -110,7 +110,7 @@
-wintersummertreessnowsun
+wintersummertreessnowsun
diff --git a/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg b/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg
index 1178e671f..4b24c1970 100644
--- a/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg
+++ b/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg
@@ -108,7 +108,7 @@
-wintersummertreessnowsun
+wintersummertreessnowsun
diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go
index 9faf2a361..9360c3eed 100644
--- a/d2renderers/d2svg/d2svg.go
+++ b/d2renderers/d2svg/d2svg.go
@@ -11,7 +11,6 @@ import (
"html"
"io"
"sort"
- "strconv"
"strings"
"math"
@@ -495,178 +494,6 @@ func makeLabelMask(labelTL *geo.Point, width, height int, opacity float64) strin
)
}
-// Gets a certain line/curve's SVG path string. offsetIdx and pathData provides the points needed
-func getSVGPathString(pathType string, offsetIdx int, pathData []string) (string, error) {
- switch pathType {
- case "M":
- return fmt.Sprintf("M %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil
- case "L":
- return fmt.Sprintf("L %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil
- case "C":
- return fmt.Sprintf("C %s %s %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4], pathData[offsetIdx+5], pathData[offsetIdx+6]), nil
- case "S":
- return fmt.Sprintf("S %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4]), nil
- default:
- return "", fmt.Errorf("unknown svg path command \"%s\"", pathData[offsetIdx])
- }
-}
-
-// Gets how much to increment by on an SVG string to get to the next path command
-func getPathStringIncrement(pathType string) (int, error) {
- switch pathType {
- case "M":
- return 3, nil
- case "L":
- return 3, nil
- case "C":
- return 7, nil
- case "S":
- return 5, nil
- default:
- return 0, fmt.Errorf("unknown svg path command \"%s\"", pathType)
- }
-}
-
-// This function finds the length of a path in SVG notation
-func pathLength(pathData []string) (float64, error) {
- var x, y, pathLength float64
- var prevPosition geo.Point
- var increment int
-
- for i := 0; i < len(pathData); i += increment {
- switch pathData[i] {
- case "M":
- x, _ = strconv.ParseFloat(pathData[i+1], 64)
- y, _ = strconv.ParseFloat(pathData[i+2], 64)
- case "L":
- x, _ = strconv.ParseFloat(pathData[i+1], 64)
- y, _ = strconv.ParseFloat(pathData[i+2], 64)
-
- pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- case "C":
- x, _ = strconv.ParseFloat(pathData[i+5], 64)
- y, _ = strconv.ParseFloat(pathData[i+6], 64)
-
- pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- case "S":
- x, _ = strconv.ParseFloat(pathData[i+3], 64)
- y, _ = strconv.ParseFloat(pathData[i+4], 64)
-
- pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- default:
- return 0, fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
- }
-
- prevPosition = geo.Point{X: x, Y: y}
-
- incr, err := getPathStringIncrement(pathData[i])
-
- if err != nil {
- return 0, err
- }
-
- increment = incr
- }
-
- return pathLength, nil
-}
-
-// Splits an SVG path into two SVG paths, with the first path being ~{percentage}% of the path
-func splitPath(path string, percentage float64) (string, string, error) {
- var sumPathLens, curPathLen, x, y float64
- var prevPosition geo.Point
- var path1, path2 string
- var increment int
-
- pastHalf := false
- pathData := strings.Split(path, " ")
- pathLen, err := pathLength(pathData)
-
- if err != nil {
- return "", "", err
- }
-
- for i := 0; i < len(pathData); i += increment {
- switch pathData[i] {
- case "M":
- x, _ = strconv.ParseFloat(pathData[i+1], 64)
- y, _ = strconv.ParseFloat(pathData[i+2], 64)
- case "L":
- x, _ = strconv.ParseFloat(pathData[i+1], 64)
- y, _ = strconv.ParseFloat(pathData[i+2], 64)
-
- curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- case "C":
- x, _ = strconv.ParseFloat(pathData[i+5], 64)
- y, _ = strconv.ParseFloat(pathData[i+6], 64)
-
- curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- case "S":
- x, _ = strconv.ParseFloat(pathData[i+3], 64)
- y, _ = strconv.ParseFloat(pathData[i+4], 64)
-
- curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
- default:
- return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
- }
-
- curPath, err := getSVGPathString(pathData[i], i, pathData)
- if err != nil {
- return "", "", err
- }
-
- sumPathLens += curPathLen
-
- if pastHalf { // add to path2
- path2 += curPath
- } else if sumPathLens < pathLen*percentage { // add to path1
- path1 += curPath
- } else { // transition from path1 -> path2
- t := (pathLen*percentage - sumPathLens + curPathLen) / curPathLen
-
- switch pathData[i] {
- case "L":
- path1 += fmt.Sprintf("L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y)
- path2 += fmt.Sprintf("M %f %f L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y, x, y)
- case "C":
- h1x, _ := strconv.ParseFloat(pathData[i+1], 64)
- h1y, _ := strconv.ParseFloat(pathData[i+2], 64)
- h2x, _ := strconv.ParseFloat(pathData[i+3], 64)
- h2y, _ := strconv.ParseFloat(pathData[i+4], 64)
-
- heading1 := geo.Point{X: h1x, Y: h1y}
- heading2 := geo.Point{X: h2x, Y: h2y}
- nextPoint := geo.Point{X: x, Y: y}
-
- q1, q2, q3, q4 := svg.BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0, 0.5)
- path1 += fmt.Sprintf("C %f %f %f %f %f %f ", q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y)
-
- q1, q2, q3, q4 = svg.BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0.5, 1)
- path2 += fmt.Sprintf("M %f %f C %f %f %f %f %f %f ", q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y)
- case "S":
- // Skip S curves because they are shorter and we can split along the connection to the next path instead
- path1 += fmt.Sprintf("S %s %s %s %s ", pathData[i+1], pathData[i+2], pathData[i+3], pathData[i+4])
- path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4])
- default:
- return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
- }
-
- pastHalf = true
- }
-
- incr, err := getPathStringIncrement(pathData[i])
-
- if err != nil {
- return "", "", err
- }
-
- increment = incr
- prevPosition = geo.Point{X: x, Y: y}
- }
-
- return path1, path2, nil
-}
-
func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) {
opacityStyle := ""
if connection.Opacity != 1.0 {
@@ -745,7 +572,7 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co
// If connection is animated and bidirectional
if connection.Animated && ((connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead)) {
// There is no pure CSS way to animate bidirectional connections in two directions, so we split it up
- path1, path2, err := splitPath(path, 0.5)
+ path1, path2, err := svg.SplitPath(path, 0.5)
if err != nil {
return "", err
diff --git a/e2etests/testdata/txtar.txt b/e2etests/testdata/txtar.txt
index 19692fcd8..263bfa561 100644
--- a/e2etests/testdata/txtar.txt
+++ b/e2etests/testdata/txtar.txt
@@ -214,7 +214,7 @@ ok: {
dog1 -> dog3
}
--- bidirectional_connection_animation --
+-- bidirectional-connection-animation --
a <-> b: {style.animated: true}
a <-> c: {style.animated: true}
a <-> d: {style.animated: true}
@@ -266,3 +266,17 @@ x <-> y <-> z: {
}
direction: right
}
+
+-- sketch-bidirectional-connection-animation --
+vars: {
+ d2-config: {
+ sketch: true
+ }
+}
+
+a <-> b: {style.animated: true}
+a <-> c: {style.animated: true}
+a <-> d: {style.animated: true}
+a <-> e
+f <-> g: {style.animated: true}
+x -- x: {style.animated: true}
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/dagre/board.exp.json b/e2etests/testdata/txtar/bidirectional-connection-animation/dagre/board.exp.json
similarity index 100%
rename from e2etests/testdata/txtar/bidirectional_connection_animation/dagre/board.exp.json
rename to e2etests/testdata/txtar/bidirectional-connection-animation/dagre/board.exp.json
diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/dagre/sketch.exp.svg b/e2etests/testdata/txtar/bidirectional-connection-animation/dagre/sketch.exp.svg
similarity index 100%
rename from e2etests/testdata/txtar/bidirectional_connection_animation/dagre/sketch.exp.svg
rename to e2etests/testdata/txtar/bidirectional-connection-animation/dagre/sketch.exp.svg
diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/elk/board.exp.json b/e2etests/testdata/txtar/bidirectional-connection-animation/elk/board.exp.json
similarity index 100%
rename from e2etests/testdata/txtar/bidirectional_connection_animation/elk/board.exp.json
rename to e2etests/testdata/txtar/bidirectional-connection-animation/elk/board.exp.json
diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/elk/sketch.exp.svg b/e2etests/testdata/txtar/bidirectional-connection-animation/elk/sketch.exp.svg
similarity index 100%
rename from e2etests/testdata/txtar/bidirectional_connection_animation/elk/sketch.exp.svg
rename to e2etests/testdata/txtar/bidirectional-connection-animation/elk/sketch.exp.svg
diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json
new file mode 100644
index 000000000..d4be34b71
--- /dev/null
+++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json
@@ -0,0 +1,703 @@
+{
+ "name": "",
+ "config": {
+ "sketch": true,
+ "themeID": null,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "HandDrawn",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": 172,
+ "y": 0
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 0,
+ "y": 166
+ },
+ "width": 55,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 10,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": 115,
+ "y": 166
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "d",
+ "type": "rectangle",
+ "pos": {
+ "x": 229,
+ "y": 166
+ },
+ "width": 55,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 10,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "e",
+ "type": "rectangle",
+ "pos": {
+ "x": 344,
+ "y": 166
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "e",
+ "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": "f",
+ "type": "rectangle",
+ "pos": {
+ "x": 457,
+ "y": 0
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "f",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "g",
+ "type": "rectangle",
+ "pos": {
+ "x": 457,
+ "y": 166
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "g",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "x",
+ "type": "rectangle",
+ "pos": {
+ "x": 571,
+ "y": 0
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "x",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a <-> b)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "b",
+ "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,
+ "route": [
+ {
+ "x": 172.5,
+ "y": 46
+ },
+ {
+ "x": 56.5,
+ "y": 102
+ },
+ {
+ "x": 27.5,
+ "y": 126
+ },
+ {
+ "x": 27.5,
+ "y": 166
+ }
+ ],
+ "isCurve": true,
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> c)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "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,
+ "route": [
+ {
+ "x": 176,
+ "y": 66
+ },
+ {
+ "x": 148.8000030517578,
+ "y": 106
+ },
+ {
+ "x": 142,
+ "y": 126
+ },
+ {
+ "x": 142,
+ "y": 166
+ }
+ ],
+ "isCurve": true,
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> d)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "d",
+ "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,
+ "route": [
+ {
+ "x": 222.5,
+ "y": 66
+ },
+ {
+ "x": 249.6999969482422,
+ "y": 106
+ },
+ {
+ "x": 256.5,
+ "y": 126
+ },
+ {
+ "x": 256.5,
+ "y": 166
+ }
+ ],
+ "isCurve": true,
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> e)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "e",
+ "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,
+ "route": [
+ {
+ "x": 226.25,
+ "y": 46.08599853515625
+ },
+ {
+ "x": 341.6499938964844,
+ "y": 102.01699829101562
+ },
+ {
+ "x": 370.5,
+ "y": 126
+ },
+ {
+ "x": 370.5,
+ "y": 166
+ }
+ ],
+ "isCurve": true,
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(f <-> g)[0]",
+ "src": "f",
+ "srcArrow": "triangle",
+ "dst": "g",
+ "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,
+ "route": [
+ {
+ "x": 484,
+ "y": 66
+ },
+ {
+ "x": 484,
+ "y": 106
+ },
+ {
+ "x": 484,
+ "y": 126
+ },
+ {
+ "x": 484,
+ "y": 166
+ }
+ ],
+ "isCurve": true,
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(x -- x)[0]",
+ "src": "x",
+ "srcArrow": "none",
+ "dst": "x",
+ "dstArrow": "none",
+ "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,
+ "route": [
+ {
+ "x": 624.666015625,
+ "y": 16
+ },
+ {
+ "x": 646.2659912109375,
+ "y": 3.1989998817443848
+ },
+ {
+ "x": 653,
+ "y": 0
+ },
+ {
+ "x": 655,
+ "y": 0
+ },
+ {
+ "x": 657,
+ "y": 0
+ },
+ {
+ "x": 659.666015625,
+ "y": 6.599999904632568
+ },
+ {
+ "x": 661.666015625,
+ "y": 16.5
+ },
+ {
+ "x": 663.666015625,
+ "y": 26.399999618530273
+ },
+ {
+ "x": 663.666015625,
+ "y": 39.599998474121094
+ },
+ {
+ "x": 661.666015625,
+ "y": 49.5
+ },
+ {
+ "x": 659.666015625,
+ "y": 59.400001525878906
+ },
+ {
+ "x": 646.2659912109375,
+ "y": 62.79999923706055
+ },
+ {
+ "x": 624.666015625,
+ "y": 50
+ }
+ ],
+ "isCurve": true,
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "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": "",
+ "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/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg
new file mode 100644
index 000000000..01ae7f387
--- /dev/null
+++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg
@@ -0,0 +1,116 @@
+
\ No newline at end of file
diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json
new file mode 100644
index 000000000..5abaaf205
--- /dev/null
+++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json
@@ -0,0 +1,653 @@
+{
+ "name": "",
+ "config": {
+ "sketch": true,
+ "themeID": null,
+ "darkThemeID": null,
+ "pad": null,
+ "center": null,
+ "layoutEngine": null
+ },
+ "isFolderOnly": false,
+ "fontFamily": "HandDrawn",
+ "shapes": [
+ {
+ "id": "a",
+ "type": "rectangle",
+ "pos": {
+ "x": 71,
+ "y": 12
+ },
+ "width": 160,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "b",
+ "type": "rectangle",
+ "pos": {
+ "x": 12,
+ "y": 208
+ },
+ "width": 55,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 10,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "c",
+ "type": "rectangle",
+ "pos": {
+ "x": 87,
+ "y": 208
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "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": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "d",
+ "type": "rectangle",
+ "pos": {
+ "x": 161,
+ "y": 208
+ },
+ "width": 55,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "d",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 10,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "e",
+ "type": "rectangle",
+ "pos": {
+ "x": 236,
+ "y": 208
+ },
+ "width": 53,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "e",
+ "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": "f",
+ "type": "rectangle",
+ "pos": {
+ "x": 309,
+ "y": 12
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "f",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "g",
+ "type": "rectangle",
+ "pos": {
+ "x": 309,
+ "y": 208
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "g",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ },
+ {
+ "id": "x",
+ "type": "rectangle",
+ "pos": {
+ "x": 433,
+ "y": 12
+ },
+ "width": 54,
+ "height": 66,
+ "opacity": 1,
+ "strokeDash": 0,
+ "strokeWidth": 2,
+ "borderRadius": 0,
+ "fill": "B6",
+ "stroke": "B1",
+ "shadow": false,
+ "3d": false,
+ "multiple": false,
+ "double-border": false,
+ "tooltip": "",
+ "link": "",
+ "icon": null,
+ "iconPosition": "",
+ "blend": false,
+ "fields": null,
+ "methods": null,
+ "columns": null,
+ "label": "x",
+ "fontSize": 16,
+ "fontFamily": "DEFAULT",
+ "language": "",
+ "color": "N1",
+ "italic": false,
+ "bold": true,
+ "underline": false,
+ "labelWidth": 9,
+ "labelHeight": 21,
+ "labelPosition": "INSIDE_MIDDLE_CENTER",
+ "zIndex": 0,
+ "level": 1
+ }
+ ],
+ "connections": [
+ {
+ "id": "(a <-> b)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "b",
+ "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,
+ "route": [
+ {
+ "x": 103.25,
+ "y": 78
+ },
+ {
+ "x": 103.25,
+ "y": 118
+ },
+ {
+ "x": 39.5,
+ "y": 118
+ },
+ {
+ "x": 39.5,
+ "y": 208
+ }
+ ],
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> c)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "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,
+ "route": [
+ {
+ "x": 135.25,
+ "y": 78
+ },
+ {
+ "x": 135.25,
+ "y": 168
+ },
+ {
+ "x": 114,
+ "y": 168
+ },
+ {
+ "x": 114,
+ "y": 208
+ }
+ ],
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> d)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "d",
+ "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,
+ "route": [
+ {
+ "x": 167.25,
+ "y": 78
+ },
+ {
+ "x": 167.25,
+ "y": 168
+ },
+ {
+ "x": 188.5,
+ "y": 168
+ },
+ {
+ "x": 188.5,
+ "y": 208
+ }
+ ],
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(a <-> e)[0]",
+ "src": "a",
+ "srcArrow": "triangle",
+ "dst": "e",
+ "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,
+ "route": [
+ {
+ "x": 199.25,
+ "y": 78
+ },
+ {
+ "x": 199.25,
+ "y": 118
+ },
+ {
+ "x": 262.5,
+ "y": 118
+ },
+ {
+ "x": 262.5,
+ "y": 208
+ }
+ ],
+ "animated": false,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(f <-> g)[0]",
+ "src": "f",
+ "srcArrow": "triangle",
+ "dst": "g",
+ "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,
+ "route": [
+ {
+ "x": 336,
+ "y": 78
+ },
+ {
+ "x": 336,
+ "y": 208
+ }
+ ],
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "zIndex": 0
+ },
+ {
+ "id": "(x -- x)[0]",
+ "src": "x",
+ "srcArrow": "none",
+ "dst": "x",
+ "dstArrow": "none",
+ "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,
+ "route": [
+ {
+ "x": 433,
+ "y": 34
+ },
+ {
+ "x": 383,
+ "y": 34
+ },
+ {
+ "x": 383,
+ "y": 56
+ },
+ {
+ "x": 433,
+ "y": 56
+ }
+ ],
+ "animated": true,
+ "tooltip": "",
+ "icon": null,
+ "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": "",
+ "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/sketch-bidirectional-connection-animation/elk/sketch.exp.svg b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/sketch.exp.svg
new file mode 100644
index 000000000..9603d3358
--- /dev/null
+++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/sketch.exp.svg
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+abcdefgx
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/svg/path.go b/lib/svg/path.go
index eddbb970f..56201c8ce 100644
--- a/lib/svg/path.go
+++ b/lib/svg/path.go
@@ -3,6 +3,7 @@ package svg
import (
"fmt"
"math"
+ "strconv"
"strings"
"oss.terrastruct.com/d2/lib/geo"
@@ -139,3 +140,179 @@ func BezierCurveSegment(p1, p2, p3, p4 *geo.Point, t0, t1 float64) (geo.Point, g
return q1, q2, q3, q4
}
+
+// Gets a certain line/curve's SVG path string. offsetIdx and pathData provides the points needed
+func getSVGPathString(pathType string, offsetIdx int, pathData []string) (string, error) {
+ switch pathType {
+ case "M":
+ return fmt.Sprintf("M %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil
+ case "L":
+ return fmt.Sprintf("L %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil
+ case "C":
+ return fmt.Sprintf("C %s %s %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4], pathData[offsetIdx+5], pathData[offsetIdx+6]), nil
+ case "S":
+ return fmt.Sprintf("S %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4]), nil
+ default:
+ return "", fmt.Errorf("unknown svg path command \"%s\"", pathData[offsetIdx])
+ }
+}
+
+// Gets how much to increment by on an SVG string to get to the next path command
+func getPathStringIncrement(pathType string) (int, error) {
+ switch pathType {
+ case "M":
+ return 3, nil
+ case "L":
+ return 3, nil
+ case "C":
+ return 7, nil
+ case "S":
+ return 5, nil
+ default:
+ return 0, fmt.Errorf("unknown svg path command \"%s\"", pathType)
+ }
+}
+
+// This function finds the length of a path in SVG notation
+func pathLength(pathData []string) (float64, error) {
+ var x, y, pathLength float64
+ var prevPosition geo.Point
+ var increment int
+
+ for i := 0; i < len(pathData); i += increment {
+ switch pathData[i] {
+ case "M":
+ x, _ = strconv.ParseFloat(pathData[i+1], 64)
+ y, _ = strconv.ParseFloat(pathData[i+2], 64)
+ case "L":
+ x, _ = strconv.ParseFloat(pathData[i+1], 64)
+ y, _ = strconv.ParseFloat(pathData[i+2], 64)
+
+ pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ case "C":
+ x, _ = strconv.ParseFloat(pathData[i+5], 64)
+ y, _ = strconv.ParseFloat(pathData[i+6], 64)
+
+ pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ case "S":
+ x, _ = strconv.ParseFloat(pathData[i+3], 64)
+ y, _ = strconv.ParseFloat(pathData[i+4], 64)
+
+ pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ default:
+ return 0, fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
+ }
+
+ prevPosition = geo.Point{X: x, Y: y}
+
+ incr, err := getPathStringIncrement(pathData[i])
+
+ if err != nil {
+ return 0, err
+ }
+
+ increment = incr
+ }
+
+ return pathLength, nil
+}
+
+// Splits an SVG path into two SVG paths, with the first path being ~{percentage}% of the path
+func SplitPath(path string, percentage float64) (string, string, error) {
+ var sumPathLens, curPathLen, x, y float64
+ var prevPosition geo.Point
+ var path1, path2 string
+ var increment int
+
+ pastHalf := false
+ pathData := strings.Split(path, " ")
+ pathLen, err := pathLength(pathData)
+
+ if err != nil {
+ return "", "", err
+ }
+
+ for i := 0; i < len(pathData); i += increment {
+ switch pathData[i] {
+ case "M":
+ x, _ = strconv.ParseFloat(pathData[i+1], 64)
+ y, _ = strconv.ParseFloat(pathData[i+2], 64)
+
+ curPathLen = 0
+ case "L":
+ x, _ = strconv.ParseFloat(pathData[i+1], 64)
+ y, _ = strconv.ParseFloat(pathData[i+2], 64)
+
+ curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ case "C":
+ x, _ = strconv.ParseFloat(pathData[i+5], 64)
+ y, _ = strconv.ParseFloat(pathData[i+6], 64)
+
+ curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ case "S":
+ x, _ = strconv.ParseFloat(pathData[i+3], 64)
+ y, _ = strconv.ParseFloat(pathData[i+4], 64)
+
+ curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y)
+ default:
+ return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
+ }
+
+ curPath, err := getSVGPathString(pathData[i], i, pathData)
+ if err != nil {
+ return "", "", err
+ }
+
+ sumPathLens += curPathLen
+
+ if pastHalf { // add to path2
+ path2 += curPath
+ } else if sumPathLens < pathLen*percentage { // add to path1
+ path1 += curPath
+ } else { // transition from path1 -> path2
+ t := (pathLen*percentage - sumPathLens + curPathLen) / curPathLen
+
+ switch pathData[i] {
+ case "M":
+ path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4])
+ case "L":
+ path1 += fmt.Sprintf("L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y)
+ path2 += fmt.Sprintf("M %f %f L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y, x, y)
+ case "C":
+ h1x, _ := strconv.ParseFloat(pathData[i+1], 64)
+ h1y, _ := strconv.ParseFloat(pathData[i+2], 64)
+ h2x, _ := strconv.ParseFloat(pathData[i+3], 64)
+ h2y, _ := strconv.ParseFloat(pathData[i+4], 64)
+
+ heading1 := geo.Point{X: h1x, Y: h1y}
+ heading2 := geo.Point{X: h2x, Y: h2y}
+ nextPoint := geo.Point{X: x, Y: y}
+
+ q1, q2, q3, q4 := BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0, 0.5)
+ path1 += fmt.Sprintf("C %f %f %f %f %f %f ", q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y)
+
+ q1, q2, q3, q4 = BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0.5, 1)
+ path2 += fmt.Sprintf("M %f %f C %f %f %f %f %f %f ", q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y)
+ case "S":
+ // Skip S curves because they are shorter and we can split along the connection to the next path instead
+ path1 += fmt.Sprintf("S %s %s %s %s ", pathData[i+1], pathData[i+2], pathData[i+3], pathData[i+4])
+ path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4])
+ default:
+ return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i])
+ }
+
+ pastHalf = true
+ }
+
+ incr, err := getPathStringIncrement(pathData[i])
+
+ if err != nil {
+ return "", "", err
+ }
+
+ increment = incr
+ prevPosition = geo.Point{X: x, Y: y}
+ }
+
+ return path1, path2, nil
+}