d2/d2target/d2target.go

573 lines
14 KiB
Go
Raw Normal View History

package d2target
import (
2022-12-06 06:32:23 +00:00
"encoding/json"
"fmt"
"hash/fnv"
"math"
"net/url"
"strings"
2022-12-01 18:48:01 +00:00
"oss.terrastruct.com/util-go/go2"
2022-12-21 07:43:45 +00:00
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/shape"
2023-01-12 19:20:18 +00:00
"oss.terrastruct.com/d2/lib/svg"
)
const (
DEFAULT_ICON_SIZE = 32
MAX_ICON_SIZE = 64
2023-01-19 19:51:30 +00:00
THREE_DEE_OFFSET = 15
2023-01-19 20:39:12 +00:00
MULTIPLE_OFFSET = 10
2023-01-22 08:50:14 +00:00
2023-01-22 10:40:47 +00:00
INNER_BORDER_OFFSET = 5
)
2023-01-22 09:53:10 +00:00
var BorderOffset = geo.NewVector(5, 5)
2023-01-22 08:50:14 +00:00
type Diagram struct {
2022-12-21 07:43:45 +00:00
Name string `json:"name"`
Description string `json:"description,omitempty"`
FontFamily *d2fonts.FontFamily `json:"fontFamily,omitempty"`
Shapes []Shape `json:"shapes"`
Connections []Connection `json:"connections"`
2023-01-28 07:05:42 +00:00
Layers []*Diagram `json:"layers,omitempty"`
Scenarios []*Diagram `json:"scenarios,omitempty"`
Steps []*Diagram `json:"steps,omitempty"`
}
2022-12-06 06:32:23 +00:00
func (diagram Diagram) HashID() (string, error) {
b1, err := json.Marshal(diagram.Shapes)
if err != nil {
return "", err
}
b2, err := json.Marshal(diagram.Connections)
if err != nil {
return "", err
}
h := fnv.New32a()
h.Write(append(b1, b2...))
return fmt.Sprint(h.Sum32()), nil
}
func (diagram Diagram) BoundingBox() (topLeft, bottomRight Point) {
2022-12-07 02:23:22 +00:00
if len(diagram.Shapes) == 0 {
return Point{0, 0}, Point{0, 0}
}
x1 := int(math.MaxInt32)
y1 := int(math.MaxInt32)
x2 := int(math.MinInt32)
y2 := int(math.MinInt32)
for _, targetShape := range diagram.Shapes {
2022-12-31 04:49:49 +00:00
x1 = go2.Min(x1, targetShape.Pos.X-targetShape.StrokeWidth)
y1 = go2.Min(y1, targetShape.Pos.Y-targetShape.StrokeWidth)
x2 = go2.Max(x2, targetShape.Pos.X+targetShape.Width+targetShape.StrokeWidth)
y2 = go2.Max(y2, targetShape.Pos.Y+targetShape.Height+targetShape.StrokeWidth)
if targetShape.Tooltip != "" || targetShape.Link != "" {
// 16 is the icon radius
y1 = go2.Min(y1, targetShape.Pos.Y-targetShape.StrokeWidth-16)
x2 = go2.Max(x2, targetShape.Pos.X+targetShape.StrokeWidth+targetShape.Width+16)
}
2023-01-19 20:28:10 +00:00
if targetShape.ThreeDee {
y1 = go2.Min(y1, targetShape.Pos.Y-THREE_DEE_OFFSET-targetShape.StrokeWidth)
x2 = go2.Max(x2, targetShape.Pos.X+THREE_DEE_OFFSET+targetShape.Width+targetShape.StrokeWidth)
}
2023-01-19 20:39:12 +00:00
if targetShape.Multiple {
y1 = go2.Min(y1, targetShape.Pos.Y-MULTIPLE_OFFSET-targetShape.StrokeWidth)
x2 = go2.Max(x2, targetShape.Pos.X+MULTIPLE_OFFSET+targetShape.Width+targetShape.StrokeWidth)
}
2023-01-19 20:28:10 +00:00
if targetShape.Label != "" {
labelPosition := label.Position(targetShape.LabelPosition)
if !labelPosition.IsOutside() {
continue
}
shapeType := DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type]
s := shape.NewShape(shapeType,
geo.NewBox(
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
float64(targetShape.Width),
float64(targetShape.Height),
),
)
labelTL := labelPosition.GetPointOnBox(s.GetBox(), label.PADDING, float64(targetShape.LabelWidth), float64(targetShape.LabelHeight))
x1 = go2.Min(x1, int(labelTL.X))
y1 = go2.Min(y1, int(labelTL.Y))
x2 = go2.Max(x2, int(labelTL.X)+targetShape.LabelWidth)
y2 = go2.Max(y2, int(labelTL.Y)+targetShape.LabelHeight)
}
}
for _, connection := range diagram.Connections {
for _, point := range connection.Route {
x1 = go2.Min(x1, int(math.Floor(point.X)))
y1 = go2.Min(y1, int(math.Floor(point.Y)))
x2 = go2.Max(x2, int(math.Ceil(point.X)))
y2 = go2.Max(y2, int(math.Ceil(point.Y)))
}
if connection.Label != "" {
labelTL := connection.GetLabelTopLeft()
x1 = go2.Min(x1, int(labelTL.X))
y1 = go2.Min(y1, int(labelTL.Y))
x2 = go2.Max(x2, int(labelTL.X)+connection.LabelWidth)
y2 = go2.Max(y2, int(labelTL.Y)+connection.LabelHeight)
}
}
return Point{x1, y1}, Point{x2, y2}
}
func NewDiagram() *Diagram {
return &Diagram{}
}
type Shape struct {
ID string `json:"id"`
Type string `json:"type"`
Pos Point `json:"pos"`
Width int `json:"width"`
Height int `json:"height"`
Opacity float64 `json:"opacity"`
StrokeDash float64 `json:"strokeDash"`
StrokeWidth int `json:"strokeWidth"`
BorderRadius int `json:"borderRadius"`
Fill string `json:"fill"`
Stroke string `json:"stroke"`
Shadow bool `json:"shadow"`
ThreeDee bool `json:"3d"`
Multiple bool `json:"multiple"`
DoubleBorder bool `json:"double-border"`
Tooltip string `json:"tooltip"`
Link string `json:"link"`
Icon *url.URL `json:"icon"`
IconPosition string `json:"iconPosition"`
2022-12-05 19:22:16 +00:00
// Whether the shape should allow shapes behind it to bleed through
// Currently just used for sequence diagram groups
Blend bool `json:"blend"`
Class
SQLTable
Text
LabelPosition string `json:"labelPosition,omitempty"`
2022-11-29 23:50:18 +00:00
ZIndex int `json:"zIndex"`
2022-11-30 19:22:43 +00:00
Level int `json:"level"`
2022-12-24 23:56:22 +00:00
// These are used for special shapes, sql_table and class
PrimaryAccentColor string `json:"primaryAccentColor,omitempty"`
SecondaryAccentColor string `json:"secondaryAccentColor,omitempty"`
NeutralAccentColor string `json:"neutralAccentColor,omitempty"`
}
2023-01-12 19:22:53 +00:00
func (s Shape) CSSStyle() string {
out := ""
if s.Type == ShapeSQLTable || s.Type == ShapeClass {
// Fill is used for header fill in these types
// This fill property is just background of rows
out += fmt.Sprintf(`fill:%s;`, s.Stroke)
// Stroke (border) of these shapes should match the header fill
out += fmt.Sprintf(`stroke:%s;`, s.Fill)
} else {
out += fmt.Sprintf(`fill:%s;`, s.Fill)
out += fmt.Sprintf(`stroke:%s;`, s.Stroke)
}
out += fmt.Sprintf(`stroke-width:%d;`, s.StrokeWidth)
if s.StrokeDash != 0 {
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(s.StrokeWidth), s.StrokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
}
if s.BorderRadius != 0 {
out += fmt.Sprintf(`rx:%d;`, s.BorderRadius)
}
2023-01-12 19:22:53 +00:00
return out
}
func (s *Shape) SetType(t string) {
// Some types are synonyms of other types, but with hinting for autolayout
// They should only have one representation in the final export
if strings.EqualFold(t, ShapeCircle) {
t = ShapeOval
} else if strings.EqualFold(t, ShapeSquare) {
t = ShapeRectangle
}
s.Type = strings.ToLower(t)
}
2022-11-29 23:50:18 +00:00
func (s Shape) GetZIndex() int {
return s.ZIndex
}
2022-11-30 19:22:43 +00:00
func (s Shape) GetID() string {
return s.ID
}
type Text struct {
Label string `json:"label"`
FontSize int `json:"fontSize"`
FontFamily string `json:"fontFamily"`
Language string `json:"language"`
Color string `json:"color"`
Italic bool `json:"italic"`
Bold bool `json:"bold"`
Underline bool `json:"underline"`
LabelWidth int `json:"labelWidth"`
LabelHeight int `json:"labelHeight"`
}
func BaseShape() *Shape {
return &Shape{
Opacity: 1,
StrokeDash: 0,
StrokeWidth: 2,
Text: Text{
Bold: true,
FontFamily: "DEFAULT",
},
}
}
type Connection struct {
ID string `json:"id"`
Src string `json:"src"`
SrcArrow Arrowhead `json:"srcArrow"`
SrcLabel string `json:"srcLabel"`
Dst string `json:"dst"`
DstArrow Arrowhead `json:"dstArrow"`
DstLabel string `json:"dstLabel"`
Opacity float64 `json:"opacity"`
StrokeDash float64 `json:"strokeDash"`
StrokeWidth int `json:"strokeWidth"`
Stroke string `json:"stroke"`
Fill string `json:"fill,omitempty"`
Text
LabelPosition string `json:"labelPosition"`
LabelPercentage float64 `json:"labelPercentage"`
Route []*geo.Point `json:"route"`
IsCurve bool `json:"isCurve,omitempty"`
Animated bool `json:"animated"`
Tooltip string `json:"tooltip"`
Icon *url.URL `json:"icon"`
2022-11-29 23:50:18 +00:00
ZIndex int `json:"zIndex"`
}
func BaseConnection() *Connection {
return &Connection{
SrcArrow: NoArrowhead,
DstArrow: NoArrowhead,
Route: make([]*geo.Point, 0),
Opacity: 1,
StrokeDash: 0,
StrokeWidth: 2,
Text: Text{
Italic: true,
FontFamily: "DEFAULT",
},
}
}
2023-01-12 19:20:18 +00:00
func (c Connection) CSSStyle() string {
out := ""
out += fmt.Sprintf(`stroke:%s;`, c.Stroke)
out += fmt.Sprintf(`stroke-width:%d;`, c.StrokeWidth)
strokeDash := c.StrokeDash
if strokeDash == 0 && c.Animated {
strokeDash = 5
}
if strokeDash != 0 {
dashSize, gapSize := svg.GetStrokeDashAttributes(float64(c.StrokeWidth), strokeDash)
out += fmt.Sprintf(`stroke-dasharray:%f,%f;`, dashSize, gapSize)
if c.Animated {
dashOffset := -10
if c.SrcArrow != NoArrowhead && c.DstArrow == NoArrowhead {
dashOffset = 10
}
out += fmt.Sprintf(`stroke-dashoffset:%f;`, float64(dashOffset)*(dashSize+gapSize))
out += fmt.Sprintf(`animation: dashdraw %fs linear infinite;`, gapSize*0.5)
}
}
return out
}
func (c *Connection) GetLabelTopLeft() *geo.Point {
return label.Position(c.LabelPosition).GetPointOnRoute(
c.Route,
float64(c.StrokeWidth),
c.LabelPercentage,
float64(c.LabelWidth),
float64(c.LabelHeight),
)
}
2022-11-29 23:50:18 +00:00
func (c Connection) GetZIndex() int {
return c.ZIndex
}
2022-11-30 19:22:43 +00:00
func (c Connection) GetID() string {
return c.ID
}
type Arrowhead string
const (
NoArrowhead Arrowhead = "none"
ArrowArrowhead Arrowhead = "arrow"
TriangleArrowhead Arrowhead = "triangle"
DiamondArrowhead Arrowhead = "diamond"
FilledDiamondArrowhead Arrowhead = "filled-diamond"
2023-01-10 03:44:45 +00:00
CircleArrowhead Arrowhead = "circle"
2023-01-10 17:11:06 +00:00
FilledCircleArrowhead Arrowhead = "filled-circle"
// For fat arrows
LineArrowhead Arrowhead = "line"
// Crows feet notation
CfOne Arrowhead = "cf-one"
CfMany Arrowhead = "cf-many"
CfOneRequired Arrowhead = "cf-one-required"
CfManyRequired Arrowhead = "cf-many-required"
)
var Arrowheads = map[string]struct{}{
string(NoArrowhead): {},
string(ArrowArrowhead): {},
string(TriangleArrowhead): {},
string(DiamondArrowhead): {},
string(FilledDiamondArrowhead): {},
2023-01-10 03:44:45 +00:00
string(CircleArrowhead): {},
string(FilledCircleArrowhead): {},
string(CfOne): {},
string(CfMany): {},
string(CfOneRequired): {},
string(CfManyRequired): {},
}
func ToArrowhead(arrowheadType string, filled bool) Arrowhead {
switch arrowheadType {
case string(DiamondArrowhead):
if filled {
return FilledDiamondArrowhead
}
return DiamondArrowhead
2023-01-10 03:44:45 +00:00
case string(CircleArrowhead):
if filled {
return FilledCircleArrowhead
}
return CircleArrowhead
case string(ArrowArrowhead):
return ArrowArrowhead
case string(CfOne):
return CfOne
case string(CfMany):
return CfMany
case string(CfOneRequired):
return CfOneRequired
case string(CfManyRequired):
return CfManyRequired
default:
return TriangleArrowhead
}
}
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
func NewPoint(x, y int) Point {
return Point{X: x, Y: y}
}
const (
2022-11-28 22:21:50 +00:00
ShapeRectangle = "rectangle"
ShapeSquare = "square"
ShapePage = "page"
ShapeParallelogram = "parallelogram"
ShapeDocument = "document"
ShapeCylinder = "cylinder"
ShapeQueue = "queue"
ShapePackage = "package"
ShapeStep = "step"
ShapeCallout = "callout"
ShapeStoredData = "stored_data"
ShapePerson = "person"
ShapeDiamond = "diamond"
ShapeOval = "oval"
ShapeCircle = "circle"
ShapeHexagon = "hexagon"
ShapeCloud = "cloud"
ShapeText = "text"
ShapeCode = "code"
ShapeClass = "class"
ShapeSQLTable = "sql_table"
ShapeImage = "image"
ShapeSequenceDiagram = "sequence_diagram"
)
var Shapes = []string{
ShapeRectangle,
ShapeSquare,
ShapePage,
ShapeParallelogram,
ShapeDocument,
ShapeCylinder,
ShapeQueue,
ShapePackage,
ShapeStep,
ShapeCallout,
ShapeStoredData,
ShapePerson,
ShapeDiamond,
ShapeOval,
ShapeCircle,
ShapeHexagon,
ShapeCloud,
ShapeText,
ShapeCode,
ShapeClass,
ShapeSQLTable,
ShapeImage,
2022-11-28 22:21:50 +00:00
ShapeSequenceDiagram,
}
func IsShape(s string) bool {
if s == "" {
// Default shape is rectangle.
return true
}
for _, s2 := range Shapes {
if strings.EqualFold(s, s2) {
return true
}
}
return false
}
type MText struct {
Text string `json:"text"`
FontSize int `json:"fontSize"`
IsBold bool `json:"isBold"`
IsItalic bool `json:"isItalic"`
Language string `json:"language"`
Shape string `json:"shape"`
Dimensions TextDimensions `json:"dimensions,omitempty"`
}
type TextDimensions struct {
Width int `json:"width"`
Height int `json:"height"`
}
func NewTextDimensions(w, h int) *TextDimensions {
return &TextDimensions{Width: w, Height: h}
}
func (text MText) GetColor(theme *d2themes.Theme, isItalic bool) string {
if isItalic {
return theme.Colors.Neutrals.N2
}
return theme.Colors.Neutrals.N1
}
var DSL_SHAPE_TO_SHAPE_TYPE = map[string]string{
2022-11-28 22:21:50 +00:00
"": shape.SQUARE_TYPE,
ShapeRectangle: shape.SQUARE_TYPE,
ShapeSquare: shape.REAL_SQUARE_TYPE,
ShapePage: shape.PAGE_TYPE,
ShapeParallelogram: shape.PARALLELOGRAM_TYPE,
ShapeDocument: shape.DOCUMENT_TYPE,
ShapeCylinder: shape.CYLINDER_TYPE,
ShapeQueue: shape.QUEUE_TYPE,
ShapePackage: shape.PACKAGE_TYPE,
ShapeStep: shape.STEP_TYPE,
ShapeCallout: shape.CALLOUT_TYPE,
ShapeStoredData: shape.STORED_DATA_TYPE,
ShapePerson: shape.PERSON_TYPE,
ShapeDiamond: shape.DIAMOND_TYPE,
ShapeOval: shape.OVAL_TYPE,
ShapeCircle: shape.CIRCLE_TYPE,
ShapeHexagon: shape.HEXAGON_TYPE,
ShapeCloud: shape.CLOUD_TYPE,
ShapeText: shape.TEXT_TYPE,
ShapeCode: shape.CODE_TYPE,
ShapeClass: shape.CLASS_TYPE,
ShapeSQLTable: shape.TABLE_TYPE,
ShapeImage: shape.IMAGE_TYPE,
ShapeSequenceDiagram: shape.SQUARE_TYPE,
}
var SHAPE_TYPE_TO_DSL_SHAPE map[string]string
func init() {
SHAPE_TYPE_TO_DSL_SHAPE = make(map[string]string, len(DSL_SHAPE_TO_SHAPE_TYPE))
for k, v := range DSL_SHAPE_TO_SHAPE_TYPE {
SHAPE_TYPE_TO_DSL_SHAPE[v] = k
}
}
func (s *Shape) GetIconSize(box *geo.Box) int {
iconPosition := label.Position(s.IconPosition)
minDimension := int(math.Min(box.Width, box.Height))
halfMinDimension := int(math.Ceil(0.5 * float64(minDimension)))
var size int
if iconPosition == label.InsideMiddleCenter {
size = halfMinDimension
} else {
size = go2.IntMin(
minDimension,
go2.IntMax(DEFAULT_ICON_SIZE, halfMinDimension),
)
}
size = go2.IntMin(size, MAX_ICON_SIZE)
if !iconPosition.IsOutside() {
size = go2.IntMin(size,
go2.IntMin(
go2.IntMax(int(box.Width)-2*label.PADDING, 0),
go2.IntMax(int(box.Height)-2*label.PADDING, 0),
),
)
}
return size
}