2022-11-03 13:54:49 +00:00
|
|
|
// d2svg implements an SVG renderer for d2 diagrams.
|
|
|
|
|
// The input is d2exporter's output
|
|
|
|
|
package d2svg
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
_ "embed"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"hash/fnv"
|
2022-12-24 20:45:12 +00:00
|
|
|
"html"
|
2022-11-03 13:54:49 +00:00
|
|
|
"io"
|
2022-11-29 22:21:23 +00:00
|
|
|
"sort"
|
2022-11-03 13:54:49 +00:00
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"math"
|
|
|
|
|
|
|
|
|
|
"github.com/alecthomas/chroma"
|
|
|
|
|
"github.com/alecthomas/chroma/formatters"
|
|
|
|
|
"github.com/alecthomas/chroma/lexers"
|
|
|
|
|
"github.com/alecthomas/chroma/styles"
|
|
|
|
|
|
2022-12-01 18:48:01 +00:00
|
|
|
"oss.terrastruct.com/util-go/go2"
|
|
|
|
|
|
2022-12-05 20:48:03 +00:00
|
|
|
"oss.terrastruct.com/d2/d2graph"
|
2022-11-03 13:54:49 +00:00
|
|
|
"oss.terrastruct.com/d2/d2renderers/d2fonts"
|
2022-11-27 18:11:14 +00:00
|
|
|
"oss.terrastruct.com/d2/d2renderers/d2latex"
|
2022-12-21 07:43:45 +00:00
|
|
|
"oss.terrastruct.com/d2/d2renderers/d2sketch"
|
2022-11-03 13:54:49 +00:00
|
|
|
"oss.terrastruct.com/d2/d2target"
|
2023-01-09 21:16:22 +00:00
|
|
|
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
|
2022-11-10 19:21:14 +00:00
|
|
|
"oss.terrastruct.com/d2/lib/color"
|
2022-11-03 13:54:49 +00:00
|
|
|
"oss.terrastruct.com/d2/lib/geo"
|
|
|
|
|
"oss.terrastruct.com/d2/lib/label"
|
|
|
|
|
"oss.terrastruct.com/d2/lib/shape"
|
2022-12-21 07:43:45 +00:00
|
|
|
"oss.terrastruct.com/d2/lib/svg"
|
2023-01-09 18:16:28 +00:00
|
|
|
svg_style "oss.terrastruct.com/d2/lib/svg/style"
|
2022-12-01 13:46:45 +00:00
|
|
|
"oss.terrastruct.com/d2/lib/textmeasure"
|
2022-11-03 13:54:49 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2022-12-12 07:31:01 +00:00
|
|
|
DEFAULT_PADDING = 100
|
2022-11-03 13:54:49 +00:00
|
|
|
MIN_ARROWHEAD_STROKE_WIDTH = 2
|
2022-11-10 19:21:14 +00:00
|
|
|
threeDeeOffset = 15
|
2022-12-27 07:56:23 +00:00
|
|
|
|
2022-12-29 00:19:30 +00:00
|
|
|
appendixIconRadius = 16
|
2022-11-03 13:54:49 +00:00
|
|
|
)
|
|
|
|
|
|
2022-11-09 03:40:20 +00:00
|
|
|
var multipleOffset = geo.NewVector(10, -10)
|
|
|
|
|
|
2022-12-27 07:56:23 +00:00
|
|
|
//go:embed tooltip.svg
|
2022-12-28 20:07:01 +00:00
|
|
|
var TooltipIcon string
|
2022-12-27 07:56:23 +00:00
|
|
|
|
2022-12-29 00:19:30 +00:00
|
|
|
//go:embed link.svg
|
|
|
|
|
var LinkIcon string
|
|
|
|
|
|
2022-11-12 18:29:21 +00:00
|
|
|
//go:embed style.css
|
2023-01-09 21:16:22 +00:00
|
|
|
var baseStylesheet string
|
2022-11-12 18:29:21 +00:00
|
|
|
|
2022-12-21 07:43:45 +00:00
|
|
|
//go:embed sketchstyle.css
|
|
|
|
|
var sketchStyleCSS string
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
//go:embed github-markdown.css
|
|
|
|
|
var mdCSS string
|
|
|
|
|
|
2022-12-21 07:43:45 +00:00
|
|
|
type RenderOpts struct {
|
2023-01-09 21:16:22 +00:00
|
|
|
Pad int
|
|
|
|
|
Sketch bool
|
|
|
|
|
ThemeID int64
|
|
|
|
|
DarkThemeID int64
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
func dimensions(writer io.Writer, diagram *d2target.Diagram, pad int) (width, height int, topLeft, bottomRight d2target.Point) {
|
2022-11-03 13:54:49 +00:00
|
|
|
tl, br := diagram.BoundingBox()
|
2022-12-12 07:31:01 +00:00
|
|
|
w := br.X - tl.X + pad*2
|
|
|
|
|
h := br.Y - tl.Y + pad*2
|
2022-11-03 13:54:49 +00:00
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
return w, h, tl, br
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func arrowheadMarkerID(isTarget bool, connection d2target.Connection) string {
|
|
|
|
|
var arrowhead d2target.Arrowhead
|
|
|
|
|
if isTarget {
|
|
|
|
|
arrowhead = connection.DstArrow
|
|
|
|
|
} else {
|
|
|
|
|
arrowhead = connection.SrcArrow
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fmt.Sprintf("mk-%s", hash(fmt.Sprintf("%s,%t,%d,%s",
|
2023-01-09 18:16:28 +00:00
|
|
|
arrowhead, isTarget, connection.StrokeWidth, svg_style.ConnectionTheme(connection),
|
2022-11-03 13:54:49 +00:00
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func arrowheadDimensions(arrowhead d2target.Arrowhead, strokeWidth float64) (width, height float64) {
|
|
|
|
|
var widthMultiplier float64
|
|
|
|
|
var heightMultiplier float64
|
|
|
|
|
switch arrowhead {
|
|
|
|
|
case d2target.ArrowArrowhead:
|
|
|
|
|
widthMultiplier = 5
|
|
|
|
|
heightMultiplier = 5
|
|
|
|
|
case d2target.TriangleArrowhead:
|
|
|
|
|
widthMultiplier = 5
|
|
|
|
|
heightMultiplier = 6
|
|
|
|
|
case d2target.LineArrowhead:
|
|
|
|
|
widthMultiplier = 5
|
|
|
|
|
heightMultiplier = 8
|
|
|
|
|
case d2target.FilledDiamondArrowhead:
|
|
|
|
|
widthMultiplier = 11
|
|
|
|
|
heightMultiplier = 7
|
|
|
|
|
case d2target.DiamondArrowhead:
|
|
|
|
|
widthMultiplier = 11
|
|
|
|
|
heightMultiplier = 9
|
2022-12-31 16:50:40 +00:00
|
|
|
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
|
2022-12-31 10:59:40 +00:00
|
|
|
widthMultiplier = 14
|
|
|
|
|
heightMultiplier = 15
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clippedStrokeWidth := go2.Max(MIN_ARROWHEAD_STROKE_WIDTH, strokeWidth)
|
|
|
|
|
return clippedStrokeWidth * widthMultiplier, clippedStrokeWidth * heightMultiplier
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-06 18:32:56 +00:00
|
|
|
func arrowheadMarker(isTarget bool, id string, bgColor string, connection d2target.Connection) string {
|
2022-11-03 13:54:49 +00:00
|
|
|
arrowhead := connection.DstArrow
|
|
|
|
|
if !isTarget {
|
|
|
|
|
arrowhead = connection.SrcArrow
|
|
|
|
|
}
|
|
|
|
|
strokeWidth := float64(connection.StrokeWidth)
|
|
|
|
|
width, height := arrowheadDimensions(arrowhead, strokeWidth)
|
|
|
|
|
|
|
|
|
|
var path string
|
|
|
|
|
switch arrowhead {
|
|
|
|
|
case d2target.ArrowArrowhead:
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl := svg_style.NewThemableElement("polygon")
|
|
|
|
|
polygonEl.Fill = svg_style.ConnectionTheme(connection)
|
|
|
|
|
polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if isTarget {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., 0.,
|
|
|
|
|
width, height/2,
|
|
|
|
|
0., height,
|
|
|
|
|
width/4, height/2,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., height/2,
|
|
|
|
|
width, 0.,
|
|
|
|
|
width*3/4, height/2,
|
|
|
|
|
width, height,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
path = polygonEl.Render()
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.TriangleArrowhead:
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl := svg_style.NewThemableElement("polygon")
|
|
|
|
|
polygonEl.Fill = svg_style.ConnectionTheme(connection)
|
|
|
|
|
polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if isTarget {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., 0.,
|
|
|
|
|
width, height/2.0,
|
|
|
|
|
0., height,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
width, 0.,
|
|
|
|
|
0., height/2.0,
|
|
|
|
|
width, height,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
path = polygonEl.Render()
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.LineArrowhead:
|
2023-01-09 18:16:28 +00:00
|
|
|
polylineEl := svg_style.NewThemableElement("polyline")
|
|
|
|
|
polylineEl.Fill = color.None
|
|
|
|
|
polylineEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
polylineEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if isTarget {
|
2023-01-09 18:16:28 +00:00
|
|
|
polylineEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
strokeWidth/2, strokeWidth/2,
|
|
|
|
|
width-strokeWidth/2, height/2,
|
|
|
|
|
strokeWidth/2, height-strokeWidth/2,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
polylineEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
width-strokeWidth/2, strokeWidth/2,
|
|
|
|
|
strokeWidth/2, height/2,
|
|
|
|
|
width-strokeWidth/2, height-strokeWidth/2,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
path = polylineEl.Render()
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.FilledDiamondArrowhead:
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl := svg_style.NewThemableElement("polygon")
|
|
|
|
|
polygonEl.Fill = svg_style.ConnectionTheme(connection)
|
|
|
|
|
polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if isTarget {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., height/2.0,
|
|
|
|
|
width/2.0, 0.,
|
|
|
|
|
width, height/2.0,
|
|
|
|
|
width/2.0, height,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., height/2.0,
|
|
|
|
|
width/2.0, 0.,
|
|
|
|
|
width, height/2.0,
|
|
|
|
|
width/2.0, height,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
path = polygonEl.Render()
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.DiamondArrowhead:
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl := svg_style.NewThemableElement("polygon")
|
|
|
|
|
polygonEl.Fill = bgColor
|
|
|
|
|
polygonEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if isTarget {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
0., height/2.0,
|
|
|
|
|
width/2, height/8,
|
|
|
|
|
width, height/2.0,
|
|
|
|
|
width/2.0, height*0.9,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f",
|
2022-11-03 13:54:49 +00:00
|
|
|
width/8, height/2.0,
|
|
|
|
|
width*0.6, height/8,
|
|
|
|
|
width*1.1, height/2.0,
|
|
|
|
|
width*0.6, height*7/8,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
path = polygonEl.Render()
|
2022-12-31 16:50:40 +00:00
|
|
|
case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired:
|
2023-01-01 10:31:45 +00:00
|
|
|
offset := 4.0 + float64(connection.StrokeWidth*2)
|
2023-01-09 18:16:28 +00:00
|
|
|
|
|
|
|
|
var modifierEl *svg_style.ThemableElement
|
2022-12-31 17:56:34 +00:00
|
|
|
if arrowhead == d2target.CfOneRequired || arrowhead == d2target.CfManyRequired {
|
2023-01-09 18:16:28 +00:00
|
|
|
modifierEl := svg_style.NewThemableElement("path")
|
|
|
|
|
modifierEl.D = fmt.Sprintf("M%f,%f %f,%f",
|
2022-12-31 10:59:40 +00:00
|
|
|
offset, 0.,
|
|
|
|
|
offset, height,
|
|
|
|
|
)
|
2023-01-09 18:16:28 +00:00
|
|
|
modifierEl.Fill = bgColor
|
|
|
|
|
modifierEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
modifierEl.Class = "connection"
|
|
|
|
|
modifierEl.Style = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
2022-12-31 10:59:40 +00:00
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
modifierEl := svg_style.NewThemableElement("circle")
|
|
|
|
|
modifierEl.Cx = offset/2.0 + 1.0
|
|
|
|
|
modifierEl.Cy = height / 2.0
|
|
|
|
|
modifierEl.R = offset / 2.0
|
|
|
|
|
modifierEl.Fill = bgColor
|
|
|
|
|
modifierEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
modifierEl.Class = "connection"
|
|
|
|
|
modifierEl.Style = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
2022-12-31 10:59:40 +00:00
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
|
|
|
|
|
childPathEl := svg_style.NewThemableElement("path")
|
2022-12-31 17:56:34 +00:00
|
|
|
if arrowhead == d2target.CfMany || arrowhead == d2target.CfManyRequired {
|
2023-01-09 18:16:28 +00:00
|
|
|
childPathEl.D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f M%f,%f %f,%f",
|
2023-01-01 10:31:45 +00:00
|
|
|
width-3.0, height/2.0,
|
2022-12-31 10:59:40 +00:00
|
|
|
width+offset, height/2.0,
|
|
|
|
|
offset+2.0, height/2.0,
|
|
|
|
|
width+offset, 0.,
|
|
|
|
|
offset+2.0, height/2.0,
|
|
|
|
|
width+offset, height,
|
|
|
|
|
)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
childPathEl.D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f",
|
2023-01-01 10:31:45 +00:00
|
|
|
width-3.0, height/2.0,
|
2022-12-31 10:59:40 +00:00
|
|
|
width+offset, height/2.0,
|
|
|
|
|
offset*1.8, 0.,
|
|
|
|
|
offset*1.8, height,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
|
|
|
|
|
gEl := svg_style.NewThemableElement("g")
|
|
|
|
|
gEl.Fill = bgColor
|
|
|
|
|
gEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
gEl.Class = "connection"
|
|
|
|
|
gEl.Style = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth)
|
|
|
|
|
if !isTarget {
|
|
|
|
|
gEl.Transform = fmt.Sprintf("scale(-1) translate(-%f, -%f)", width, height)
|
|
|
|
|
}
|
|
|
|
|
gEl.Content = fmt.Sprintf("%s%s",
|
|
|
|
|
modifierEl.Render(), childPathEl.Render(),
|
|
|
|
|
)
|
|
|
|
|
path = gEl.Render()
|
2022-11-03 13:54:49 +00:00
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var refX float64
|
|
|
|
|
refY := height / 2
|
|
|
|
|
switch arrowhead {
|
|
|
|
|
case d2target.DiamondArrowhead:
|
|
|
|
|
if isTarget {
|
|
|
|
|
refX = width - 0.6*strokeWidth
|
|
|
|
|
} else {
|
|
|
|
|
refX = width/8 + 0.6*strokeWidth
|
|
|
|
|
}
|
|
|
|
|
width *= 1.1
|
|
|
|
|
default:
|
|
|
|
|
if isTarget {
|
2022-11-09 17:37:28 +00:00
|
|
|
refX = width - 1.5*strokeWidth
|
2022-11-03 13:54:49 +00:00
|
|
|
} else {
|
2022-11-09 17:37:28 +00:00
|
|
|
refX = 1.5 * strokeWidth
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return strings.Join([]string{
|
|
|
|
|
fmt.Sprintf(`<marker id="%s" markerWidth="%f" markerHeight="%f" refX="%f" refY="%f"`,
|
|
|
|
|
id, width, height, refX, refY,
|
|
|
|
|
),
|
|
|
|
|
fmt.Sprintf(`viewBox="%f %f %f %f"`, 0., 0., width, height),
|
|
|
|
|
`orient="auto" markerUnits="userSpaceOnUse">`,
|
|
|
|
|
path,
|
|
|
|
|
"</marker>",
|
|
|
|
|
}, " ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point
|
2022-11-09 17:37:28 +00:00
|
|
|
func arrowheadAdjustment(start, end *geo.Point, arrowhead d2target.Arrowhead, edgeStrokeWidth, shapeStrokeWidth int) *geo.Point {
|
|
|
|
|
distance := (float64(edgeStrokeWidth) + float64(shapeStrokeWidth)) / 2.0
|
2022-11-03 13:54:49 +00:00
|
|
|
if arrowhead != d2target.NoArrowhead {
|
2022-11-09 17:37:28 +00:00
|
|
|
distance += float64(edgeStrokeWidth)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
v := geo.NewVector(end.X-start.X, end.Y-start.Y)
|
|
|
|
|
return v.Unit().Multiply(-distance).ToPoint()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// returns the path's d attribute for the given connection
|
2022-11-09 17:37:28 +00:00
|
|
|
func pathData(connection d2target.Connection, idToShape map[string]d2target.Shape) string {
|
2022-11-03 13:54:49 +00:00
|
|
|
var path []string
|
|
|
|
|
route := connection.Route
|
2022-11-09 17:37:28 +00:00
|
|
|
srcShape := idToShape[connection.Src]
|
|
|
|
|
dstShape := idToShape[connection.Dst]
|
2022-11-03 13:54:49 +00:00
|
|
|
|
2022-11-09 17:37:28 +00:00
|
|
|
sourceAdjustment := arrowheadAdjustment(route[0], route[1], connection.SrcArrow, connection.StrokeWidth, srcShape.StrokeWidth)
|
2022-11-03 13:54:49 +00:00
|
|
|
path = append(path, fmt.Sprintf("M %f %f",
|
|
|
|
|
route[0].X-sourceAdjustment.X,
|
|
|
|
|
route[0].Y-sourceAdjustment.Y,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
if connection.IsCurve {
|
|
|
|
|
i := 1
|
|
|
|
|
for ; i < len(route)-3; i += 3 {
|
|
|
|
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
|
|
|
|
route[i].X, route[i].Y,
|
|
|
|
|
route[i+1].X, route[i+1].Y,
|
|
|
|
|
route[i+2].X, route[i+2].Y,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
// final curve target adjustment
|
2022-11-09 17:37:28 +00:00
|
|
|
targetAdjustment := arrowheadAdjustment(route[i+1], route[i+2], connection.DstArrow, connection.StrokeWidth, dstShape.StrokeWidth)
|
2022-11-03 13:54:49 +00:00
|
|
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
|
|
|
|
route[i].X, route[i].Y,
|
|
|
|
|
route[i+1].X, route[i+1].Y,
|
|
|
|
|
route[i+2].X+targetAdjustment.X,
|
|
|
|
|
route[i+2].Y+targetAdjustment.Y,
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
for i := 1; i < len(route)-1; i++ {
|
|
|
|
|
prevSource := route[i-1]
|
|
|
|
|
prevTarget := route[i]
|
|
|
|
|
currTarget := route[i+1]
|
|
|
|
|
prevVector := prevSource.VectorTo(prevTarget)
|
|
|
|
|
currVector := prevTarget.VectorTo(currTarget)
|
|
|
|
|
|
|
|
|
|
dist := geo.EuclideanDistance(prevTarget.X, prevTarget.Y, currTarget.X, currTarget.Y)
|
|
|
|
|
units := math.Min(10, dist/2)
|
|
|
|
|
|
|
|
|
|
prevTranslations := prevVector.Unit().Multiply(units).ToPoint()
|
|
|
|
|
currTranslations := currVector.Unit().Multiply(units).ToPoint()
|
|
|
|
|
|
|
|
|
|
path = append(path, fmt.Sprintf("L %f %f",
|
|
|
|
|
prevTarget.X-prevTranslations.X,
|
|
|
|
|
prevTarget.Y-prevTranslations.Y,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
// If the segment length is too small, instead of drawing 2 arcs, just skip this segment and bezier curve to the next one
|
|
|
|
|
if units < 10 && i < len(route)-2 {
|
|
|
|
|
nextTarget := route[i+2]
|
|
|
|
|
nextVector := geo.NewVector(nextTarget.X-currTarget.X, nextTarget.Y-currTarget.Y)
|
|
|
|
|
i++
|
|
|
|
|
nextTranslations := nextVector.Unit().Multiply(units).ToPoint()
|
|
|
|
|
|
|
|
|
|
// These 2 bezier control points aren't just at the corner -- they are reflected at the corner, which causes the curve to be ~tangent to the corner,
|
|
|
|
|
// which matches how the two arcs look
|
|
|
|
|
path = append(path, fmt.Sprintf("C %f %f %f %f %f %f",
|
|
|
|
|
// Control point
|
|
|
|
|
prevTarget.X+prevTranslations.X,
|
|
|
|
|
prevTarget.Y+prevTranslations.Y,
|
|
|
|
|
// Control point
|
|
|
|
|
currTarget.X-nextTranslations.X,
|
|
|
|
|
currTarget.Y-nextTranslations.Y,
|
|
|
|
|
// Where curve ends
|
|
|
|
|
currTarget.X+nextTranslations.X,
|
|
|
|
|
currTarget.Y+nextTranslations.Y,
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
path = append(path, fmt.Sprintf("S %f %f %f %f",
|
|
|
|
|
prevTarget.X,
|
|
|
|
|
prevTarget.Y,
|
|
|
|
|
prevTarget.X+currTranslations.X,
|
|
|
|
|
prevTarget.Y+currTranslations.Y,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastPoint := route[len(route)-1]
|
|
|
|
|
secondToLastPoint := route[len(route)-2]
|
|
|
|
|
|
2022-11-09 17:37:28 +00:00
|
|
|
targetAdjustment := arrowheadAdjustment(secondToLastPoint, lastPoint, connection.DstArrow, connection.StrokeWidth, dstShape.StrokeWidth)
|
2022-11-03 13:54:49 +00:00
|
|
|
path = append(path, fmt.Sprintf("L %f %f",
|
|
|
|
|
lastPoint.X+targetAdjustment.X,
|
|
|
|
|
lastPoint.Y+targetAdjustment.Y,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return strings.Join(path, " ")
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-05 19:57:16 +00:00
|
|
|
func makeLabelMask(labelTL *geo.Point, width, height int) string {
|
2022-12-03 06:47:54 +00:00
|
|
|
return fmt.Sprintf(`<rect x="%f" y="%f" width="%d" height="%d" fill="black"></rect>`,
|
|
|
|
|
labelTL.X, labelTL.Y,
|
2022-12-05 19:57:16 +00:00
|
|
|
width,
|
|
|
|
|
height,
|
2022-12-03 06:47:54 +00:00
|
|
|
)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-06 18:32:56 +00:00
|
|
|
func drawConnection(writer io.Writer, bgColor string, fgColor string, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) {
|
2022-12-22 19:06:57 +00:00
|
|
|
fmt.Fprintf(writer, `<g id="%s">`, svg.EscapeText(connection.ID))
|
2022-11-03 13:54:49 +00:00
|
|
|
var markerStart string
|
|
|
|
|
if connection.SrcArrow != d2target.NoArrowhead {
|
|
|
|
|
id := arrowheadMarkerID(false, connection)
|
|
|
|
|
if _, in := markers[id]; !in {
|
2023-01-06 18:32:56 +00:00
|
|
|
marker := arrowheadMarker(false, id, bgColor, connection)
|
2022-11-03 13:54:49 +00:00
|
|
|
if marker == "" {
|
|
|
|
|
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprint(writer, marker)
|
|
|
|
|
markers[id] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
markerStart = fmt.Sprintf(`marker-start="url(#%s)" `, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var markerEnd string
|
|
|
|
|
if connection.DstArrow != d2target.NoArrowhead {
|
|
|
|
|
id := arrowheadMarkerID(true, connection)
|
|
|
|
|
if _, in := markers[id]; !in {
|
2023-01-06 18:32:56 +00:00
|
|
|
marker := arrowheadMarker(true, id, bgColor, connection)
|
2022-11-03 13:54:49 +00:00
|
|
|
if marker == "" {
|
|
|
|
|
panic(fmt.Sprintf("received empty arrow head marker for: %#v", connection))
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprint(writer, marker)
|
|
|
|
|
markers[id] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
markerEnd = fmt.Sprintf(`marker-end="url(#%s)" `, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var labelTL *geo.Point
|
|
|
|
|
if connection.Label != "" {
|
|
|
|
|
labelTL = connection.GetLabelTopLeft()
|
|
|
|
|
labelTL.X = math.Round(labelTL.X)
|
|
|
|
|
labelTL.Y = math.Round(labelTL.Y)
|
|
|
|
|
|
|
|
|
|
if label.Position(connection.LabelPosition).IsOnEdge() {
|
2022-12-05 19:57:16 +00:00
|
|
|
labelMask = makeLabelMask(labelTL, connection.LabelWidth, connection.LabelHeight)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-21 07:43:45 +00:00
|
|
|
path := pathData(connection, idToShape)
|
|
|
|
|
attrs := fmt.Sprintf(`%s%smask="url(#%s)"`,
|
2022-11-03 13:54:49 +00:00
|
|
|
markerStart,
|
|
|
|
|
markerEnd,
|
2022-12-06 06:32:23 +00:00
|
|
|
labelMaskID,
|
2022-11-03 13:54:49 +00:00
|
|
|
)
|
2022-12-21 07:43:45 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Connection(sketchRunner, connection, path, attrs)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-21 07:43:45 +00:00
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
pathEl := svg_style.NewThemableElement("path")
|
|
|
|
|
pathEl.D = path
|
|
|
|
|
pathEl.Fill = color.None
|
|
|
|
|
pathEl.Stroke = svg_style.ConnectionTheme(connection)
|
|
|
|
|
pathEl.Class = "connection"
|
|
|
|
|
pathEl.Style = svg_style.ConnectionStyle(connection)
|
|
|
|
|
pathEl.Attributes = attrs
|
|
|
|
|
fmt.Fprint(writer, pathEl.Render())
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
|
|
|
|
|
if connection.Label != "" {
|
|
|
|
|
fontClass := "text"
|
|
|
|
|
if connection.Bold {
|
|
|
|
|
fontClass += "-bold"
|
|
|
|
|
} else if connection.Italic {
|
|
|
|
|
fontClass += "-italic"
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fontColor := color.N1
|
|
|
|
|
if connection.Color != color.Empty {
|
2022-11-26 00:47:27 +00:00
|
|
|
fontColor = connection.Color
|
|
|
|
|
}
|
2022-12-16 23:44:26 +00:00
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
if connection.Fill != color.Empty {
|
|
|
|
|
rectEl := svg_style.NewThemableElement("rect")
|
|
|
|
|
rectEl.X, rectEl.Y = labelTL.X, labelTL.Y
|
|
|
|
|
rectEl.Width, rectEl.Height = float64(connection.LabelWidth), float64(connection.LabelHeight)
|
|
|
|
|
rectEl.Fill = connection.Fill
|
|
|
|
|
fmt.Fprint(writer, rectEl.Render())
|
2022-12-16 23:44:26 +00:00
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
|
|
|
|
|
textEl := svg_style.NewThemableElement("text")
|
|
|
|
|
textEl.X = labelTL.X + float64(connection.LabelWidth)/2
|
|
|
|
|
textEl.Y = labelTL.Y + float64(connection.FontSize)
|
|
|
|
|
textEl.Fill = fontColor
|
|
|
|
|
textEl.Class = fontClass
|
|
|
|
|
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", connection.FontSize)
|
|
|
|
|
textEl.Content = RenderText(connection.Label, textEl.X, float64(connection.LabelHeight))
|
|
|
|
|
fmt.Fprint(writer, textEl.Render())
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
2022-11-24 04:14:46 +00:00
|
|
|
|
|
|
|
|
length := geo.Route(connection.Route).Length()
|
|
|
|
|
if connection.SrcLabel != "" {
|
2022-11-24 04:40:24 +00:00
|
|
|
// TODO use arrowhead label dimensions https://github.com/terrastruct/d2/issues/183
|
2022-11-24 04:14:46 +00:00
|
|
|
size := float64(connection.FontSize)
|
|
|
|
|
position := 0.
|
|
|
|
|
if length > 0 {
|
|
|
|
|
position = size / length
|
|
|
|
|
}
|
2023-01-06 18:32:56 +00:00
|
|
|
fmt.Fprint(writer, renderArrowheadLabel(fgColor, connection, connection.SrcLabel, position, size, size))
|
2022-11-24 04:14:46 +00:00
|
|
|
}
|
|
|
|
|
if connection.DstLabel != "" {
|
2022-11-24 04:40:24 +00:00
|
|
|
// TODO use arrowhead label dimensions https://github.com/terrastruct/d2/issues/183
|
2022-11-24 04:14:46 +00:00
|
|
|
size := float64(connection.FontSize)
|
|
|
|
|
position := 1.
|
|
|
|
|
if length > 0 {
|
|
|
|
|
position -= size / length
|
|
|
|
|
}
|
2023-01-06 18:32:56 +00:00
|
|
|
fmt.Fprint(writer, renderArrowheadLabel(fgColor, connection, connection.DstLabel, position, size, size))
|
2022-11-24 04:14:46 +00:00
|
|
|
}
|
2022-11-26 03:31:54 +00:00
|
|
|
fmt.Fprintf(writer, `</g>`)
|
2022-12-03 06:47:54 +00:00
|
|
|
return
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-06 18:32:56 +00:00
|
|
|
func renderArrowheadLabel(fgColor string, connection d2target.Connection, text string, position, width, height float64) string {
|
2022-11-24 04:40:24 +00:00
|
|
|
labelTL := label.UnlockedTop.GetPointOnRoute(connection.Route, float64(connection.StrokeWidth), position, width, height)
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
textEl := svg_style.NewThemableElement("text")
|
|
|
|
|
textEl.X = labelTL.X + width/2
|
|
|
|
|
textEl.Y = labelTL.Y + float64(connection.FontSize)
|
|
|
|
|
textEl.Fill = fgColor
|
|
|
|
|
textEl.Class = "text-italic"
|
|
|
|
|
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", connection.FontSize)
|
|
|
|
|
textEl.Content = RenderText(text, textEl.X, height)
|
|
|
|
|
return textEl.Render()
|
2022-11-24 04:40:24 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
func renderOval(tl *geo.Point, width, height float64, fill, stroke, style string) string {
|
|
|
|
|
el := svg_style.NewThemableElement("ellipse")
|
|
|
|
|
el.Rx = width / 2
|
|
|
|
|
el.Ry = height / 2
|
|
|
|
|
el.Cx = tl.X + el.Rx
|
|
|
|
|
el.Cy = tl.Y + el.Ry
|
|
|
|
|
el.Class = "shape"
|
|
|
|
|
el.Style = style
|
|
|
|
|
return el.Render()
|
2022-11-09 03:40:20 +00:00
|
|
|
}
|
|
|
|
|
|
2022-11-09 19:10:51 +00:00
|
|
|
func defineShadowFilter(writer io.Writer) {
|
|
|
|
|
fmt.Fprint(writer, `<defs>
|
|
|
|
|
<filter id="shadow-filter" width="200%" height="200%" x="-50%" y="-50%">
|
|
|
|
|
<feGaussianBlur stdDeviation="1.7 " in="SourceGraphic"></feGaussianBlur>
|
2022-11-09 19:14:31 +00:00
|
|
|
<feFlood flood-color="#3d4574" flood-opacity="0.4" result="ShadowFeFlood" in="SourceGraphic"></feFlood>
|
|
|
|
|
<feComposite in="ShadowFeFlood" in2="SourceAlpha" operator="in" result="ShadowFeComposite"></feComposite>
|
|
|
|
|
<feOffset dx="3" dy="5" result="ShadowFeOffset" in="ShadowFeComposite"></feOffset>
|
|
|
|
|
<feBlend in="SourceGraphic" in2="ShadowFeOffset" mode="normal" result="ShadowFeBlend"></feBlend>
|
2022-11-09 19:10:51 +00:00
|
|
|
</filter>
|
|
|
|
|
</defs>`)
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-28 22:04:45 +00:00
|
|
|
func render3dRect(targetShape d2target.Shape) string {
|
|
|
|
|
moveTo := func(p d2target.Point) string {
|
2022-11-30 00:39:38 +00:00
|
|
|
return fmt.Sprintf("M%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y)
|
2022-11-28 22:04:45 +00:00
|
|
|
}
|
|
|
|
|
lineTo := func(p d2target.Point) string {
|
2022-11-30 00:39:38 +00:00
|
|
|
return fmt.Sprintf("L%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y)
|
2022-11-28 22:04:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// draw border all in one path to prevent overlapping sections
|
|
|
|
|
var borderSegments []string
|
|
|
|
|
borderSegments = append(borderSegments,
|
|
|
|
|
moveTo(d2target.Point{X: 0, Y: 0}),
|
|
|
|
|
)
|
|
|
|
|
for _, v := range []d2target.Point{
|
|
|
|
|
{X: threeDeeOffset, Y: -threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width + threeDeeOffset, Y: -threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width + threeDeeOffset, Y: targetShape.Height - threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width, Y: targetShape.Height},
|
|
|
|
|
{X: 0, Y: targetShape.Height},
|
|
|
|
|
{X: 0, Y: 0},
|
|
|
|
|
{X: targetShape.Width, Y: 0},
|
|
|
|
|
{X: targetShape.Width, Y: targetShape.Height},
|
|
|
|
|
} {
|
|
|
|
|
borderSegments = append(borderSegments, lineTo(v))
|
|
|
|
|
}
|
|
|
|
|
// move to top right to draw last segment without overlapping
|
|
|
|
|
borderSegments = append(borderSegments,
|
|
|
|
|
moveTo(d2target.Point{X: targetShape.Width, Y: 0}),
|
|
|
|
|
)
|
|
|
|
|
borderSegments = append(borderSegments,
|
|
|
|
|
lineTo(d2target.Point{X: targetShape.Width + threeDeeOffset, Y: -threeDeeOffset}),
|
|
|
|
|
)
|
2023-01-09 18:16:28 +00:00
|
|
|
border := svg_style.NewThemableElement("path")
|
|
|
|
|
border.D = strings.Join(borderSegments, " ")
|
2023-01-09 21:50:17 +00:00
|
|
|
border.Fill = color.None
|
2023-01-09 18:16:28 +00:00
|
|
|
_, borderStroke := svg_style.ShapeTheme(targetShape)
|
|
|
|
|
border.Stroke = borderStroke
|
|
|
|
|
borderStyle := svg_style.ShapeStyle(targetShape)
|
|
|
|
|
border.Style = borderStyle
|
|
|
|
|
renderedBorder := border.Render()
|
2022-11-28 22:04:45 +00:00
|
|
|
|
|
|
|
|
// create mask from border stroke, to cut away from the shape fills
|
2022-12-22 19:06:57 +00:00
|
|
|
maskID := fmt.Sprintf("border-mask-%v", svg.EscapeText(targetShape.ID))
|
2022-11-28 22:04:45 +00:00
|
|
|
borderMask := strings.Join([]string{
|
|
|
|
|
fmt.Sprintf(`<defs><mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
|
|
|
|
|
maskID, targetShape.Pos.X, targetShape.Pos.Y-threeDeeOffset, targetShape.Width+threeDeeOffset, targetShape.Height+threeDeeOffset,
|
|
|
|
|
),
|
|
|
|
|
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
|
|
|
|
|
targetShape.Pos.X, targetShape.Pos.Y-threeDeeOffset, targetShape.Width+threeDeeOffset, targetShape.Height+threeDeeOffset,
|
|
|
|
|
),
|
|
|
|
|
fmt.Sprintf(`<path d="%s" style="%s;stroke:#000;fill:none;opacity:1;"/></mask></defs>`,
|
|
|
|
|
strings.Join(borderSegments, ""), borderStyle),
|
|
|
|
|
}, "\n")
|
|
|
|
|
|
|
|
|
|
// render the main rectangle without stroke and the border mask
|
2023-01-09 18:16:28 +00:00
|
|
|
mainShape := svg_style.NewThemableElement("rect")
|
|
|
|
|
mainShape.X = float64(targetShape.Pos.X)
|
|
|
|
|
mainShape.Y = float64(targetShape.Pos.Y)
|
|
|
|
|
mainShape.Width = float64(targetShape.Width)
|
|
|
|
|
mainShape.Height = float64(targetShape.Height)
|
|
|
|
|
mainShape.Mask = fmt.Sprintf("url(#%s)", maskID)
|
|
|
|
|
mainShapeFill, _ := svg_style.ShapeTheme(targetShape)
|
|
|
|
|
mainShape.Fill = mainShapeFill
|
2023-01-09 21:50:17 +00:00
|
|
|
mainShape.Stroke = color.None
|
2023-01-09 18:16:28 +00:00
|
|
|
mainShape.Style = svg_style.ShapeStyle(targetShape)
|
|
|
|
|
mainShapeRendered := mainShape.Render()
|
2022-11-28 22:04:45 +00:00
|
|
|
|
|
|
|
|
// render the side shapes in the darkened color without stroke and the border mask
|
|
|
|
|
var sidePoints []string
|
|
|
|
|
for _, v := range []d2target.Point{
|
|
|
|
|
{X: 0, Y: 0},
|
|
|
|
|
{X: threeDeeOffset, Y: -threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width + threeDeeOffset, Y: -threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width + threeDeeOffset, Y: targetShape.Height - threeDeeOffset},
|
|
|
|
|
{X: targetShape.Width, Y: targetShape.Height},
|
|
|
|
|
{X: targetShape.Width, Y: 0},
|
|
|
|
|
} {
|
|
|
|
|
sidePoints = append(sidePoints,
|
|
|
|
|
fmt.Sprintf("%d,%d", v.X+targetShape.Pos.X, v.Y+targetShape.Pos.Y),
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 21:50:17 +00:00
|
|
|
// TODO make darker color part of the theme? or just keep this bypass
|
|
|
|
|
darkerColor, err := color.Darken(targetShape.Fill)
|
|
|
|
|
if err != nil {
|
|
|
|
|
darkerColor = targetShape.Fill
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
sideShape := svg_style.NewThemableElement("polygon")
|
2022-11-28 22:04:45 +00:00
|
|
|
sideShape.Fill = darkerColor
|
2023-01-09 18:16:28 +00:00
|
|
|
sideShape.Points = strings.Join(sidePoints, " ")
|
|
|
|
|
sideShape.Mask = fmt.Sprintf("url(#%s)", maskID)
|
|
|
|
|
sideShape.Style = svg_style.ShapeStyle(targetShape)
|
2023-01-09 21:50:17 +00:00
|
|
|
renderedSides := sideShape.Render()
|
2022-11-28 22:04:45 +00:00
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
return borderMask + mainShapeRendered + renderedSides + renderedBorder
|
2022-11-28 22:04:45 +00:00
|
|
|
}
|
|
|
|
|
|
2022-12-21 07:43:45 +00:00
|
|
|
func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, err error) {
|
2022-12-29 00:19:30 +00:00
|
|
|
closingTag := "</g>"
|
|
|
|
|
if targetShape.Link != "" {
|
2022-12-29 00:41:15 +00:00
|
|
|
fmt.Fprintf(writer, `<a href="%s" xlink:href="%[1]s">`, targetShape.Link)
|
2022-12-29 00:19:30 +00:00
|
|
|
closingTag += "</a>"
|
|
|
|
|
}
|
2022-12-22 19:06:57 +00:00
|
|
|
fmt.Fprintf(writer, `<g id="%s">`, svg.EscapeText(targetShape.ID))
|
2022-11-03 13:54:49 +00:00
|
|
|
tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y))
|
|
|
|
|
width := float64(targetShape.Width)
|
|
|
|
|
height := float64(targetShape.Height)
|
2023-01-09 18:16:28 +00:00
|
|
|
fill, stroke := svg_style.ShapeTheme(targetShape)
|
|
|
|
|
style := svg_style.ShapeStyle(targetShape)
|
2022-11-03 13:54:49 +00:00
|
|
|
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
|
|
|
|
|
|
|
|
|
|
s := shape.NewShape(shapeType, geo.NewBox(tl, width, height))
|
|
|
|
|
|
2022-11-10 03:24:27 +00:00
|
|
|
var shadowAttr string
|
2022-11-10 19:21:14 +00:00
|
|
|
if targetShape.Shadow {
|
2022-11-10 03:24:27 +00:00
|
|
|
switch targetShape.Type {
|
|
|
|
|
case d2target.ShapeText,
|
|
|
|
|
d2target.ShapeCode,
|
|
|
|
|
d2target.ShapeClass,
|
|
|
|
|
d2target.ShapeSQLTable:
|
|
|
|
|
default:
|
|
|
|
|
shadowAttr = `filter="url(#shadow-filter)" `
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-05 19:22:16 +00:00
|
|
|
var blendModeClass string
|
|
|
|
|
if targetShape.Blend {
|
|
|
|
|
blendModeClass = " blend"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(writer, `<g class="shape%s" %s>`, blendModeClass, shadowAttr)
|
2022-11-10 03:24:27 +00:00
|
|
|
|
2022-11-09 03:40:20 +00:00
|
|
|
var multipleTL *geo.Point
|
|
|
|
|
if targetShape.Multiple {
|
|
|
|
|
multipleTL = tl.AddVector(multipleOffset)
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
switch targetShape.Type {
|
|
|
|
|
case d2target.ShapeClass:
|
2022-12-22 19:32:41 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Class(sketchRunner, targetShape)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-22 19:32:41 +00:00
|
|
|
} else {
|
|
|
|
|
drawClass(writer, targetShape)
|
|
|
|
|
}
|
2022-12-29 00:19:30 +00:00
|
|
|
fmt.Fprintf(writer, `</g>`)
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, closingTag)
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, nil
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.ShapeSQLTable:
|
2022-12-22 19:06:57 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Table(sketchRunner, targetShape)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-22 19:06:57 +00:00
|
|
|
} else {
|
|
|
|
|
drawTable(writer, targetShape)
|
|
|
|
|
}
|
2022-12-29 00:19:30 +00:00
|
|
|
fmt.Fprintf(writer, `</g>`)
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, closingTag)
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, nil
|
2022-11-03 13:54:49 +00:00
|
|
|
case d2target.ShapeOval:
|
2022-11-09 03:40:20 +00:00
|
|
|
if targetShape.Multiple {
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, renderOval(multipleTL, width, height, fill, stroke, style))
|
2022-11-09 03:40:20 +00:00
|
|
|
}
|
2022-12-21 07:43:45 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Oval(sketchRunner, targetShape)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-21 07:43:45 +00:00
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, renderOval(tl, width, height, fill, stroke, style))
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
|
|
|
|
|
case d2target.ShapeImage:
|
2023-01-09 18:16:28 +00:00
|
|
|
el := svg_style.NewThemableElement("image")
|
|
|
|
|
el.X = float64(targetShape.Pos.X)
|
|
|
|
|
el.Y = float64(targetShape.Pos.Y)
|
|
|
|
|
el.Width = float64(targetShape.Width)
|
|
|
|
|
el.Height = float64(targetShape.Height)
|
|
|
|
|
el.Href = html.EscapeString(targetShape.Icon.String())
|
|
|
|
|
el.Fill = fill
|
|
|
|
|
el.Stroke = stroke
|
|
|
|
|
el.Style = style
|
|
|
|
|
fmt.Fprint(writer, el.Render())
|
2022-11-10 03:24:27 +00:00
|
|
|
|
|
|
|
|
// TODO should standardize "" to rectangle
|
2022-12-05 21:53:09 +00:00
|
|
|
case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, "":
|
2022-11-10 19:21:14 +00:00
|
|
|
if targetShape.ThreeDee {
|
2022-11-28 22:04:45 +00:00
|
|
|
fmt.Fprint(writer, render3dRect(targetShape))
|
|
|
|
|
} else {
|
|
|
|
|
if targetShape.Multiple {
|
2023-01-09 18:16:28 +00:00
|
|
|
el := svg_style.NewThemableElement("rect")
|
|
|
|
|
el.X = float64(targetShape.Pos.X + 10)
|
|
|
|
|
el.Y = float64(targetShape.Pos.Y - 10)
|
|
|
|
|
el.Width = float64(targetShape.Width)
|
|
|
|
|
el.Height = float64(targetShape.Height)
|
|
|
|
|
el.Fill = fill
|
|
|
|
|
el.Stroke = stroke
|
|
|
|
|
el.Style = style
|
|
|
|
|
fmt.Fprint(writer, el.Render())
|
2022-11-10 19:21:14 +00:00
|
|
|
}
|
2022-12-21 07:43:45 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Rect(sketchRunner, targetShape)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-21 07:43:45 +00:00
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
el := svg_style.NewThemableElement("rect")
|
|
|
|
|
el.X = float64(targetShape.Pos.X)
|
|
|
|
|
el.Y = float64(targetShape.Pos.Y)
|
|
|
|
|
el.Width = float64(targetShape.Width)
|
|
|
|
|
el.Height = float64(targetShape.Height)
|
|
|
|
|
el.Fill = fill
|
|
|
|
|
el.Stroke = stroke
|
|
|
|
|
el.Style = style
|
|
|
|
|
fmt.Fprint(writer, el.Render())
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
2022-11-09 03:40:20 +00:00
|
|
|
}
|
2022-11-10 03:24:27 +00:00
|
|
|
case d2target.ShapeText, d2target.ShapeCode:
|
2022-11-03 13:54:49 +00:00
|
|
|
default:
|
2022-11-09 03:40:20 +00:00
|
|
|
if targetShape.Multiple {
|
|
|
|
|
multiplePathData := shape.NewShape(shapeType, geo.NewBox(multipleTL, width, height)).GetSVGPathData()
|
2023-01-09 18:16:28 +00:00
|
|
|
el := svg_style.NewThemableElement("path")
|
|
|
|
|
el.Fill = fill
|
|
|
|
|
el.Stroke = stroke
|
|
|
|
|
el.Style = style
|
2022-11-09 03:40:20 +00:00
|
|
|
for _, pathData := range multiplePathData {
|
2023-01-09 18:16:28 +00:00
|
|
|
el.D = pathData
|
|
|
|
|
fmt.Fprint(writer, el.Render())
|
2022-11-09 03:40:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
2022-11-09 19:45:26 +00:00
|
|
|
|
2022-12-21 07:43:45 +00:00
|
|
|
if sketchRunner != nil {
|
|
|
|
|
out, err := d2sketch.Paths(sketchRunner, targetShape, s.GetSVGPathData())
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, out)
|
2022-12-21 07:43:45 +00:00
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
el := svg_style.NewThemableElement("path")
|
|
|
|
|
el.Fill = fill
|
|
|
|
|
el.Stroke = stroke
|
|
|
|
|
el.Style = style
|
2022-12-21 07:43:45 +00:00
|
|
|
for _, pathData := range s.GetSVGPathData() {
|
2023-01-09 18:16:28 +00:00
|
|
|
el.D = pathData
|
|
|
|
|
fmt.Fprint(writer, el.Render())
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-29 00:19:30 +00:00
|
|
|
// Closes the class=shape
|
2022-11-10 03:24:27 +00:00
|
|
|
fmt.Fprintf(writer, `</g>`)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage {
|
|
|
|
|
iconPosition := label.Position(targetShape.IconPosition)
|
|
|
|
|
var box *geo.Box
|
|
|
|
|
if iconPosition.IsOutside() {
|
|
|
|
|
box = s.GetBox()
|
|
|
|
|
} else {
|
|
|
|
|
box = s.GetInnerBox()
|
|
|
|
|
}
|
|
|
|
|
iconSize := targetShape.GetIconSize(box)
|
|
|
|
|
|
|
|
|
|
tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize))
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(writer, `<image href="%s" x="%f" y="%f" width="%d" height="%d" />`,
|
2022-12-24 20:45:12 +00:00
|
|
|
html.EscapeString(targetShape.Icon.String()),
|
2022-11-03 13:54:49 +00:00
|
|
|
tl.X,
|
|
|
|
|
tl.Y,
|
|
|
|
|
iconSize,
|
|
|
|
|
iconSize,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if targetShape.Label != "" {
|
|
|
|
|
labelPosition := label.Position(targetShape.LabelPosition)
|
|
|
|
|
var box *geo.Box
|
|
|
|
|
if labelPosition.IsOutside() {
|
|
|
|
|
box = s.GetBox()
|
|
|
|
|
} else {
|
|
|
|
|
box = s.GetInnerBox()
|
|
|
|
|
}
|
|
|
|
|
labelTL := labelPosition.GetPointOnBox(box, label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
|
|
|
|
|
|
|
|
|
|
fontClass := "text"
|
|
|
|
|
if targetShape.Bold {
|
|
|
|
|
fontClass += "-bold"
|
|
|
|
|
} else if targetShape.Italic {
|
|
|
|
|
fontClass += "-italic"
|
|
|
|
|
}
|
2022-12-26 05:49:26 +00:00
|
|
|
if targetShape.Underline {
|
|
|
|
|
fontClass += " text-underline"
|
|
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
|
2022-12-26 05:49:26 +00:00
|
|
|
if targetShape.Type == d2target.ShapeCode {
|
2022-11-27 21:54:41 +00:00
|
|
|
lexer := lexers.Get(targetShape.Language)
|
|
|
|
|
if lexer == nil {
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, fmt.Errorf("code snippet lexer for %s not found", targetShape.Language)
|
2022-11-27 21:54:41 +00:00
|
|
|
}
|
|
|
|
|
style := styles.Get("github")
|
|
|
|
|
if style == nil {
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, errors.New(`code snippet style "github" not found`)
|
2022-11-27 21:54:41 +00:00
|
|
|
}
|
|
|
|
|
formatter := formatters.Get("svg")
|
|
|
|
|
if formatter == nil {
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, errors.New(`code snippet formatter "svg" not found`)
|
2022-11-27 21:54:41 +00:00
|
|
|
}
|
|
|
|
|
iterator, err := lexer.Tokenise(nil, targetShape.Label)
|
|
|
|
|
if err != nil {
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, err
|
2022-11-27 21:54:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
svgStyles := styleToSVG(style)
|
|
|
|
|
fmt.Fprintf(writer, `<g transform="translate(%f %f)" style="opacity:%f">`, box.TopLeft.X, box.TopLeft.Y, targetShape.Opacity)
|
2023-01-09 18:16:28 +00:00
|
|
|
rectEl := svg_style.NewThemableElement("rect")
|
|
|
|
|
rectEl.Width = float64(targetShape.Width)
|
|
|
|
|
rectEl.Height = float64(targetShape.Height)
|
|
|
|
|
rectEl.Stroke = targetShape.Stroke
|
|
|
|
|
rectEl.Class = "shape"
|
|
|
|
|
rectEl.Style = fmt.Sprintf(`fill:%s`, style.Get(chroma.Background).Background.String())
|
|
|
|
|
fmt.Fprint(writer, rectEl.Render())
|
2022-11-27 21:54:41 +00:00
|
|
|
// Padding
|
|
|
|
|
fmt.Fprintf(writer, `<g transform="translate(6 6)">`)
|
|
|
|
|
|
|
|
|
|
for index, tokens := range chroma.SplitTokensIntoLines(iterator.Tokens()) {
|
|
|
|
|
// TODO mono font looks better with 1.2 em (use px equivalent), but textmeasure needs to account for it. Not obvious how that should be done
|
|
|
|
|
fmt.Fprintf(writer, "<text class=\"text-mono\" x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1*float64(index+1))
|
|
|
|
|
for _, token := range tokens {
|
|
|
|
|
text := svgEscaper.Replace(token.String())
|
|
|
|
|
attr := styleAttr(svgStyles, token.Type)
|
|
|
|
|
if attr != "" {
|
|
|
|
|
text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprint(writer, text)
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprint(writer, "</text>")
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(writer, "</g></g>")
|
2022-12-26 05:49:26 +00:00
|
|
|
} else if targetShape.Type == d2target.ShapeText && targetShape.Language == "latex" {
|
|
|
|
|
render, err := d2latex.Render(targetShape.Label)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return labelMask, err
|
|
|
|
|
}
|
2023-01-09 23:17:56 +00:00
|
|
|
gEl := svg_style.NewThemableElement("g")
|
|
|
|
|
gEl.Transform = fmt.Sprintf("translate(%f %f)", box.TopLeft.X, box.TopLeft.Y)
|
|
|
|
|
gEl.Color = targetShape.Stroke
|
|
|
|
|
gEl.Style = fmt.Sprintf("opacity:%f", targetShape.Opacity)
|
|
|
|
|
gEl.Content = render
|
|
|
|
|
fmt.Fprint(writer, gEl.Render())
|
2022-12-26 05:49:26 +00:00
|
|
|
} else if targetShape.Type == d2target.ShapeText && targetShape.Language != "" {
|
|
|
|
|
render, err := textmeasure.RenderMarkdown(targetShape.Label)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return labelMask, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(writer, `<g><foreignObject requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" x="%f" y="%f" width="%d" height="%d">`,
|
|
|
|
|
box.TopLeft.X, box.TopLeft.Y, targetShape.Width, targetShape.Height,
|
|
|
|
|
)
|
|
|
|
|
// we need the self closing form in this svg/xhtml context
|
|
|
|
|
render = strings.ReplaceAll(render, "<hr>", "<hr />")
|
2022-12-18 05:22:33 +00:00
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
mdEl := svg_style.NewThemableElement("div")
|
|
|
|
|
mdEl.Xmlns = "http://www.w3.org/1999/xhtml"
|
|
|
|
|
mdEl.Class = "md"
|
|
|
|
|
mdEl.Content = render
|
|
|
|
|
fmt.Fprint(writer, mdEl.Render())
|
2022-12-26 05:49:26 +00:00
|
|
|
fmt.Fprint(writer, `</foreignObject></g>`)
|
|
|
|
|
} else {
|
2023-01-09 18:16:28 +00:00
|
|
|
fontColor := color.N1
|
|
|
|
|
if targetShape.Color != color.Empty {
|
2022-11-26 00:47:27 +00:00
|
|
|
fontColor = targetShape.Color
|
|
|
|
|
}
|
2023-01-09 18:16:28 +00:00
|
|
|
textEl := svg_style.NewThemableElement("text")
|
|
|
|
|
textEl.X = labelTL.X + float64(targetShape.LabelWidth)/2
|
2022-11-03 13:54:49 +00:00
|
|
|
// text is vertically positioned at its baseline which is at labelTL+FontSize
|
2023-01-09 18:16:28 +00:00
|
|
|
textEl.Y = labelTL.Y + float64(targetShape.FontSize)
|
|
|
|
|
textEl.Fill = fontColor
|
|
|
|
|
textEl.Class = fontClass
|
|
|
|
|
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", targetShape.FontSize)
|
|
|
|
|
textEl.Content = RenderText(targetShape.Label, textEl.X, float64(targetShape.LabelHeight))
|
|
|
|
|
fmt.Fprint(writer, textEl.Render())
|
2022-12-05 23:53:43 +00:00
|
|
|
if targetShape.Blend {
|
2022-12-05 21:32:33 +00:00
|
|
|
labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight-d2graph.INNER_LABEL_PADDING)
|
|
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
2022-12-27 07:56:23 +00:00
|
|
|
|
2022-12-29 00:19:30 +00:00
|
|
|
rightPadForTooltip := 0
|
2022-12-27 07:56:23 +00:00
|
|
|
if targetShape.Tooltip != "" {
|
2022-12-29 00:19:30 +00:00
|
|
|
rightPadForTooltip = 2 * appendixIconRadius
|
|
|
|
|
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
|
|
|
|
|
targetShape.Pos.X+targetShape.Width-appendixIconRadius,
|
|
|
|
|
targetShape.Pos.Y-appendixIconRadius,
|
2022-12-28 20:07:01 +00:00
|
|
|
TooltipIcon,
|
2022-12-27 07:56:23 +00:00
|
|
|
)
|
2022-12-27 08:20:49 +00:00
|
|
|
fmt.Fprintf(writer, `<title>%s</title>`, targetShape.Tooltip)
|
2022-12-27 07:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
2022-12-29 00:19:30 +00:00
|
|
|
if targetShape.Link != "" {
|
|
|
|
|
fmt.Fprintf(writer, `<g transform="translate(%d %d)" class="appendix-icon">%s</g>`,
|
|
|
|
|
targetShape.Pos.X+targetShape.Width-appendixIconRadius-rightPadForTooltip,
|
|
|
|
|
targetShape.Pos.Y-appendixIconRadius,
|
|
|
|
|
LinkIcon,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
fmt.Fprint(writer, closingTag)
|
2022-12-05 19:57:16 +00:00
|
|
|
return labelMask, nil
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2022-12-28 04:29:51 +00:00
|
|
|
func RenderText(text string, x, height float64) string {
|
2022-11-03 13:54:49 +00:00
|
|
|
if !strings.Contains(text, "\n") {
|
2022-12-22 19:06:57 +00:00
|
|
|
return svg.EscapeText(text)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
rendered := []string{}
|
|
|
|
|
lines := strings.Split(text, "\n")
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
dy := height / float64(len(lines))
|
|
|
|
|
if i == 0 {
|
|
|
|
|
dy = 0
|
|
|
|
|
}
|
2022-12-22 19:06:57 +00:00
|
|
|
escaped := svg.EscapeText(line)
|
2022-11-03 13:54:49 +00:00
|
|
|
if escaped == "" {
|
|
|
|
|
// if there are multiple newlines in a row we still need text for the tspan to render
|
|
|
|
|
escaped = " "
|
|
|
|
|
}
|
|
|
|
|
rendered = append(rendered, fmt.Sprintf(`<tspan x="%f" dy="%f">%s</tspan>`, x, dy, escaped))
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(rendered, "")
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
func embedFonts(buf *bytes.Buffer, fontFamily *d2fonts.FontFamily) string {
|
2022-11-03 13:54:49 +00:00
|
|
|
content := buf.String()
|
2023-01-09 18:16:28 +00:00
|
|
|
out := `<style type="text/css"><![CDATA[`
|
2022-11-03 13:54:49 +00:00
|
|
|
|
|
|
|
|
triggers := []string{
|
|
|
|
|
`class="text"`,
|
2022-12-26 05:49:26 +00:00
|
|
|
`class="text `,
|
2022-11-03 13:54:49 +00:00
|
|
|
`class="md"`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += fmt.Sprintf(`
|
2022-11-03 13:54:49 +00:00
|
|
|
.text {
|
|
|
|
|
font-family: "font-regular";
|
|
|
|
|
}
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: font-regular;
|
|
|
|
|
src: url("%s");
|
|
|
|
|
}`,
|
2022-12-21 07:43:45 +00:00
|
|
|
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_REGULAR)])
|
2022-11-03 13:54:49 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-26 05:49:26 +00:00
|
|
|
triggers = []string{
|
|
|
|
|
`text-underline`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += `
|
2022-12-26 05:49:26 +00:00
|
|
|
.text-underline {
|
|
|
|
|
text-decoration: underline;
|
2023-01-09 18:16:28 +00:00
|
|
|
}`
|
2022-12-26 05:49:26 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-27 07:56:23 +00:00
|
|
|
triggers = []string{
|
2022-12-29 00:19:30 +00:00
|
|
|
`appendix-icon`,
|
2022-12-27 07:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += `
|
2022-12-29 00:19:30 +00:00
|
|
|
.appendix-icon {
|
2022-12-28 23:15:38 +00:00
|
|
|
filter: drop-shadow(0px 0px 32px rgba(31, 36, 58, 0.1));
|
2023-01-09 18:16:28 +00:00
|
|
|
}`
|
2022-12-27 07:56:23 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
triggers = []string{
|
2023-01-09 18:16:28 +00:00
|
|
|
`class="text-bold`,
|
2022-11-03 13:54:49 +00:00
|
|
|
`<b>`,
|
|
|
|
|
`<strong>`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += fmt.Sprintf(`
|
2022-11-03 13:54:49 +00:00
|
|
|
.text-bold {
|
|
|
|
|
font-family: "font-bold";
|
|
|
|
|
}
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: font-bold;
|
|
|
|
|
src: url("%s");
|
|
|
|
|
}`,
|
2022-12-21 07:43:45 +00:00
|
|
|
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_BOLD)])
|
2022-11-03 13:54:49 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
triggers = []string{
|
2023-01-09 18:16:28 +00:00
|
|
|
`class="text-italic`,
|
2022-11-03 13:54:49 +00:00
|
|
|
`<em>`,
|
|
|
|
|
`<dfn>`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += fmt.Sprintf(`
|
2022-11-03 13:54:49 +00:00
|
|
|
.text-italic {
|
|
|
|
|
font-family: "font-italic";
|
|
|
|
|
}
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: font-italic;
|
|
|
|
|
src: url("%s");
|
|
|
|
|
}`,
|
2022-12-21 07:43:45 +00:00
|
|
|
d2fonts.FontEncodings[fontFamily.Font(0, d2fonts.FONT_STYLE_ITALIC)])
|
2022-11-03 13:54:49 +00:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-11-07 19:52:01 +00:00
|
|
|
|
|
|
|
|
triggers = []string{
|
2023-01-09 18:16:28 +00:00
|
|
|
`class="text-mono`,
|
2022-11-07 19:52:01 +00:00
|
|
|
`<pre>`,
|
|
|
|
|
`<code>`,
|
|
|
|
|
`<kbd>`,
|
|
|
|
|
`<samp>`,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, t := range triggers {
|
|
|
|
|
if strings.Contains(content, t) {
|
2023-01-09 18:16:28 +00:00
|
|
|
out += fmt.Sprintf(`
|
2022-11-03 13:54:49 +00:00
|
|
|
.text-mono {
|
|
|
|
|
font-family: "font-mono";
|
|
|
|
|
}
|
|
|
|
|
@font-face {
|
|
|
|
|
font-family: font-mono;
|
|
|
|
|
src: url("%s");
|
|
|
|
|
}`,
|
2022-11-07 19:52:01 +00:00
|
|
|
d2fonts.FontEncodings[d2fonts.SourceCodePro.Font(0, d2fonts.FONT_STYLE_REGULAR)])
|
|
|
|
|
break
|
|
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
out += `]]></style>`
|
|
|
|
|
return out
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-03 19:27:15 +00:00
|
|
|
//go:embed fitToScreen.js
|
|
|
|
|
var fitToScreenScript string
|
|
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
const (
|
|
|
|
|
BG_COLOR = color.N7
|
|
|
|
|
FG_COLOR = color.N1
|
2023-01-09 21:16:22 +00:00
|
|
|
|
|
|
|
|
DEFAULT_THEME int64 = 0
|
|
|
|
|
DEFAULT_DARK_THEME int64 = math.MaxInt64 // no theme selected
|
2023-01-09 18:16:28 +00:00
|
|
|
)
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
// TODO minify output at end
|
2022-12-21 07:43:45 +00:00
|
|
|
func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
|
|
|
|
|
var sketchRunner *d2sketch.Runner
|
|
|
|
|
pad := DEFAULT_PADDING
|
2023-01-09 21:16:22 +00:00
|
|
|
themeID := DEFAULT_THEME
|
|
|
|
|
darkThemeID := DEFAULT_DARK_THEME
|
2022-12-21 07:43:45 +00:00
|
|
|
if opts != nil {
|
|
|
|
|
pad = opts.Pad
|
|
|
|
|
if opts.Sketch {
|
|
|
|
|
var err error
|
|
|
|
|
sketchRunner, err = d2sketch.InitSketchVM()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-01-09 21:16:22 +00:00
|
|
|
themeID = opts.ThemeID
|
|
|
|
|
darkThemeID = opts.DarkThemeID
|
2022-12-21 07:43:45 +00:00
|
|
|
}
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
|
|
2022-11-09 19:10:51 +00:00
|
|
|
// only define shadow filter if a shape uses it
|
|
|
|
|
for _, s := range diagram.Shapes {
|
|
|
|
|
if s.Shadow {
|
|
|
|
|
defineShadowFilter(buf)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-06 06:33:35 +00:00
|
|
|
// Mask URLs are global. So when multiple SVGs attach to a DOM, they share
|
|
|
|
|
// the same namespace for mask URLs.
|
2022-12-06 06:32:23 +00:00
|
|
|
labelMaskID, err := diagram.HashID()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
// SVG has no notion of z-index. The z-index is effectively the order it's drawn.
|
|
|
|
|
// So draw from the least nested to most nested
|
2022-11-09 17:37:28 +00:00
|
|
|
idToShape := make(map[string]d2target.Shape)
|
2022-11-30 21:15:33 +00:00
|
|
|
allObjects := make([]DiagramObject, 0, len(diagram.Shapes)+len(diagram.Connections))
|
2022-11-03 13:54:49 +00:00
|
|
|
for _, s := range diagram.Shapes {
|
2022-11-09 17:37:28 +00:00
|
|
|
idToShape[s.ID] = s
|
2022-11-29 22:21:23 +00:00
|
|
|
allObjects = append(allObjects, s)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
2022-11-29 22:21:23 +00:00
|
|
|
for _, c := range diagram.Connections {
|
|
|
|
|
allObjects = append(allObjects, c)
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2022-11-30 19:22:43 +00:00
|
|
|
sortObjects(allObjects)
|
2022-11-29 22:21:23 +00:00
|
|
|
|
2022-12-03 06:47:54 +00:00
|
|
|
var labelMasks []string
|
2022-11-03 13:54:49 +00:00
|
|
|
markers := map[string]struct{}{}
|
2022-11-29 22:21:23 +00:00
|
|
|
for _, obj := range allObjects {
|
|
|
|
|
if c, is := obj.(d2target.Connection); is {
|
2023-01-09 18:16:28 +00:00
|
|
|
labelMask, err := drawConnection(buf, BG_COLOR, FG_COLOR, labelMaskID, c, markers, idToShape, sketchRunner)
|
2022-12-21 07:43:45 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2022-12-06 03:50:42 +00:00
|
|
|
if labelMask != "" {
|
2022-12-03 06:47:54 +00:00
|
|
|
labelMasks = append(labelMasks, labelMask)
|
|
|
|
|
}
|
2022-11-29 22:21:23 +00:00
|
|
|
} else if s, is := obj.(d2target.Shape); is {
|
2022-12-21 07:43:45 +00:00
|
|
|
labelMask, err := drawShape(buf, s, sketchRunner)
|
2022-12-06 03:50:42 +00:00
|
|
|
if err != nil {
|
2022-11-29 22:21:23 +00:00
|
|
|
return nil, err
|
2022-12-05 19:57:16 +00:00
|
|
|
} else if labelMask != "" {
|
|
|
|
|
labelMasks = append(labelMasks, labelMask)
|
2022-11-29 22:21:23 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2022-12-17 01:15:58 +00:00
|
|
|
return nil, fmt.Errorf("unknown object of type %T", obj)
|
2022-11-29 22:21:23 +00:00
|
|
|
}
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2022-12-17 01:15:37 +00:00
|
|
|
// Note: we always want this since we reference it on connections even if there end up being no masked labels
|
2023-01-09 18:16:28 +00:00
|
|
|
w, h, tl, _ := dimensions(buf, diagram, pad)
|
2022-12-17 01:15:37 +00:00
|
|
|
fmt.Fprint(buf, strings.Join([]string{
|
2022-12-21 20:54:02 +00:00
|
|
|
fmt.Sprintf(`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">`,
|
|
|
|
|
labelMaskID, -pad, -pad, w, h,
|
2022-12-17 01:15:37 +00:00
|
|
|
),
|
2022-12-21 20:54:02 +00:00
|
|
|
fmt.Sprintf(`<rect x="%d" y="%d" width="%d" height="%d" fill="white"></rect>`,
|
|
|
|
|
-pad, -pad, w, h,
|
2022-12-17 01:15:37 +00:00
|
|
|
),
|
|
|
|
|
strings.Join(labelMasks, "\n"),
|
|
|
|
|
`</mask>`,
|
|
|
|
|
}, "\n"))
|
2022-12-03 06:47:54 +00:00
|
|
|
|
2023-01-09 18:16:28 +00:00
|
|
|
// TODO minify
|
|
|
|
|
// TODO background stuff. e.g. dotted, grid, colors
|
2023-01-09 23:17:56 +00:00
|
|
|
backgroundEl := svg_style.NewThemableElement("rect")
|
|
|
|
|
backgroundEl.X = float64(tl.X - pad - 10) // TODO the background is not rendered all over the image
|
|
|
|
|
backgroundEl.Y = float64(tl.Y - pad - 10) // so I had to add 10 to the size - someone smarter than me please fix this
|
|
|
|
|
backgroundEl.Width = float64(w + 10*2) // new observations: adding even 10 to the size seems to fail at higher image sizes?
|
|
|
|
|
backgroundEl.Height = float64(h + 10*2)
|
|
|
|
|
backgroundEl.Fill = color.N7
|
2023-01-09 18:16:28 +00:00
|
|
|
|
|
|
|
|
// generate elements that will be appended to the SVG tag
|
2023-01-09 21:16:22 +00:00
|
|
|
themeStylesheet := themeCSS(themeID, darkThemeID)
|
|
|
|
|
sketchStylesheet := ""
|
2023-01-09 18:16:28 +00:00
|
|
|
if sketchRunner != nil {
|
2023-01-09 21:16:22 +00:00
|
|
|
sketchStylesheet = "\n" + sketchStyleCSS
|
2023-01-09 18:16:28 +00:00
|
|
|
}
|
2023-01-09 21:16:22 +00:00
|
|
|
svgOut := fmt.Sprintf(`<style type="text/css"><![CDATA[%s%s%s]]></style>`, baseStylesheet, themeStylesheet, sketchStylesheet)
|
2023-01-09 18:16:28 +00:00
|
|
|
// this script won't run in --watch mode because script tags are ignored when added via el.innerHTML = element
|
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
|
|
|
|
|
svgOut += fmt.Sprintf(`<script type="application/javascript"><![CDATA[%s]]></script>`, fitToScreenScript)
|
|
|
|
|
hasMarkdown := false
|
|
|
|
|
for _, s := range diagram.Shapes {
|
|
|
|
|
if s.Label != "" && s.Type == d2target.ShapeText {
|
|
|
|
|
hasMarkdown = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if hasMarkdown {
|
|
|
|
|
svgOut += fmt.Sprintf(`<style type="text/css">%s</style>`, mdCSS)
|
|
|
|
|
}
|
2023-01-09 21:16:22 +00:00
|
|
|
if sketchRunner != nil {
|
2023-01-09 18:16:28 +00:00
|
|
|
svgOut += d2sketch.DefineFillPattern()
|
|
|
|
|
}
|
|
|
|
|
svgOut += embedFonts(buf, diagram.FontFamily)
|
|
|
|
|
|
|
|
|
|
// render the document
|
|
|
|
|
docRendered := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?><svg id="d2-svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s</svg>`,
|
|
|
|
|
w, h, tl.X-pad, tl.Y-pad, w, h,
|
|
|
|
|
svgOut,
|
2023-01-09 23:17:56 +00:00
|
|
|
backgroundEl.Render(),
|
2023-01-09 18:16:28 +00:00
|
|
|
buf.String(),
|
|
|
|
|
)
|
|
|
|
|
return []byte(docRendered), nil
|
2022-11-03 13:54:49 +00:00
|
|
|
}
|
|
|
|
|
|
2023-01-09 21:16:22 +00:00
|
|
|
func themeCSS(themeID, darkThemeID int64) (stylesheet string) {
|
|
|
|
|
out := singleThemeRulesets(themeID)
|
|
|
|
|
|
|
|
|
|
if darkThemeID != math.MaxInt64 {
|
|
|
|
|
out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", singleThemeRulesets(darkThemeID))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func singleThemeRulesets(themeID int64) (rulesets string) {
|
|
|
|
|
out := ""
|
|
|
|
|
theme := d2themescatalog.Find(themeID)
|
2023-01-09 23:17:56 +00:00
|
|
|
|
2023-01-09 21:16:22 +00:00
|
|
|
for _, property := range []string{"fill", "stroke", "background-color", "color"} {
|
|
|
|
|
out += fmt.Sprintf(".%s-N1{%s:%s;}.%s-N2{%s:%s;}.%s-N3{%s:%s;}.%s-N4{%s:%s;}.%s-N5{%s:%s;}.%s-N6{%s:%s;}.%s-N7{%s:%s;}.%s-B1{%s:%s;}.%s-B2{%s:%s;}.%s-B3{%s:%s;}.%s-B4{%s:%s;}.%s-B5{%s:%s;}.%s-B6{%s:%s;}.%s-AA2{%s:%s;}.%s-AA4{%s:%s;}.%s-AA5{%s:%s;}.%s-AB4{%s:%s;}.%s-AB5{%s:%s;}",
|
|
|
|
|
property, property, theme.Colors.Neutrals.N1,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N2,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N3,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N4,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N5,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N6,
|
|
|
|
|
property, property, theme.Colors.Neutrals.N7,
|
|
|
|
|
property, property, theme.Colors.B1,
|
|
|
|
|
property, property, theme.Colors.B2,
|
|
|
|
|
property, property, theme.Colors.B3,
|
|
|
|
|
property, property, theme.Colors.B4,
|
|
|
|
|
property, property, theme.Colors.B5,
|
|
|
|
|
property, property, theme.Colors.B6,
|
|
|
|
|
property, property, theme.Colors.AA2,
|
|
|
|
|
property, property, theme.Colors.AA4,
|
|
|
|
|
property, property, theme.Colors.AA5,
|
|
|
|
|
property, property, theme.Colors.AB4,
|
|
|
|
|
property, property, theme.Colors.AB5,
|
|
|
|
|
)
|
|
|
|
|
}
|
2023-01-09 23:17:56 +00:00
|
|
|
|
|
|
|
|
out += fmt.Sprintf(".md{--color-fg-default:%s;--color-fg-muted:%s;--color-fg-subtle:%s;--color-canvas-default:%s;--color-canvas-subtle:%s;--color-border-default:%s;--color-border-muted:%s;--color-neutral-muted:%s;--color-accent-fg:%s;--color-accent-emphasis:%s;--color-attention-subtle:%s;--color-danger-fg:%s;}",
|
|
|
|
|
theme.Colors.Neutrals.N1, theme.Colors.Neutrals.N2, theme.Colors.Neutrals.N3,
|
|
|
|
|
theme.Colors.Neutrals.N7, theme.Colors.Neutrals.N6,
|
|
|
|
|
theme.Colors.B1, theme.Colors.B2,
|
|
|
|
|
theme.Colors.Neutrals.N6, // TODO maybe N5 --color-border-default
|
|
|
|
|
theme.Colors.B2, theme.Colors.B2,
|
|
|
|
|
theme.Colors.Neutrals.N2, // TODO or N3 --color-attention-subtle
|
|
|
|
|
"red",
|
|
|
|
|
)
|
|
|
|
|
|
2023-01-09 21:16:22 +00:00
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-30 21:15:33 +00:00
|
|
|
type DiagramObject interface {
|
|
|
|
|
GetID() string
|
|
|
|
|
GetZIndex() int
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-30 19:22:43 +00:00
|
|
|
// sortObjects sorts all diagrams objects (shapes and connections) in the desired drawing order
|
|
|
|
|
// the sorting criteria is:
|
|
|
|
|
// 1. zIndex, lower comes first
|
|
|
|
|
// 2. two shapes with the same zIndex are sorted by their level (container nesting), containers come first
|
|
|
|
|
// 3. two shapes with the same zIndex and same level, are sorted in the order they were exported
|
|
|
|
|
// 4. shape and edge, shapes come first
|
2022-11-30 21:15:33 +00:00
|
|
|
func sortObjects(allObjects []DiagramObject) {
|
2022-11-30 19:22:43 +00:00
|
|
|
sort.SliceStable(allObjects, func(i, j int) bool {
|
|
|
|
|
// first sort by zIndex
|
|
|
|
|
iZIndex := allObjects[i].GetZIndex()
|
|
|
|
|
jZIndex := allObjects[j].GetZIndex()
|
|
|
|
|
if iZIndex != jZIndex {
|
|
|
|
|
return iZIndex < jZIndex
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-30 21:15:33 +00:00
|
|
|
// then, if both are shapes, parents come before their children
|
2022-11-30 19:22:43 +00:00
|
|
|
iShape, iIsShape := allObjects[i].(d2target.Shape)
|
|
|
|
|
jShape, jIsShape := allObjects[j].(d2target.Shape)
|
|
|
|
|
if iIsShape && jIsShape {
|
|
|
|
|
return iShape.Level < jShape.Level
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// then, shapes come before connections
|
|
|
|
|
_, jIsConnection := allObjects[j].(d2target.Connection)
|
|
|
|
|
return iIsShape && jIsConnection
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2022-11-03 13:54:49 +00:00
|
|
|
func hash(s string) string {
|
|
|
|
|
const secret = "lalalas"
|
|
|
|
|
h := fnv.New32a()
|
|
|
|
|
h.Write([]byte(fmt.Sprintf("%s%s", s, secret)))
|
|
|
|
|
return fmt.Sprint(h.Sum32())
|
|
|
|
|
}
|