...

Source file src/oss.terrastruct.com/d2/d2renderers/d2sketch/sketch.go

Documentation: oss.terrastruct.com/d2/d2renderers/d2sketch

     1  package d2sketch
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"fmt"
     7  	"regexp"
     8  	"strings"
     9  
    10  	_ "embed"
    11  
    12  	"github.com/dop251/goja"
    13  
    14  	"oss.terrastruct.com/d2/d2target"
    15  	"oss.terrastruct.com/d2/d2themes"
    16  	"oss.terrastruct.com/d2/lib/color"
    17  	"oss.terrastruct.com/d2/lib/geo"
    18  	"oss.terrastruct.com/d2/lib/label"
    19  	"oss.terrastruct.com/d2/lib/svg"
    20  	"oss.terrastruct.com/util-go/go2"
    21  )
    22  
    23  //go:embed rough.js
    24  var roughJS string
    25  
    26  //go:embed setup.js
    27  var setupJS string
    28  
    29  //go:embed streaks.txt
    30  var streaks string
    31  
    32  type Runner goja.Runtime
    33  
    34  var baseRoughProps = `fillWeight: 2.0,
    35  hachureGap: 16,
    36  fillStyle: "solid",
    37  bowing: 2,
    38  seed: 1,`
    39  
    40  var floatRE = regexp.MustCompile(`(\d+)\.(\d+)`)
    41  
    42  const (
    43  	BG_COLOR = color.N7
    44  	FG_COLOR = color.N1
    45  )
    46  
    47  func (r *Runner) run(js string) (goja.Value, error) {
    48  	vm := (*goja.Runtime)(r)
    49  	return vm.RunString(js)
    50  }
    51  
    52  func InitSketchVM() (*Runner, error) {
    53  	vm := goja.New()
    54  	if _, err := vm.RunString(roughJS); err != nil {
    55  		return nil, err
    56  	}
    57  	if _, err := vm.RunString(setupJS); err != nil {
    58  		return nil, err
    59  	}
    60  	r := Runner(*vm)
    61  	return &r, nil
    62  }
    63  
    64  // DefineFillPatterns adds reusable patterns that are overlayed on shapes with
    65  // fill. This gives it a subtle streaky effect that subtly looks hand-drawn but
    66  // not distractingly so.
    67  func DefineFillPatterns(buf *bytes.Buffer) {
    68  	source := buf.String()
    69  	fmt.Fprint(buf, "<defs>")
    70  
    71  	defineFillPattern(buf, source, "bright", "rgba(0, 0, 0, 0.1)")
    72  	defineFillPattern(buf, source, "normal", "rgba(0, 0, 0, 0.16)")
    73  	defineFillPattern(buf, source, "dark", "rgba(0, 0, 0, 0.32)")
    74  	defineFillPattern(buf, source, "darker", "rgba(255, 255, 255, 0.24)")
    75  
    76  	fmt.Fprint(buf, "</defs>")
    77  }
    78  
    79  func defineFillPattern(buf *bytes.Buffer, source string, luminanceCategory, fill string) {
    80  	trigger := fmt.Sprintf(`url(#streaks-%s)`, luminanceCategory)
    81  	if strings.Contains(source, trigger) {
    82  		fmt.Fprintf(buf, streaks, luminanceCategory, fill)
    83  	}
    84  }
    85  
    86  func Rect(r *Runner, shape d2target.Shape) (string, error) {
    87  	js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
    88  		fill: "#000",
    89  		stroke: "#000",
    90  		strokeWidth: %d,
    91  		%s
    92  	});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
    93  	paths, err := computeRoughPathData(r, js)
    94  	if err != nil {
    95  		return "", err
    96  	}
    97  	output := ""
    98  	pathEl := d2themes.NewThemableElement("path")
    99  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   100  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   101  	pathEl.FillPattern = shape.FillPattern
   102  	pathEl.ClassName = "shape"
   103  	pathEl.Style = shape.CSSStyle()
   104  	for _, p := range paths {
   105  		pathEl.D = p
   106  		output += pathEl.Render()
   107  	}
   108  
   109  	sketchOEl := d2themes.NewThemableElement("rect")
   110  	sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   111  	sketchOEl.Width = float64(shape.Width)
   112  	sketchOEl.Height = float64(shape.Height)
   113  	renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
   114  	if err != nil {
   115  		return "", err
   116  	}
   117  	output += renderedSO
   118  
   119  	return output, nil
   120  }
   121  
   122  func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
   123  	jsBigRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
   124  		fill: "#000",
   125  		stroke: "#000",
   126  		strokeWidth: %d,
   127  		%s
   128  	});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
   129  	pathsBigRect, err := computeRoughPathData(r, jsBigRect)
   130  	if err != nil {
   131  		return "", err
   132  	}
   133  	jsSmallRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
   134  		fill: "#000",
   135  		stroke: "#000",
   136  		strokeWidth: %d,
   137  		%s
   138  	});`, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.StrokeWidth, baseRoughProps)
   139  	pathsSmallRect, err := computeRoughPathData(r, jsSmallRect)
   140  	if err != nil {
   141  		return "", err
   142  	}
   143  
   144  	output := ""
   145  
   146  	pathEl := d2themes.NewThemableElement("path")
   147  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   148  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   149  	pathEl.FillPattern = shape.FillPattern
   150  	pathEl.ClassName = "shape"
   151  	pathEl.Style = shape.CSSStyle()
   152  	for _, p := range pathsBigRect {
   153  		pathEl.D = p
   154  		output += pathEl.Render()
   155  	}
   156  
   157  	pathEl = d2themes.NewThemableElement("path")
   158  	pathEl.SetTranslate(float64(shape.Pos.X+d2target.INNER_BORDER_OFFSET), float64(shape.Pos.Y+d2target.INNER_BORDER_OFFSET))
   159  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   160  	// No need for inner to double paint
   161  	pathEl.Fill = "transparent"
   162  	pathEl.ClassName = "shape"
   163  	pathEl.Style = shape.CSSStyle()
   164  	for _, p := range pathsSmallRect {
   165  		pathEl.D = p
   166  		output += pathEl.Render()
   167  	}
   168  
   169  	sketchOEl := d2themes.NewThemableElement("rect")
   170  	sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   171  	sketchOEl.Width = float64(shape.Width)
   172  	sketchOEl.Height = float64(shape.Height)
   173  	renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, shape.Fill).Render()
   174  	if err != nil {
   175  		return "", err
   176  	}
   177  	output += renderedSO
   178  
   179  	return output, nil
   180  }
   181  
   182  func Oval(r *Runner, shape d2target.Shape) (string, error) {
   183  	js := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
   184  		fill: "#000",
   185  		stroke: "#000",
   186  		strokeWidth: %d,
   187  		%s
   188  	});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
   189  	paths, err := computeRoughPathData(r, js)
   190  	if err != nil {
   191  		return "", err
   192  	}
   193  	output := ""
   194  	pathEl := d2themes.NewThemableElement("path")
   195  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   196  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   197  	pathEl.FillPattern = shape.FillPattern
   198  	pathEl.ClassName = "shape"
   199  	pathEl.Style = shape.CSSStyle()
   200  	for _, p := range paths {
   201  		pathEl.D = p
   202  		output += pathEl.Render()
   203  	}
   204  
   205  	soElement := d2themes.NewThemableElement("ellipse")
   206  	soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
   207  	soElement.Rx = float64(shape.Width / 2)
   208  	soElement.Ry = float64(shape.Height / 2)
   209  	renderedSO, err := d2themes.NewThemableSketchOverlay(
   210  		soElement,
   211  		pathEl.Fill,
   212  	).Render()
   213  	if err != nil {
   214  		return "", err
   215  	}
   216  	output += renderedSO
   217  
   218  	return output, nil
   219  }
   220  
   221  func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
   222  	jsBigCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
   223  		fill: "#000",
   224  		stroke: "#000",
   225  		strokeWidth: %d,
   226  		%s
   227  	});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
   228  	jsSmallCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
   229  		fill: "#000",
   230  		stroke: "#000",
   231  		strokeWidth: %d,
   232  		%s
   233  	});`, shape.Width/2, shape.Height/2, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.StrokeWidth, baseRoughProps)
   234  	pathsBigCircle, err := computeRoughPathData(r, jsBigCircle)
   235  	if err != nil {
   236  		return "", err
   237  	}
   238  	pathsSmallCircle, err := computeRoughPathData(r, jsSmallCircle)
   239  	if err != nil {
   240  		return "", err
   241  	}
   242  
   243  	output := ""
   244  
   245  	pathEl := d2themes.NewThemableElement("path")
   246  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   247  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   248  	pathEl.FillPattern = shape.FillPattern
   249  	pathEl.ClassName = "shape"
   250  	pathEl.Style = shape.CSSStyle()
   251  	for _, p := range pathsBigCircle {
   252  		pathEl.D = p
   253  		output += pathEl.Render()
   254  	}
   255  
   256  	pathEl = d2themes.NewThemableElement("path")
   257  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   258  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   259  	// No need for inner to double paint
   260  	pathEl.Fill = "transparent"
   261  	pathEl.ClassName = "shape"
   262  	pathEl.Style = shape.CSSStyle()
   263  	for _, p := range pathsSmallCircle {
   264  		pathEl.D = p
   265  		output += pathEl.Render()
   266  	}
   267  	soElement := d2themes.NewThemableElement("ellipse")
   268  	soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
   269  	soElement.Rx = float64(shape.Width / 2)
   270  	soElement.Ry = float64(shape.Height / 2)
   271  	renderedSO, err := d2themes.NewThemableSketchOverlay(
   272  		soElement,
   273  		shape.Fill,
   274  	).Render()
   275  	if err != nil {
   276  		return "", err
   277  	}
   278  	output += renderedSO
   279  
   280  	return output, nil
   281  }
   282  
   283  // TODO need to personalize this per shape like we do in Terrastruct app
   284  func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
   285  	output := ""
   286  	for _, path := range paths {
   287  		js := fmt.Sprintf(`node = rc.path("%s", {
   288  		fill: "#000",
   289  		stroke: "#000",
   290  		strokeWidth: %d,
   291  		%s
   292  	});`, path, shape.StrokeWidth, baseRoughProps)
   293  		sketchPaths, err := computeRoughPathData(r, js)
   294  		if err != nil {
   295  			return "", err
   296  		}
   297  		pathEl := d2themes.NewThemableElement("path")
   298  		pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   299  		pathEl.FillPattern = shape.FillPattern
   300  		pathEl.ClassName = "shape"
   301  		pathEl.Style = shape.CSSStyle()
   302  		for _, p := range sketchPaths {
   303  			pathEl.D = p
   304  			output += pathEl.Render()
   305  		}
   306  
   307  		soElement := d2themes.NewThemableElement("path")
   308  		for _, p := range sketchPaths {
   309  			soElement.D = p
   310  			renderedSO, err := d2themes.NewThemableSketchOverlay(
   311  				soElement,
   312  				pathEl.Fill,
   313  			).Render()
   314  			if err != nil {
   315  				return "", err
   316  			}
   317  			output += renderedSO
   318  		}
   319  	}
   320  	return output, nil
   321  }
   322  
   323  func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) {
   324  	roughness := 0.5
   325  	js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness)
   326  	paths, err := computeRoughPathData(r, js)
   327  	if err != nil {
   328  		return "", err
   329  	}
   330  	output := ""
   331  	animatedClass := ""
   332  	if connection.Animated {
   333  		animatedClass = " animated-connection"
   334  	}
   335  
   336  	pathEl := d2themes.NewThemableElement("path")
   337  	pathEl.Fill = color.None
   338  	pathEl.Stroke = connection.Stroke
   339  	pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
   340  	pathEl.Style = connection.CSSStyle()
   341  	pathEl.Attributes = attrs
   342  	for _, p := range paths {
   343  		pathEl.D = p
   344  		output += pathEl.Render()
   345  	}
   346  	return output, nil
   347  }
   348  
   349  // TODO cleanup
   350  func Table(r *Runner, shape d2target.Shape) (string, error) {
   351  	output := ""
   352  	js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
   353  		fill: "#000",
   354  		stroke: "#000",
   355  		strokeWidth: %d,
   356  		%s
   357  	});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
   358  	paths, err := computeRoughPathData(r, js)
   359  	if err != nil {
   360  		return "", err
   361  	}
   362  	pathEl := d2themes.NewThemableElement("path")
   363  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   364  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   365  	pathEl.FillPattern = shape.FillPattern
   366  	pathEl.ClassName = "shape"
   367  	pathEl.Style = shape.CSSStyle()
   368  	for _, p := range paths {
   369  		pathEl.D = p
   370  		output += pathEl.Render()
   371  	}
   372  
   373  	box := geo.NewBox(
   374  		geo.NewPoint(float64(shape.Pos.X), float64(shape.Pos.Y)),
   375  		float64(shape.Width),
   376  		float64(shape.Height),
   377  	)
   378  	rowHeight := box.Height / float64(1+len(shape.SQLTable.Columns))
   379  	headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight)
   380  
   381  	js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
   382  		fill: "#000",
   383  		%s
   384  	});`, shape.Width, rowHeight, baseRoughProps)
   385  	paths, err = computeRoughPathData(r, js)
   386  	if err != nil {
   387  		return "", err
   388  	}
   389  	pathEl = d2themes.NewThemableElement("path")
   390  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   391  	pathEl.Fill = shape.Fill
   392  	pathEl.FillPattern = shape.FillPattern
   393  	pathEl.ClassName = "class_header"
   394  	for _, p := range paths {
   395  		pathEl.D = p
   396  		output += pathEl.Render()
   397  	}
   398  
   399  	if shape.Label != "" {
   400  		tl := label.InsideMiddleLeft.GetPointOnBox(
   401  			headerBox,
   402  			20,
   403  			float64(shape.LabelWidth),
   404  			float64(shape.LabelHeight),
   405  		)
   406  
   407  		textEl := d2themes.NewThemableElement("text")
   408  		textEl.X = tl.X
   409  		textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
   410  		textEl.Fill = shape.GetFontColor()
   411  		textEl.ClassName = "text"
   412  		textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx",
   413  			"start", 4+shape.FontSize,
   414  		)
   415  		textEl.Content = svg.EscapeText(shape.Label)
   416  		output += textEl.Render()
   417  	}
   418  
   419  	var longestNameWidth int
   420  	for _, f := range shape.Columns {
   421  		longestNameWidth = go2.Max(longestNameWidth, f.Name.LabelWidth)
   422  	}
   423  
   424  	rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
   425  	rowBox.TopLeft.Y += headerBox.Height
   426  	for _, f := range shape.Columns {
   427  		nameTL := label.InsideMiddleLeft.GetPointOnBox(
   428  			rowBox,
   429  			d2target.NamePadding,
   430  			rowBox.Width,
   431  			float64(shape.FontSize),
   432  		)
   433  		constraintTR := label.InsideMiddleRight.GetPointOnBox(
   434  			rowBox,
   435  			d2target.TypePadding,
   436  			0,
   437  			float64(shape.FontSize),
   438  		)
   439  
   440  		textEl := d2themes.NewThemableElement("text")
   441  		textEl.X = nameTL.X
   442  		textEl.Y = nameTL.Y + float64(shape.FontSize)*3/4
   443  		textEl.Fill = shape.PrimaryAccentColor
   444  		textEl.ClassName = "text"
   445  		textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "start", float64(shape.FontSize))
   446  		textEl.Content = svg.EscapeText(f.Name.Label)
   447  		output += textEl.Render()
   448  
   449  		textEl.X = nameTL.X + float64(longestNameWidth) + 2*d2target.NamePadding
   450  		textEl.Fill = shape.NeutralAccentColor
   451  		textEl.Content = svg.EscapeText(f.Type.Label)
   452  		output += textEl.Render()
   453  
   454  		textEl.X = constraintTR.X
   455  		textEl.Y = constraintTR.Y + float64(shape.FontSize)*3/4
   456  		textEl.Fill = shape.SecondaryAccentColor
   457  		textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx;letter-spacing:2px", "end", float64(shape.FontSize))
   458  		textEl.Content = f.ConstraintAbbr()
   459  		output += textEl.Render()
   460  
   461  		rowBox.TopLeft.Y += rowHeight
   462  
   463  		js = fmt.Sprintf(`node = rc.line(%f, %f, %f, %f, {
   464  		%s
   465  	});`, rowBox.TopLeft.X, rowBox.TopLeft.Y, rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y, baseRoughProps)
   466  		paths, err = computeRoughPathData(r, js)
   467  		if err != nil {
   468  			return "", err
   469  		}
   470  		pathEl := d2themes.NewThemableElement("path")
   471  		pathEl.Fill = shape.Fill
   472  		pathEl.FillPattern = shape.FillPattern
   473  		for _, p := range paths {
   474  			pathEl.D = p
   475  			output += pathEl.Render()
   476  		}
   477  	}
   478  
   479  	sketchOEl := d2themes.NewThemableElement("rect")
   480  	sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   481  	sketchOEl.Width = float64(shape.Width)
   482  	sketchOEl.Height = float64(shape.Height)
   483  	renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
   484  	if err != nil {
   485  		return "", err
   486  	}
   487  	output += renderedSO
   488  
   489  	return output, nil
   490  }
   491  
   492  func Class(r *Runner, shape d2target.Shape) (string, error) {
   493  	output := ""
   494  	js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
   495  		fill: "#000",
   496  		stroke: "#000",
   497  		strokeWidth: %d,
   498  		%s
   499  	});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
   500  	paths, err := computeRoughPathData(r, js)
   501  	if err != nil {
   502  		return "", err
   503  	}
   504  	pathEl := d2themes.NewThemableElement("path")
   505  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   506  	pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
   507  	pathEl.FillPattern = shape.FillPattern
   508  	pathEl.ClassName = "shape"
   509  	pathEl.Style = shape.CSSStyle()
   510  	for _, p := range paths {
   511  		pathEl.D = p
   512  		output += pathEl.Render()
   513  	}
   514  
   515  	box := geo.NewBox(
   516  		geo.NewPoint(float64(shape.Pos.X), float64(shape.Pos.Y)),
   517  		float64(shape.Width),
   518  		float64(shape.Height),
   519  	)
   520  
   521  	rowHeight := box.Height / float64(2+len(shape.Class.Fields)+len(shape.Class.Methods))
   522  	headerBox := geo.NewBox(box.TopLeft, box.Width, 2*rowHeight)
   523  
   524  	js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
   525  		fill: "#000",
   526  		%s
   527  	});`, shape.Width, headerBox.Height, baseRoughProps)
   528  	paths, err = computeRoughPathData(r, js)
   529  	if err != nil {
   530  		return "", err
   531  	}
   532  	pathEl = d2themes.NewThemableElement("path")
   533  	pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   534  	pathEl.Fill = shape.Fill
   535  	pathEl.FillPattern = shape.FillPattern
   536  	pathEl.ClassName = "class_header"
   537  	for _, p := range paths {
   538  		pathEl.D = p
   539  		output += pathEl.Render()
   540  	}
   541  
   542  	sketchOEl := d2themes.NewThemableElement("rect")
   543  	sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
   544  	sketchOEl.Width = float64(shape.Width)
   545  	sketchOEl.Height = headerBox.Height
   546  	renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
   547  	if err != nil {
   548  		return "", err
   549  	}
   550  	output += renderedSO
   551  
   552  	if shape.Label != "" {
   553  		tl := label.InsideMiddleCenter.GetPointOnBox(
   554  			headerBox,
   555  			0,
   556  			float64(shape.LabelWidth),
   557  			float64(shape.LabelHeight),
   558  		)
   559  
   560  		textEl := d2themes.NewThemableElement("text")
   561  		textEl.X = tl.X + float64(shape.LabelWidth)/2
   562  		textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
   563  		textEl.Fill = shape.GetFontColor()
   564  		textEl.ClassName = "text-mono"
   565  		textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx",
   566  			"middle",
   567  			4+shape.FontSize,
   568  		)
   569  		textEl.Content = svg.EscapeText(shape.Label)
   570  		output += textEl.Render()
   571  	}
   572  
   573  	rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
   574  	rowBox.TopLeft.Y += headerBox.Height
   575  	for _, f := range shape.Fields {
   576  		output += classRow(shape, rowBox, f.VisibilityToken(), f.Name, f.Type, float64(shape.FontSize))
   577  		rowBox.TopLeft.Y += rowHeight
   578  	}
   579  
   580  	js = fmt.Sprintf(`node = rc.line(%f, %f, %f, %f, {
   581  %s
   582  	});`, rowBox.TopLeft.X, rowBox.TopLeft.Y, rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y, baseRoughProps)
   583  	paths, err = computeRoughPathData(r, js)
   584  	if err != nil {
   585  		return "", err
   586  	}
   587  	pathEl = d2themes.NewThemableElement("path")
   588  	pathEl.Fill = shape.Fill
   589  	pathEl.FillPattern = shape.FillPattern
   590  	pathEl.ClassName = "class_header"
   591  	for _, p := range paths {
   592  		pathEl.D = p
   593  		output += pathEl.Render()
   594  	}
   595  
   596  	for _, m := range shape.Methods {
   597  		output += classRow(shape, rowBox, m.VisibilityToken(), m.Name, m.Return, float64(shape.FontSize))
   598  		rowBox.TopLeft.Y += rowHeight
   599  	}
   600  
   601  	return output, nil
   602  }
   603  
   604  func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText string, fontSize float64) string {
   605  	output := ""
   606  	prefixTL := label.InsideMiddleLeft.GetPointOnBox(
   607  		box,
   608  		d2target.PrefixPadding,
   609  		box.Width,
   610  		fontSize,
   611  	)
   612  	typeTR := label.InsideMiddleRight.GetPointOnBox(
   613  		box,
   614  		d2target.TypePadding,
   615  		0,
   616  		fontSize,
   617  	)
   618  
   619  	textEl := d2themes.NewThemableElement("text")
   620  	textEl.X = prefixTL.X
   621  	textEl.Y = prefixTL.Y + fontSize*3/4
   622  	textEl.Fill = shape.PrimaryAccentColor
   623  	textEl.ClassName = "text-mono"
   624  	textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "start", fontSize)
   625  	textEl.Content = prefix
   626  	output += textEl.Render()
   627  
   628  	textEl.X = prefixTL.X + d2target.PrefixWidth
   629  	textEl.Fill = shape.Fill
   630  	textEl.Content = svg.EscapeText(nameText)
   631  	output += textEl.Render()
   632  
   633  	textEl.X = typeTR.X
   634  	textEl.Y = typeTR.Y + fontSize*3/4
   635  	textEl.Fill = shape.SecondaryAccentColor
   636  	textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "end", fontSize)
   637  	textEl.Content = svg.EscapeText(typeText)
   638  	output += textEl.Render()
   639  
   640  	return output
   641  }
   642  
   643  func computeRoughPathData(r *Runner, js string) ([]string, error) {
   644  	if _, err := r.run(js); err != nil {
   645  		return nil, err
   646  	}
   647  	roughPaths, err := extractRoughPaths(r)
   648  	if err != nil {
   649  		return nil, err
   650  	}
   651  	return extractPathData(roughPaths)
   652  }
   653  
   654  func computeRoughPaths(r *Runner, js string) ([]roughPath, error) {
   655  	if _, err := r.run(js); err != nil {
   656  		return nil, err
   657  	}
   658  	return extractRoughPaths(r)
   659  }
   660  
   661  type attrs struct {
   662  	D string `json:"d"`
   663  }
   664  
   665  type style struct {
   666  	Stroke      string `json:"stroke,omitempty"`
   667  	StrokeWidth string `json:"strokeWidth,omitempty"`
   668  	Fill        string `json:"fill,omitempty"`
   669  }
   670  
   671  type roughPath struct {
   672  	Attrs attrs `json:"attrs"`
   673  	Style style `json:"style"`
   674  }
   675  
   676  func (rp roughPath) StyleCSS() string {
   677  	style := ""
   678  	if rp.Style.StrokeWidth != "" {
   679  		style += fmt.Sprintf("stroke-width:%s;", rp.Style.StrokeWidth)
   680  	}
   681  	return style
   682  }
   683  
   684  func extractRoughPaths(r *Runner) ([]roughPath, error) {
   685  	val, err := r.run("JSON.stringify(node.children, null, '  ')")
   686  	if err != nil {
   687  		return nil, err
   688  	}
   689  
   690  	var roughPaths []roughPath
   691  	err = json.Unmarshal([]byte(val.String()), &roughPaths)
   692  	if err != nil {
   693  		return nil, err
   694  	}
   695  
   696  	// we want to have a fixed precision to the decimals in the path data
   697  	for i := range roughPaths {
   698  		// truncate all floats in path to only use up to 6 decimal places
   699  		roughPaths[i].Attrs.D = floatRE.ReplaceAllStringFunc(roughPaths[i].Attrs.D, func(floatStr string) string {
   700  			i := strings.Index(floatStr, ".")
   701  			decimalLen := len(floatStr) - i - 1
   702  			end := i + go2.Min(decimalLen, 6)
   703  			return floatStr[:end+1]
   704  		})
   705  	}
   706  
   707  	return roughPaths, nil
   708  }
   709  
   710  func extractPathData(roughPaths []roughPath) ([]string, error) {
   711  	var paths []string
   712  	for _, rp := range roughPaths {
   713  		paths = append(paths, rp.Attrs.D)
   714  	}
   715  	return paths, nil
   716  }
   717  
   718  func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeWidth int) (arrowJS, extraJS string) {
   719  	// Note: selected each seed that looks the good for consistent renders
   720  	switch arrowhead {
   721  	case d2target.ArrowArrowhead:
   722  		arrowJS = fmt.Sprintf(
   723  			`node = rc.linearPath(%s, { strokeWidth: %d, stroke: "%s", seed: 3 })`,
   724  			`[[-10, -4], [0, 0], [-10, 4]]`,
   725  			strokeWidth,
   726  			stroke,
   727  		)
   728  	case d2target.TriangleArrowhead:
   729  		arrowJS = fmt.Sprintf(
   730  			`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 2 })`,
   731  			`[[-10, -4], [0, 0], [-10, 4]]`,
   732  			strokeWidth,
   733  			stroke,
   734  			stroke,
   735  		)
   736  	case d2target.UnfilledTriangleArrowhead:
   737  		arrowJS = fmt.Sprintf(
   738  			`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 2 })`,
   739  			`[[-10, -4], [0, 0], [-10, 4]]`,
   740  			strokeWidth,
   741  			stroke,
   742  			BG_COLOR,
   743  		)
   744  	case d2target.DiamondArrowhead:
   745  		arrowJS = fmt.Sprintf(
   746  			`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1 })`,
   747  			`[[-20, 0], [-10, 5], [0, 0], [-10, -5], [-20, 0]]`,
   748  			strokeWidth,
   749  			stroke,
   750  			BG_COLOR,
   751  		)
   752  	case d2target.FilledDiamondArrowhead:
   753  		arrowJS = fmt.Sprintf(
   754  			`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "zigzag", fillWeight: 4, seed: 1 })`,
   755  			`[[-20, 0], [-10, 5], [0, 0], [-10, -5], [-20, 0]]`,
   756  			strokeWidth,
   757  			stroke,
   758  			stroke,
   759  		)
   760  	case d2target.CfManyRequired:
   761  		arrowJS = fmt.Sprintf(
   762  			// TODO why does fillStyle: "zigzag" error with path
   763  			`node = rc.path(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 4, seed: 2 })`,
   764  			`"M-15,-10 -15,10 M0,10 -15,0 M0,-10 -15,0"`,
   765  			strokeWidth,
   766  			stroke,
   767  			stroke,
   768  		)
   769  	case d2target.CfMany:
   770  		arrowJS = fmt.Sprintf(
   771  			`node = rc.path(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 4, seed: 8 })`,
   772  			`"M0,10 -15,0 M0,-10 -15,0"`,
   773  			strokeWidth,
   774  			stroke,
   775  			stroke,
   776  		)
   777  		extraJS = fmt.Sprintf(
   778  			`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 1, seed: 4 })`,
   779  			strokeWidth,
   780  			stroke,
   781  			BG_COLOR,
   782  		)
   783  	case d2target.CfOneRequired:
   784  		arrowJS = fmt.Sprintf(
   785  			`node = rc.path(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 4, seed: 2 })`,
   786  			`"M-15,-10 -15,10 M-10,-10 -10,10"`,
   787  			strokeWidth,
   788  			stroke,
   789  			stroke,
   790  		)
   791  	case d2target.CfOne:
   792  		arrowJS = fmt.Sprintf(
   793  			`node = rc.path(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 4, seed: 3 })`,
   794  			`"M-10,-10 -10,10"`,
   795  			strokeWidth,
   796  			stroke,
   797  			stroke,
   798  		)
   799  		extraJS = fmt.Sprintf(
   800  			`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 1, seed: 5 })`,
   801  			strokeWidth,
   802  			stroke,
   803  			BG_COLOR,
   804  		)
   805  	}
   806  	return
   807  }
   808  
   809  func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.Point) (string, error) {
   810  	arrowPaths := []string{}
   811  
   812  	if connection.SrcArrow != d2target.NoArrowhead {
   813  		arrowJS, extraJS := ArrowheadJS(r, connection.SrcArrow, connection.Stroke, connection.StrokeWidth)
   814  		if arrowJS == "" {
   815  			return "", nil
   816  		}
   817  
   818  		startingSegment := geo.NewSegment(connection.Route[0], connection.Route[1])
   819  		startingVector := startingSegment.ToVector().Reverse()
   820  		angle := startingVector.Degrees()
   821  
   822  		transform := fmt.Sprintf(`transform="translate(%f %f) rotate(%v)"`,
   823  			startingSegment.Start.X+srcAdj.X, startingSegment.Start.Y+srcAdj.Y, angle,
   824  		)
   825  
   826  		roughPaths, err := computeRoughPaths(r, arrowJS)
   827  		if err != nil {
   828  			return "", err
   829  		}
   830  		if extraJS != "" {
   831  			extraPaths, err := computeRoughPaths(r, extraJS)
   832  			if err != nil {
   833  				return "", err
   834  			}
   835  			roughPaths = append(roughPaths, extraPaths...)
   836  		}
   837  
   838  		pathEl := d2themes.NewThemableElement("path")
   839  		pathEl.ClassName = "connection"
   840  		pathEl.Attributes = transform
   841  		for _, rp := range roughPaths {
   842  			pathEl.D = rp.Attrs.D
   843  			pathEl.Fill = rp.Style.Fill
   844  			pathEl.Stroke = rp.Style.Stroke
   845  			pathEl.Style = rp.StyleCSS()
   846  			arrowPaths = append(arrowPaths, pathEl.Render())
   847  		}
   848  	}
   849  
   850  	if connection.DstArrow != d2target.NoArrowhead {
   851  		arrowJS, extraJS := ArrowheadJS(r, connection.DstArrow, connection.Stroke, connection.StrokeWidth)
   852  		if arrowJS == "" {
   853  			return "", nil
   854  		}
   855  
   856  		length := len(connection.Route)
   857  		endingSegment := geo.NewSegment(connection.Route[length-2], connection.Route[length-1])
   858  		endingVector := endingSegment.ToVector()
   859  		angle := endingVector.Degrees()
   860  
   861  		transform := fmt.Sprintf(`transform="translate(%f %f) rotate(%v)"`,
   862  			endingSegment.End.X+dstAdj.X, endingSegment.End.Y+dstAdj.Y, angle,
   863  		)
   864  
   865  		roughPaths, err := computeRoughPaths(r, arrowJS)
   866  		if err != nil {
   867  			return "", err
   868  		}
   869  		if extraJS != "" {
   870  			extraPaths, err := computeRoughPaths(r, extraJS)
   871  			if err != nil {
   872  				return "", err
   873  			}
   874  			roughPaths = append(roughPaths, extraPaths...)
   875  		}
   876  
   877  		pathEl := d2themes.NewThemableElement("path")
   878  		pathEl.ClassName = "connection"
   879  		pathEl.Attributes = transform
   880  		for _, rp := range roughPaths {
   881  			pathEl.D = rp.Attrs.D
   882  			pathEl.Fill = rp.Style.Fill
   883  			pathEl.Stroke = rp.Style.Stroke
   884  			pathEl.Style = rp.StyleCSS()
   885  			arrowPaths = append(arrowPaths, pathEl.Render())
   886  		}
   887  	}
   888  
   889  	return strings.Join(arrowPaths, " "), nil
   890  }
   891  

View as plain text