From 3e4397f6b685c6deffff31118660ee733c99b807 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Tue, 11 Mar 2025 15:30:05 -0600 Subject: [PATCH] d2svg: implement legend --- d2renderers/d2svg/d2svg.go | 340 ++ d2renderers/d2svg/d2svg.go-e | 2992 +++++++++++++++++ d2target/d2target.go | 10 + .../txtar/legend/dagre/board.exp.json | 725 ++++ .../txtar/legend/dagre/sketch.exp.svg | 106 + .../testdata/txtar/legend/elk/board.exp.json | 684 ++++ .../testdata/txtar/legend/elk/sketch.exp.svg | 106 + e2etests/txtar.txt | 42 + 8 files changed, 5005 insertions(+) create mode 100644 d2renderers/d2svg/d2svg.go-e create mode 100644 e2etests/testdata/txtar/legend/dagre/board.exp.json create mode 100644 e2etests/testdata/txtar/legend/dagre/sketch.exp.svg create mode 100644 e2etests/testdata/txtar/legend/elk/board.exp.json create mode 100644 e2etests/testdata/txtar/legend/elk/sketch.exp.svg diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 8c28b9e5e..1c16261c3 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -44,6 +44,13 @@ const ( DEFAULT_PADDING = 100 appendixIconRadius = 16 + + // Legend constants + LEGEND_PADDING = 20 + LEGEND_ITEM_SPACING = 15 + LEGEND_ICON_SIZE = 24 + LEGEND_FONT_SIZE = 14 + LEGEND_CORNER_PADDING = 10 ) var multipleOffset = geo.NewVector(d2target.MULTIPLE_OFFSET, -d2target.MULTIPLE_OFFSET) @@ -101,6 +108,262 @@ func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height in return left, top, width, height } +func renderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash string, theme *d2themes.Theme) error { + if diagram.Legend == nil || (len(diagram.Legend.Shapes) == 0 && len(diagram.Legend.Connections) == 0) { + return nil + } + + _, br := diagram.BoundingBox() + + ruler, err := textmeasure.NewRuler() + if err != nil { + return err + } + + totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING + maxLabelWidth := 0 + + itemCount := 0 + + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + if itemCount > 0 { + totalHeight -= LEGEND_ITEM_SPACING / 2 + } + + if itemCount > 0 && len(diagram.Legend.Connections) > 0 { + totalHeight += LEGEND_PADDING * 1.5 + } else { + totalHeight += LEGEND_PADDING * 1.2 + } + + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + legendX := br.X + LEGEND_CORNER_PADDING + tl, _ := diagram.BoundingBox() + legendY := br.Y - totalHeight + if legendY < tl.Y { + legendY = tl.Y + } + + shadowEl := d2themes.NewThemableElement("rect", theme) + shadowEl.Fill = "#F7F7FA" + shadowEl.Stroke = "#DEE1EB" + shadowEl.Style = "stroke-width: 1px; filter: drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.1))" + shadowEl.X = float64(legendX) + shadowEl.Y = float64(legendY) + shadowEl.Width = float64(legendWidth) + shadowEl.Height = float64(totalHeight) + shadowEl.Rx = 4 + fmt.Fprint(buf, shadowEl.Render()) + + legendEl := d2themes.NewThemableElement("rect", theme) + legendEl.Fill = "#ffffff" + legendEl.Stroke = "#DEE1EB" + legendEl.Style = "stroke-width: 1px" + legendEl.X = float64(legendX) + legendEl.Y = float64(legendY) + legendEl.Width = float64(legendWidth) + legendEl.Height = float64(totalHeight) + legendEl.Rx = 4 + fmt.Fprint(buf, legendEl.Render()) + + fmt.Fprintf(buf, `Legend`, + legendX+LEGEND_PADDING, legendY+LEGEND_PADDING+LEGEND_FONT_SIZE, LEGEND_FONT_SIZE+2) + + currentY := legendY + LEGEND_PADDING*2 + LEGEND_FONT_SIZE + + shapeCount := 0 + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + + iconX := legendX + LEGEND_PADDING + iconY := currentY + + shapeIcon, err := renderLegendShapeIcon(s, iconX, iconY, diagramHash, theme) + if err != nil { + return err + } + fmt.Fprint(buf, shapeIcon) + + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + + rowHeight := go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + textY := currentY + rowHeight/2 + int(float64(dims.Height)*0.3) + + fmt.Fprintf(buf, `%s`, + iconX+LEGEND_ICON_SIZE+LEGEND_PADDING, textY, LEGEND_FONT_SIZE, + html.EscapeString(s.Label)) + + currentY += rowHeight + LEGEND_ITEM_SPACING + shapeCount++ + } + + if shapeCount > 0 && len(diagram.Legend.Connections) > 0 { + currentY += LEGEND_ITEM_SPACING / 2 + + separatorEl := d2themes.NewThemableElement("line", theme) + separatorEl.X1 = float64(legendX + LEGEND_PADDING) + separatorEl.Y1 = float64(currentY) + separatorEl.X2 = float64(legendX + legendWidth - LEGEND_PADDING) + separatorEl.Y2 = float64(currentY) + separatorEl.Stroke = "#DEE1EB" + separatorEl.StrokeDashArray = "2,2" + fmt.Fprint(buf, separatorEl.Render()) + + currentY += LEGEND_ITEM_SPACING + } + + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + + iconX := legendX + LEGEND_PADDING + iconY := currentY + LEGEND_ICON_SIZE/2 + + connIcon, err := renderLegendConnectionIcon(c, iconX, iconY, theme) + if err != nil { + return err + } + fmt.Fprint(buf, connIcon) + + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + + rowHeight := go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + textY := currentY + rowHeight/2 + int(float64(dims.Height)*0.2) + + fmt.Fprintf(buf, `%s`, + iconX+LEGEND_ICON_SIZE+LEGEND_PADDING, textY, LEGEND_FONT_SIZE, + html.EscapeString(c.Label)) + + currentY += rowHeight + LEGEND_ITEM_SPACING + } + + if shapeCount > 0 && len(diagram.Legend.Connections) > 0 { + currentY += LEGEND_PADDING / 2 + } else { + currentY += LEGEND_PADDING / 4 + } + + return nil +} + +func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme *d2themes.Theme) (string, error) { + iconShape := s + const sizeFactor = 5 + iconShape.Pos.X = 0 + iconShape.Pos.Y = 0 + iconShape.Width = LEGEND_ICON_SIZE * sizeFactor + iconShape.Height = LEGEND_ICON_SIZE * sizeFactor + iconShape.Label = "" + buf := &bytes.Buffer{} + appendixBuf := &bytes.Buffer{} + finalBuf := &bytes.Buffer{} + fmt.Fprintf(finalBuf, ``, + x, y, 1.0/sizeFactor) + _, err := drawShape(buf, appendixBuf, diagramHash, iconShape, nil, theme) + if err != nil { + return "", err + } + + fmt.Fprint(finalBuf, buf.String()) + + fmt.Fprint(finalBuf, ``) + + return finalBuf.String(), nil +} + +func renderLegendConnectionIcon(c d2target.Connection, x, y int, theme *d2themes.Theme) (string, error) { + finalBuf := &bytes.Buffer{} + + buf := &bytes.Buffer{} + + const sizeFactor = 2 + + legendConn := *d2target.BaseConnection() + + legendConn.ID = c.ID + legendConn.SrcArrow = c.SrcArrow + legendConn.DstArrow = c.DstArrow + legendConn.StrokeDash = c.StrokeDash + legendConn.StrokeWidth = c.StrokeWidth + legendConn.Stroke = c.Stroke + legendConn.Fill = c.Fill + legendConn.BorderRadius = c.BorderRadius + legendConn.Opacity = c.Opacity + legendConn.Animated = c.Animated + + startX := 0.0 + midY := 0.0 + width := float64(LEGEND_ICON_SIZE * sizeFactor) + + legendConn.Route = []*geo.Point{ + {X: startX, Y: midY}, + {X: startX + width, Y: midY}, + } + + legendHash := fmt.Sprintf("legend-%s", hash(fmt.Sprintf("%s-%d-%d", c.ID, x, y))) + + markers := make(map[string]struct{}) + idToShape := make(map[string]d2target.Shape) + + fmt.Fprintf(finalBuf, ``, + x, y, 1.0/sizeFactor) + + _, err := drawConnection(buf, legendHash, legendConn, markers, idToShape, nil, theme) + if err != nil { + return "", err + } + + fmt.Fprint(finalBuf, buf.String()) + + fmt.Fprint(finalBuf, ``) + + return finalBuf.String(), nil +} + func arrowheadMarkerID(diagramHash string, isTarget bool, connection d2target.Connection) string { var arrowhead d2target.Arrowhead if isTarget { @@ -2085,8 +2348,85 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { // add all appendix items afterwards so they are always on top fmt.Fprint(buf, appendixItemBuf) + if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { + legendBuf := &bytes.Buffer{} + err := renderLegend(legendBuf, diagram, diagramHash, inlineTheme) + if err != nil { + return nil, err + } + fmt.Fprint(buf, legendBuf) + } + // Note: we always want this since we reference it on connections even if there end up being no masked labels left, top, w, h := dimensions(diagram, pad) + + if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { + tl, br := diagram.BoundingBox() + totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING + maxLabelWidth := 0 + itemCount := 0 + ruler, _ := textmeasure.NewRuler() + if ruler != nil { + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + if itemCount > 0 { + totalHeight -= LEGEND_ITEM_SPACING / 2 + } + + totalHeight += LEGEND_PADDING + + if totalHeight > 0 && maxLabelWidth > 0 { + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + + legendY := br.Y - totalHeight + if legendY < tl.Y { + legendY = tl.Y + } + + legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth + if left+w < legendRight { + w = legendRight - left + pad/2 + } + + if legendY < top { + diffY := top - legendY + top -= diffY + h += diffY + } + + legendBottom := legendY + totalHeight + if top+h < legendBottom { + h = legendBottom - top + pad/2 + } + } + } + } fmt.Fprint(buf, strings.Join([]string{ fmt.Sprintf(``, isolatedDiagramHash, left, top, w, h, diff --git a/d2renderers/d2svg/d2svg.go-e b/d2renderers/d2svg/d2svg.go-e new file mode 100644 index 000000000..e71eb3f92 --- /dev/null +++ b/d2renderers/d2svg/d2svg.go-e @@ -0,0 +1,2992 @@ +// d2svg implements an SVG renderer for d2 diagrams. +// The input is d2exporter's output +package d2svg + +import ( + "bytes" + _ "embed" + "encoding/base64" + "errors" + "fmt" + "hash/fnv" + "html" + "io" + "sort" + "strings" + + "math" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + + "oss.terrastruct.com/d2/d2ast" + "oss.terrastruct.com/d2/d2graph" + "oss.terrastruct.com/d2/d2renderers/d2fonts" + "oss.terrastruct.com/d2/d2renderers/d2latex" + "oss.terrastruct.com/d2/d2renderers/d2sketch" + "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/d2themes" + "oss.terrastruct.com/d2/d2themes/d2themescatalog" + "oss.terrastruct.com/d2/lib/color" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/jsrunner" + "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/d2/lib/shape" + "oss.terrastruct.com/d2/lib/svg" + "oss.terrastruct.com/d2/lib/textmeasure" + "oss.terrastruct.com/d2/lib/version" + "oss.terrastruct.com/util-go/go2" +) + +const ( + DEFAULT_PADDING = 100 + + appendixIconRadius = 16 + + // Legend constants + LEGEND_PADDING = 20 + LEGEND_ITEM_SPACING = 15 + LEGEND_ICON_SIZE = 24 + LEGEND_FONT_SIZE = 14 + LEGEND_CORNER_PADDING = 10 +) + +var multipleOffset = geo.NewVector(d2target.MULTIPLE_OFFSET, -d2target.MULTIPLE_OFFSET) + +//go:embed tooltip.svg +var TooltipIcon string + +//go:embed link.svg +var LinkIcon string + +//go:embed style.css +var BaseStylesheet string + +//go:embed github-markdown.css +var MarkdownCSS string + +//go:embed dots.txt +var dots string + +//go:embed lines.txt +var lines string + +//go:embed grain.txt +var grain string + +//go:embed paper.txt +var paper string + +type RenderOpts struct { + Pad *int64 + Sketch *bool + Center *bool + ThemeID *int64 + DarkThemeID *int64 + ThemeOverrides *d2target.ThemeOverrides + DarkThemeOverrides *d2target.ThemeOverrides + Font string + // the svg will be scaled by this factor, if unset the svg will fit to screen + Scale *float64 + + // MasterID is passed when the diagram should use something other than its own hash for unique targeting + // Currently, that's when multi-boards are collapsed + MasterID string + NoXMLTag *bool + Salt *string +} + +func dimensions(diagram *d2target.Diagram, pad int) (left, top, width, height int) { + tl, br := diagram.BoundingBox() + left = tl.X - pad + top = tl.Y - pad + width = br.X - tl.X + pad*2 + height = br.Y - tl.Y + pad*2 + + return left, top, width, height +} + +// renderLegend renders the legend box in the bottom right of the diagram +func renderLegend(buf *bytes.Buffer, diagram *d2target.Diagram, diagramHash string, theme *d2themes.Theme) error { + if diagram.Legend == nil || (len(diagram.Legend.Shapes) == 0 && len(diagram.Legend.Connections) == 0) { + return nil + } + + _, br := diagram.BoundingBox() + + // Create a ruler to measure text + ruler, err := textmeasure.NewRuler() + if err != nil { + return err + } + + // Calculate total height of legend items + // Start with top padding and title height + totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING + maxLabelWidth := 0 + + // Track number of items for precise height calculation + itemCount := 0 + + // Measure text for each legend item to determine dimensions + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + // If we have items, remove the extra spacing from the last item and add bottom padding + if itemCount > 0 { + totalHeight -= LEGEND_ITEM_SPACING / 2 // Remove some of the last spacing + } + + // Add bottom padding + totalHeight += LEGEND_PADDING + + // Calculate legend dimensions + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + + // Position legend to the right of the diagram with padding + // Add extra padding to ensure it doesn't overlap with diagram elements + legendX := br.X + LEGEND_CORNER_PADDING + // Center vertically using the bounding box, but ensure it's not too close to the top + tl, _ := diagram.BoundingBox() + legendY := tl.Y + (br.Y-tl.Y-totalHeight)/2 + if legendY < tl.Y { + legendY = tl.Y + } + + // Draw legend background with subtle shadow for better visual separation + // Add shadow/outline effect first + shadowEl := d2themes.NewThemableElement("rect", theme) + shadowEl.Fill = "#F7F7FA" + shadowEl.Stroke = "#DEE1EB" + shadowEl.Style = "stroke-width: 1px; filter: drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.1))" + shadowEl.X = float64(legendX) + shadowEl.Y = float64(legendY) + shadowEl.Width = float64(legendWidth) + shadowEl.Height = float64(totalHeight) + shadowEl.Rx = 4 + fmt.Fprint(buf, shadowEl.Render()) + + // Draw legend background + legendEl := d2themes.NewThemableElement("rect", theme) + legendEl.Fill = "#ffffff" + legendEl.Stroke = "#DEE1EB" + legendEl.Style = "stroke-width: 1px" + legendEl.X = float64(legendX) + legendEl.Y = float64(legendY) + legendEl.Width = float64(legendWidth) + legendEl.Height = float64(totalHeight) + legendEl.Rx = 4 + fmt.Fprint(buf, legendEl.Render()) + + // Draw legend title + fmt.Fprintf(buf, `Legend`, + legendX+LEGEND_PADDING, legendY+LEGEND_PADDING+LEGEND_FONT_SIZE, LEGEND_FONT_SIZE+2) + + // Current Y position for drawing items + currentY := legendY + LEGEND_PADDING*2 + LEGEND_FONT_SIZE + + // Draw legend shapes + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + + iconX := legendX + LEGEND_PADDING + iconY := currentY + + // Draw shape as icon + shapeIcon, err := renderLegendShapeIcon(s, iconX, iconY, diagramHash, theme) + if err != nil { + return err + } + fmt.Fprint(buf, shapeIcon) + + // Draw label + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + textY := currentY + dims.Height/2 + + fmt.Fprintf(buf, `%s`, + iconX+LEGEND_ICON_SIZE+LEGEND_PADDING, textY, LEGEND_FONT_SIZE, + html.EscapeString(s.Label)) + + currentY += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + } + + // Draw legend connections + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + + iconX := legendX + LEGEND_PADDING + iconY := currentY + LEGEND_ICON_SIZE/2 + + // Draw connection as line + connIcon, err := renderLegendConnectionIcon(c, iconX, iconY, theme) + if err != nil { + return err + } + fmt.Fprint(buf, connIcon) + + // Draw label + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + textY := currentY + dims.Height/2 + + fmt.Fprintf(buf, `%s`, + iconX+LEGEND_ICON_SIZE+LEGEND_PADDING, textY, LEGEND_FONT_SIZE, + html.EscapeString(c.Label)) + + currentY += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + } + + return nil +} + +// renderLegendShapeIcon creates a small representation of a shape for the legend +// This reuses the existing shape drawing code +func renderLegendShapeIcon(s d2target.Shape, x, y int, diagramHash string, theme *d2themes.Theme) (string, error) { + // Create a miniature version of the shape + iconShape := s + + // Set the position and size for the legend icon + iconShape.X = x + iconShape.Y = y + iconShape.Width = LEGEND_ICON_SIZE + iconShape.Height = LEGEND_ICON_SIZE + + // Remove the label since we don't want it in the icon + iconShape.Label = "" + + // Create wrappers to capture the rendered SVG + buf := &bytes.Buffer{} + appendixBuf := &bytes.Buffer{} + + // Use the existing drawShape function to render the shape with its proper appearance + _, err := drawShape(buf, appendixBuf, diagramHash, iconShape, nil, theme) + if err != nil { + return "", err + } + + return buf.String(), nil +} + +// renderLegendConnectionIcon creates a small representation of a connection for the legend +func renderLegendConnectionIcon(c d2target.Connection, x, y int, theme *d2themes.Theme) (string, error) { + buf := &bytes.Buffer{} + + // Default stroke width and color + strokeWidth := 2.0 + + // Default color + strokeColor := "#000000" + if c.Stroke != "" { + strokeColor = c.Stroke + } else if theme != nil { + strokeColor = theme.Colors.B4 // Use a theme color for connections + } + + // Draw simple line to represent connection + startX := float64(x) + endX := float64(x + LEGEND_ICON_SIZE) + midY := float64(y) + + fmt.Fprintf(buf, ``, + startX, midY, endX, midY, strokeColor, strokeWidth) + + // Add arrowhead if needed - check if either has an arrowhead that's not "none" + if c.SrcArrow != "none" || c.DstArrow != "none" { + // Draw destination arrowhead (right side) + fmt.Fprintf(buf, ``, + endX-6, midY-3, endX, midY, endX-6, midY+3, strokeColor) + } + + return buf.String(), nil +} + +// renderSimplifiedShape creates a simplified version of a shape for the legend +func renderSimplifiedShape(buf *bytes.Buffer, s d2target.Shape, theme *d2themes.Theme) error { + // Get fill and stroke colors + fillColor := "#E8EAF1" // Default fill color + strokeColor := "#CED3E5" // Default stroke color + + if s.Fill != "" { + fillColor = s.Fill + } else if theme != nil { + fillColor = theme.Colors.B5 // Use a theme color for fills + } + + if s.Stroke != "" { + strokeColor = s.Stroke + } else if theme != nil { + strokeColor = theme.Colors.B4 // Use a theme color for strokes + } + + // Create a simplified shape representation based on shape type + switch s.Type { + case d2target.ShapeCircle: + // Circle + circleEl := d2themes.NewThemableElement("circle", theme) + circleEl.Fill = fillColor + circleEl.Stroke = strokeColor + circleEl.Style = "stroke-width: 1px" + circleEl.Cx = float64(LEGEND_ICON_SIZE) / 2 + circleEl.Cy = float64(LEGEND_ICON_SIZE) / 2 + circleEl.R = float64(LEGEND_ICON_SIZE)/2 - 2 + fmt.Fprint(buf, circleEl.Render()) + + case d2target.ShapeDiamond: + // Diamond shape using a simple polygon + size := float64(LEGEND_ICON_SIZE) + halfSize := size / 2 + + diamondEl := d2themes.NewThemableElement("polygon", theme) + diamondEl.Fill = fillColor + diamondEl.Stroke = strokeColor + diamondEl.Style = "stroke-width: 1px" + diamondEl.Points = fmt.Sprintf("%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f", + halfSize, 2.0, // top + size-2.0, halfSize, // right + halfSize, size-2.0, // bottom + 2.0, halfSize) // left + fmt.Fprint(buf, diamondEl.Render()) + + case d2target.ShapeCylinder: + // Simplified cylinder + fmt.Fprintf(buf, ``) + + // Main rectangle part (body of cylinder) + rectEl := d2themes.NewThemableElement("rect", theme) + rectEl.Fill = fillColor + rectEl.Stroke = strokeColor + rectEl.Style = "stroke-width: 1px" + rectEl.X = 2 + rectEl.Y = 6 + rectEl.Width = float64(LEGEND_ICON_SIZE - 4) + rectEl.Height = float64(LEGEND_ICON_SIZE - 10) + fmt.Fprint(buf, rectEl.Render()) + + // Top ellipse + ellipseEl := d2themes.NewThemableElement("ellipse", theme) + ellipseEl.Fill = fillColor + ellipseEl.Stroke = strokeColor + ellipseEl.Style = "stroke-width: 1px" + ellipseEl.Cx = float64(LEGEND_ICON_SIZE) / 2 + ellipseEl.Cy = 6 + ellipseEl.Rx = float64(LEGEND_ICON_SIZE-4) / 2 + ellipseEl.Ry = 3 + fmt.Fprint(buf, ellipseEl.Render()) + + fmt.Fprintf(buf, ``) + + default: + // Default rectangle for all other shapes + rectEl := d2themes.NewThemableElement("rect", theme) + rectEl.Fill = fillColor + rectEl.Stroke = strokeColor + rectEl.Style = "stroke-width: 1px" + rectEl.X = 2 + rectEl.Y = 2 + rectEl.Width = float64(LEGEND_ICON_SIZE - 4) + rectEl.Height = float64(LEGEND_ICON_SIZE - 4) + + // Apply borderRadius appropriately + if s.Type != d2target.ShapeSquare && s.Type != d2target.ShapeRectangle { + rectEl.Rx = 4 + } + + fmt.Fprint(buf, rectEl.Render()) + } + + return nil +} + +func arrowheadMarkerID(diagramHash string, isTarget bool, connection d2target.Connection) string { + var arrowhead d2target.Arrowhead + if isTarget { + arrowhead = connection.DstArrow + } else { + arrowhead = connection.SrcArrow + } + + return fmt.Sprintf("mk-%s-%s", diagramHash, hash(fmt.Sprintf("%s,%t,%d,%s", + arrowhead, isTarget, connection.StrokeWidth, connection.Stroke, + ))) +} + +func arrowheadMarker(isTarget bool, id string, connection d2target.Connection, inlineTheme *d2themes.Theme) string { + arrowhead := connection.DstArrow + if !isTarget { + arrowhead = connection.SrcArrow + } + strokeWidth := float64(connection.StrokeWidth) + width, height := arrowhead.Dimensions(strokeWidth) + + var path string + switch arrowhead { + case d2target.ArrowArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.Fill = connection.Stroke + polygonEl.ClassName = "connection" + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., 0., + width, height/2, + 0., height, + width/4, height/2, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., height/2, + width, 0., + width*3/4, height/2, + width, height, + ) + } + path = polygonEl.Render() + case d2target.UnfilledTriangleArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.Fill = d2target.BG_COLOR + polygonEl.Stroke = connection.Stroke + polygonEl.ClassName = "connection" + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + inset := strokeWidth / 2 + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + inset, inset, + width-inset, height/2.0, + inset, height-inset, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + width-inset, inset, + inset, height/2.0, + width-inset, height-inset, + ) + } + path = polygonEl.Render() + + case d2target.TriangleArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.Fill = connection.Stroke + polygonEl.ClassName = "connection" + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + 0., 0., + width, height/2.0, + 0., height, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + width, 0., + 0., height/2.0, + width, height, + ) + } + path = polygonEl.Render() + case d2target.LineArrowhead: + polylineEl := d2themes.NewThemableElement("polyline", inlineTheme) + polylineEl.Fill = color.None + polylineEl.ClassName = "connection" + polylineEl.Stroke = connection.Stroke + polylineEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polylineEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + strokeWidth/2, strokeWidth/2, + width-strokeWidth/2, height/2, + strokeWidth/2, height-strokeWidth/2, + ) + } else { + polylineEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f", + width-strokeWidth/2, strokeWidth/2, + strokeWidth/2, height/2, + width-strokeWidth/2, height-strokeWidth/2, + ) + } + path = polylineEl.Render() + case d2target.FilledDiamondArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.ClassName = "connection" + polygonEl.Fill = connection.Stroke + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., height/2.0, + width/2.0, 0., + width, height/2.0, + width/2.0, height, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., height/2.0, + width/2.0, 0., + width, height/2.0, + width/2.0, height, + ) + } + path = polygonEl.Render() + case d2target.DiamondArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.ClassName = "connection" + polygonEl.Fill = d2target.BG_COLOR + polygonEl.Stroke = connection.Stroke + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., height/2.0, + width/2, height/8, + width, height/2.0, + width/2.0, height*0.9, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + width/8, height/2.0, + width*0.6, height/8, + width*1.1, height/2.0, + width*0.6, height*7/8, + ) + } + path = polygonEl.Render() + case d2target.FilledCircleArrowhead: + radius := width / 2 + + circleEl := d2themes.NewThemableElement("circle", inlineTheme) + circleEl.Cy = radius + circleEl.R = radius - strokeWidth/2 + circleEl.Fill = connection.Stroke + circleEl.ClassName = "connection" + circleEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + circleEl.Cx = radius + strokeWidth/2 + } else { + circleEl.Cx = radius - strokeWidth/2 + } + + path = circleEl.Render() + case d2target.CircleArrowhead: + radius := width / 2 + + circleEl := d2themes.NewThemableElement("circle", inlineTheme) + circleEl.Cy = radius + circleEl.R = radius - strokeWidth + circleEl.Fill = d2target.BG_COLOR + circleEl.Stroke = connection.Stroke + circleEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + circleEl.Cx = radius + strokeWidth/2 + } else { + circleEl.Cx = radius - strokeWidth/2 + } + + path = circleEl.Render() + case d2target.FilledBoxArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.ClassName = "connection" + polygonEl.Fill = connection.Stroke + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., 0., + 0., height, + width, height, + width, 0., + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + 0., 0., + 0., height, + width, height, + width, 0., + ) + } + + path = polygonEl.Render() + case d2target.BoxArrowhead: + polygonEl := d2themes.NewThemableElement("polygon", inlineTheme) + polygonEl.ClassName = "connection" + polygonEl.Fill = d2target.BG_COLOR + polygonEl.Stroke = connection.Stroke + polygonEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + polygonEl.Style = fmt.Sprintf("%sstroke-linejoin:miter;", polygonEl.Style) + + inset := strokeWidth / 2 + if isTarget { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + inset, inset, + inset, height-inset, + width-inset, height-inset, + width-inset, inset, + ) + } else { + polygonEl.Points = fmt.Sprintf("%f,%f %f,%f %f,%f %f,%f", + inset, inset, + inset, height-inset, + width-inset, height-inset, + width-inset, inset, + ) + } + path = polygonEl.Render() + case d2target.CfOne, d2target.CfMany, d2target.CfOneRequired, d2target.CfManyRequired: + offset := 3.0 + float64(connection.StrokeWidth)*1.8 + + var modifierEl *d2themes.ThemableElement + if arrowhead == d2target.CfOneRequired || arrowhead == d2target.CfManyRequired { + modifierEl = d2themes.NewThemableElement("path", inlineTheme) + modifierEl.D = fmt.Sprintf("M%f,%f %f,%f", + offset, 0., + offset, height, + ) + modifierEl.Fill = d2target.BG_COLOR + modifierEl.Stroke = connection.Stroke + modifierEl.ClassName = "connection" + modifierEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + } else { + modifierEl = d2themes.NewThemableElement("circle", inlineTheme) + modifierEl.Cx = offset/2.0 + 2.0 + modifierEl.Cy = height / 2.0 + modifierEl.R = offset / 2.0 + modifierEl.Fill = d2target.BG_COLOR + modifierEl.Stroke = connection.Stroke + modifierEl.ClassName = "connection" + modifierEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + } + + childPathEl := d2themes.NewThemableElement("path", inlineTheme) + if arrowhead == d2target.CfMany || arrowhead == d2target.CfManyRequired { + childPathEl.D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f M%f,%f %f,%f", + width-3.0, height/2.0, + width+offset, height/2.0, + offset+3.0, height/2.0, + width+offset, 0., + offset+3.0, height/2.0, + width+offset, height, + ) + } else { + childPathEl.D = fmt.Sprintf("M%f,%f %f,%f M%f,%f %f,%f", + width-3.0, height/2.0, + width+offset, height/2.0, + offset*2.0, 0., + offset*2.0, height, + ) + } + + gEl := d2themes.NewThemableElement("g", inlineTheme) + if !isTarget { + gEl.Transform = fmt.Sprintf("scale(-1) translate(-%f, -%f)", width, height) + } + gEl.Fill = d2target.BG_COLOR + gEl.Stroke = connection.Stroke + gEl.ClassName = "connection" + gEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, connection.StrokeWidth) + gEl.Content = fmt.Sprintf("%s%s", + modifierEl.Render(), childPathEl.Render(), + ) + path = gEl.Render() + 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 { + refX = width - 1.5*strokeWidth + } else { + refX = 1.5 * strokeWidth + } + } + + return strings.Join([]string{ + fmt.Sprintf(``, + path, + "", + }, " ") +} + +// compute the (dx, dy) adjustment to apply to get the arrowhead-adjusted end point +func arrowheadAdjustment(start, end *geo.Point, arrowhead d2target.Arrowhead, edgeStrokeWidth, shapeStrokeWidth int) *geo.Point { + distance := (float64(edgeStrokeWidth) + float64(shapeStrokeWidth)) / 2.0 + if arrowhead != d2target.NoArrowhead { + distance += float64(edgeStrokeWidth) + } + + v := geo.NewVector(end.X-start.X, end.Y-start.Y) + return v.Unit().Multiply(-distance).ToPoint() +} + +func getArrowheadAdjustments(connection d2target.Connection, idToShape map[string]d2target.Shape) (srcAdj, dstAdj *geo.Point) { + route := connection.Route + srcShape := idToShape[connection.Src] + dstShape := idToShape[connection.Dst] + + sourceAdjustment := arrowheadAdjustment(route[1], route[0], connection.SrcArrow, connection.StrokeWidth, srcShape.StrokeWidth) + + targetAdjustment := arrowheadAdjustment(route[len(route)-2], route[len(route)-1], connection.DstArrow, connection.StrokeWidth, dstShape.StrokeWidth) + return sourceAdjustment, targetAdjustment +} + +// returns the path's d attribute for the given connection +func pathData(connection d2target.Connection, srcAdj, dstAdj *geo.Point) string { + var path []string + route := connection.Route + + path = append(path, fmt.Sprintf("M %f %f", + route[0].X+srcAdj.X, + route[0].Y+srcAdj.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 + 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+dstAdj.X, + route[i+2].Y+dstAdj.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) + + connectionBorderRadius := connection.BorderRadius + units := math.Min(connectionBorderRadius, 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 < connectionBorderRadius && 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] + path = append(path, fmt.Sprintf("L %f %f", + lastPoint.X+dstAdj.X, + lastPoint.Y+dstAdj.Y, + )) + } + + return strings.Join(path, " ") +} + +func makeLabelMask(labelTL *geo.Point, width, height int, opacity float64) string { + fill := "black" + if opacity != 1 { + fill = fmt.Sprintf("rgba(0,0,0,%.2f)", opacity) + } + return fmt.Sprintf(``, + labelTL.X, labelTL.Y, + width, + height, + fill, + ) +} + +func drawConnection(writer io.Writer, diagramHash string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, jsRunner jsrunner.JSRunner, inlineTheme *d2themes.Theme) (labelMask string, _ error) { + opacityStyle := "" + if connection.Opacity != 1.0 { + opacityStyle = fmt.Sprintf(" style='opacity:%f'", connection.Opacity) + } + + classes := []string{base64.URLEncoding.EncodeToString([]byte(svg.EscapeText(connection.ID)))} + classes = append(classes, connection.Classes...) + classStr := fmt.Sprintf(` class="%s"`, strings.Join(classes, " ")) + + fmt.Fprintf(writer, ``, classStr, opacityStyle) + var markerStart string + if connection.SrcArrow != d2target.NoArrowhead { + id := arrowheadMarkerID(diagramHash, false, connection) + if _, in := markers[id]; !in { + marker := arrowheadMarker(false, id, connection, inlineTheme) + 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(diagramHash, true, connection) + if _, in := markers[id]; !in { + marker := arrowheadMarker(true, id, connection, inlineTheme) + 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) + } + + if connection.Icon != nil { + iconPos := connection.GetIconPosition() + if iconPos != nil { + fmt.Fprintf(writer, ``, + html.EscapeString(connection.Icon.String()), + iconPos.X, + iconPos.Y, + d2target.DEFAULT_ICON_SIZE, + d2target.DEFAULT_ICON_SIZE, + ) + } + } + + var labelTL *geo.Point + if connection.Label != "" { + labelTL = connection.GetLabelTopLeft() + labelTL.X = math.Round(labelTL.X) + labelTL.Y = math.Round(labelTL.Y) + + maskTL := labelTL.Copy() + width := connection.LabelWidth + height := connection.LabelHeight + + if connection.Icon != nil { + width += d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE + maskTL.X -= float64(d2target.CONNECTION_ICON_LABEL_GAP + d2target.DEFAULT_ICON_SIZE) + } + + if label.FromString(connection.LabelPosition).IsOnEdge() { + labelMask = makeLabelMask(maskTL, width, height, 1) + } else { + labelMask = makeLabelMask(maskTL, width, height, 0.75) + } + } else if connection.Icon != nil { + iconPos := connection.GetIconPosition() + if iconPos != nil { + maskTL := &geo.Point{ + X: iconPos.X, + Y: iconPos.Y, + } + if label.FromString(connection.IconPosition).IsOnEdge() { + labelMask = makeLabelMask(maskTL, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 1) + } else { + labelMask = makeLabelMask(maskTL, d2target.DEFAULT_ICON_SIZE, d2target.DEFAULT_ICON_SIZE, 0.75) + } + } + } + + srcAdj, dstAdj := getArrowheadAdjustments(connection, idToShape) + path := pathData(connection, srcAdj, dstAdj) + mask := fmt.Sprintf(`mask="url(#%s)"`, diagramHash) + + if jsRunner != nil { + out, err := d2sketch.Connection(jsRunner, connection, path, mask) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + + // render sketch arrowheads separately + arrowPaths, err := d2sketch.Arrowheads(jsRunner, connection, srcAdj, dstAdj) + if err != nil { + return "", err + } + fmt.Fprint(writer, arrowPaths) + } else { + animatedClass := "" + if connection.Animated { + animatedClass = " animated-connection" + } + + // If connection is animated and bidirectional + if connection.Animated && ((connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead)) { + // There is no pure CSS way to animate bidirectional connections in two directions, so we split it up + path1, path2, err := svg.SplitPath(path, 0.5) + + if err != nil { + return "", err + } + + pathEl1 := d2themes.NewThemableElement("path", inlineTheme) + pathEl1.D = path1 + pathEl1.Fill = color.None + pathEl1.Stroke = connection.Stroke + pathEl1.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl1.Style = connection.CSSStyle() + pathEl1.Style += "animation-direction: reverse;" + pathEl1.Attributes = fmt.Sprintf("%s%s", markerStart, mask) + fmt.Fprint(writer, pathEl1.Render()) + + pathEl2 := d2themes.NewThemableElement("path", inlineTheme) + pathEl2.D = path2 + pathEl2.Fill = color.None + pathEl2.Stroke = connection.Stroke + pathEl2.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl2.Style = connection.CSSStyle() + pathEl2.Attributes = fmt.Sprintf("%s%s", markerEnd, mask) + fmt.Fprint(writer, pathEl2.Render()) + } else { + pathEl := d2themes.NewThemableElement("path", inlineTheme) + pathEl.D = path + pathEl.Fill = color.None + pathEl.Stroke = connection.Stroke + pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl.Style = connection.CSSStyle() + pathEl.Attributes = fmt.Sprintf("%s%s%s", markerStart, markerEnd, mask) + fmt.Fprint(writer, pathEl.Render()) + } + } + + if connection.Label != "" { + fontClass := "text" + if connection.FontFamily == "mono" { + fontClass = "text-mono" + } + if connection.Bold { + fontClass += "-bold" + } else if connection.Italic { + fontClass += "-italic" + } + if connection.Underline { + fontClass += " text-underline" + } + if connection.Fill != color.Empty { + rectEl := d2themes.NewThemableElement("rect", inlineTheme) + rectEl.Rx = 10 + rectEl.X, rectEl.Y = labelTL.X-4, labelTL.Y-3 + rectEl.Width, rectEl.Height = float64(connection.LabelWidth)+8, float64(connection.LabelHeight)+6 + rectEl.Fill = connection.Fill + fmt.Fprint(writer, rectEl.Render()) + } + + textEl := d2themes.NewThemableElement("text", inlineTheme) + textEl.X = labelTL.X + float64(connection.LabelWidth)/2 + textEl.Y = labelTL.Y + float64(connection.FontSize) + textEl.ClassName = fontClass + textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "middle", connection.FontSize) + textEl.Content = RenderText(connection.Label, textEl.X, float64(connection.LabelHeight)) + + if connection.Link != "" { + textEl.ClassName += " text-underline text-link" + + fmt.Fprintf(writer, ``, svg.EscapeText(connection.Link)) + } else { + textEl.Fill = connection.GetFontColor() + } + + fmt.Fprint(writer, textEl.Render()) + + if connection.Link != "" { + fmt.Fprintf(writer, "") + } + } + + if connection.SrcLabel != nil && connection.SrcLabel.Label != "" { + fmt.Fprint(writer, renderArrowheadLabel(connection, connection.SrcLabel.Label, false, inlineTheme)) + } + if connection.DstLabel != nil && connection.DstLabel.Label != "" { + fmt.Fprint(writer, renderArrowheadLabel(connection, connection.DstLabel.Label, true, inlineTheme)) + } + fmt.Fprintf(writer, ``) + return +} + +func renderArrowheadLabel(connection d2target.Connection, text string, isDst bool, inlineTheme *d2themes.Theme) string { + var width, height float64 + if isDst { + width = float64(connection.DstLabel.LabelWidth) + height = float64(connection.DstLabel.LabelHeight) + } else { + width = float64(connection.SrcLabel.LabelWidth) + height = float64(connection.SrcLabel.LabelHeight) + } + + labelTL := connection.GetArrowheadLabelPosition(isDst) + + // svg text is positioned with the center of its baseline + baselineCenter := geo.Point{ + X: labelTL.X + width/2., + Y: labelTL.Y + float64(connection.FontSize), + } + + textEl := d2themes.NewThemableElement("text", inlineTheme) + textEl.X = baselineCenter.X + textEl.Y = baselineCenter.Y + textEl.Fill = d2target.FG_COLOR + if isDst { + if connection.DstLabel.Color != "" { + textEl.Fill = connection.DstLabel.Color + } + } else { + if connection.SrcLabel.Color != "" { + textEl.Fill = connection.SrcLabel.Color + } + } + textEl.ClassName = "text-italic" + textEl.Style = fmt.Sprintf("text-anchor:middle;font-size:%vpx", connection.FontSize) + textEl.Content = RenderText(text, textEl.X, height) + return textEl.Render() +} + +func renderOval(tl *geo.Point, width, height float64, fill, fillPattern, stroke, style string, inlineTheme *d2themes.Theme) string { + el := d2themes.NewThemableElement("ellipse", inlineTheme) + el.Rx = width / 2 + el.Ry = height / 2 + el.Cx = tl.X + el.Rx + el.Cy = tl.Y + el.Ry + el.Fill, el.Stroke = fill, stroke + el.FillPattern = fillPattern + el.ClassName = "shape" + el.Style = style + return el.Render() +} + +func renderDoubleOval(tl *geo.Point, width, height float64, fill, fillStroke, stroke, style string, inlineTheme *d2themes.Theme) string { + var innerTL *geo.Point = tl.AddVector(geo.NewVector(d2target.INNER_BORDER_OFFSET, d2target.INNER_BORDER_OFFSET)) + return renderOval(tl, width, height, fill, fillStroke, stroke, style, inlineTheme) + renderOval(innerTL, width-10, height-10, fill, "", stroke, style, inlineTheme) +} + +func defineGradients(writer io.Writer, cssGradient string) { + gradient, _ := color.ParseGradient(cssGradient) + fmt.Fprint(writer, fmt.Sprintf(`%s`, color.GradientToSVG(gradient))) +} + +func defineShadowFilter(writer io.Writer) { + fmt.Fprint(writer, ` + + + + + + + +`) +} + +func render3DRect(diagramHash string, targetShape d2target.Shape, inlineTheme *d2themes.Theme) string { + moveTo := func(p d2target.Point) string { + return fmt.Sprintf("M%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y) + } + lineTo := func(p d2target.Point) string { + return fmt.Sprintf("L%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y) + } + + // 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: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - d2target.THREE_DEE_OFFSET}, + {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 + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}), + ) + border := d2themes.NewThemableElement("path", inlineTheme) + border.D = strings.Join(borderSegments, " ") + border.Fill = color.None + _, borderStroke := d2themes.ShapeTheme(targetShape) + border.Stroke = borderStroke + borderStyle := targetShape.CSSStyle() + border.Style = borderStyle + renderedBorder := border.Render() + + // create mask from border stroke, to cut away from the shape fills + maskID := fmt.Sprintf("border-mask-%v-%v", diagramHash, svg.EscapeText(targetShape.ID)) + borderMask := strings.Join([]string{ + fmt.Sprintf(``, + maskID, targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET, + ), + fmt.Sprintf(``, + targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET, + ), + fmt.Sprintf(``, + strings.Join(borderSegments, ""), borderStyle), + }, "\n") + + // render the main rectangle without stroke and the border mask + mainShape := d2themes.NewThemableElement("rect", inlineTheme) + mainShape.X = float64(targetShape.Pos.X) + mainShape.Y = float64(targetShape.Pos.Y) + mainShape.Width = float64(targetShape.Width) + mainShape.Height = float64(targetShape.Height) + mainShape.SetMaskUrl(maskID) + mainShapeFill, _ := d2themes.ShapeTheme(targetShape) + mainShape.Fill = mainShapeFill + mainShape.FillPattern = targetShape.FillPattern + mainShape.Stroke = color.None + mainShape.Style = targetShape.CSSStyle() + mainShapeRendered := mainShape.Render() + + // 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: d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: -d2target.THREE_DEE_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - d2target.THREE_DEE_OFFSET}, + {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), + ) + } + darkerColor, err := color.Darken(targetShape.Fill) + if err != nil { + darkerColor = targetShape.Fill + } + sideShape := d2themes.NewThemableElement("polygon", inlineTheme) + sideShape.Fill = darkerColor + sideShape.Points = strings.Join(sidePoints, " ") + sideShape.SetMaskUrl(maskID) + sideShape.Style = targetShape.CSSStyle() + renderedSides := sideShape.Render() + + return borderMask + mainShapeRendered + renderedSides + renderedBorder +} + +func render3DHexagon(diagramHash string, targetShape d2target.Shape, inlineTheme *d2themes.Theme) string { + moveTo := func(p d2target.Point) string { + return fmt.Sprintf("M%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y) + } + lineTo := func(p d2target.Point) string { + return fmt.Sprintf("L%d,%d", p.X+targetShape.Pos.X, p.Y+targetShape.Pos.Y) + } + scale := func(n int, f float64) int { + return int(float64(n) * f) + } + halfYFactor := 43.6 / 87.3 + + // draw border all in one path to prevent overlapping sections + var borderSegments []string + // start from the top-left + borderSegments = append(borderSegments, + moveTo(d2target.Point{X: scale(targetShape.Width, 0.25), Y: 0}), + ) + Y_OFFSET := d2target.THREE_DEE_OFFSET / 2 + // The following iterates through the sidepoints in clockwise order from top-left, then the main points in clockwise order from bottom-right + for _, v := range []d2target.Point{ + {X: scale(targetShape.Width, 0.25) + d2target.THREE_DEE_OFFSET, Y: -Y_OFFSET}, + {X: scale(targetShape.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: -Y_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: scale(targetShape.Height, halfYFactor) - Y_OFFSET}, + {X: scale(targetShape.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - Y_OFFSET}, + {X: scale(targetShape.Width, 0.75), Y: targetShape.Height}, + {X: scale(targetShape.Width, 0.25), Y: targetShape.Height}, + {X: 0, Y: scale(targetShape.Height, halfYFactor)}, + {X: scale(targetShape.Width, 0.25), Y: 0}, + {X: scale(targetShape.Width, 0.75), Y: 0}, + {X: targetShape.Width, Y: scale(targetShape.Height, halfYFactor)}, + {X: scale(targetShape.Width, 0.75), Y: targetShape.Height}, + } { + borderSegments = append(borderSegments, lineTo(v)) + } + for _, v := range []d2target.Point{ + {X: scale(targetShape.Width, 0.75), Y: 0}, + {X: targetShape.Width, Y: scale(targetShape.Height, halfYFactor)}, + {X: scale(targetShape.Width, 0.75), Y: targetShape.Height}, + } { + borderSegments = append(borderSegments, moveTo(v)) + borderSegments = append(borderSegments, lineTo( + d2target.Point{X: v.X + d2target.THREE_DEE_OFFSET, Y: v.Y - Y_OFFSET}, + )) + } + border := d2themes.NewThemableElement("path", inlineTheme) + border.D = strings.Join(borderSegments, " ") + border.Fill = color.None + _, borderStroke := d2themes.ShapeTheme(targetShape) + border.Stroke = borderStroke + borderStyle := targetShape.CSSStyle() + border.Style = borderStyle + renderedBorder := border.Render() + + var mainPoints []string + for _, v := range []d2target.Point{ + {X: scale(targetShape.Width, 0.25), Y: 0}, + {X: scale(targetShape.Width, 0.75), Y: 0}, + {X: targetShape.Width, Y: scale(targetShape.Height, halfYFactor)}, + {X: scale(targetShape.Width, 0.75), Y: targetShape.Height}, + {X: scale(targetShape.Width, 0.25), Y: targetShape.Height}, + {X: 0, Y: scale(targetShape.Height, halfYFactor)}, + } { + mainPoints = append(mainPoints, + fmt.Sprintf("%d,%d", v.X+targetShape.Pos.X, v.Y+targetShape.Pos.Y), + ) + } + + mainPointsPoly := strings.Join(mainPoints, " ") + // create mask from border stroke, to cut away from the shape fills + maskID := fmt.Sprintf("border-mask-%v-%v", diagramHash, svg.EscapeText(targetShape.ID)) + borderMask := strings.Join([]string{ + fmt.Sprintf(``, + maskID, targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET, + ), + fmt.Sprintf(``, + targetShape.Pos.X, targetShape.Pos.Y-d2target.THREE_DEE_OFFSET, targetShape.Width+d2target.THREE_DEE_OFFSET, targetShape.Height+d2target.THREE_DEE_OFFSET, + ), + fmt.Sprintf(``, + strings.Join(borderSegments, ""), borderStyle), + }, "\n") + // render the main hexagon without stroke and the border mask + mainShape := d2themes.NewThemableElement("polygon", inlineTheme) + mainShape.X = float64(targetShape.Pos.X) + mainShape.Y = float64(targetShape.Pos.Y) + mainShape.Points = mainPointsPoly + mainShape.SetMaskUrl(maskID) + mainShapeFill, _ := d2themes.ShapeTheme(targetShape) + mainShape.FillPattern = targetShape.FillPattern + mainShape.Fill = mainShapeFill + mainShape.Stroke = color.None + mainShape.Style = targetShape.CSSStyle() + mainShapeRendered := mainShape.Render() + + // render the side shapes in the darkened color without stroke and the border mask + var sidePoints []string + for _, v := range []d2target.Point{ + {X: scale(targetShape.Width, 0.25) + d2target.THREE_DEE_OFFSET, Y: -Y_OFFSET}, + {X: scale(targetShape.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: -Y_OFFSET}, + {X: targetShape.Width + d2target.THREE_DEE_OFFSET, Y: scale(targetShape.Height, halfYFactor) - Y_OFFSET}, + {X: scale(targetShape.Width, 0.75) + d2target.THREE_DEE_OFFSET, Y: targetShape.Height - Y_OFFSET}, + {X: scale(targetShape.Width, 0.75), Y: targetShape.Height}, + {X: targetShape.Width, Y: scale(targetShape.Height, halfYFactor)}, + {X: scale(targetShape.Width, 0.75), Y: 0}, + {X: scale(targetShape.Width, 0.25), Y: 0}, + } { + sidePoints = append(sidePoints, + fmt.Sprintf("%d,%d", v.X+targetShape.Pos.X, v.Y+targetShape.Pos.Y), + ) + } + // 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 + } + sideShape := d2themes.NewThemableElement("polygon", inlineTheme) + sideShape.Fill = darkerColor + sideShape.Points = strings.Join(sidePoints, " ") + sideShape.SetMaskUrl(maskID) + sideShape.Style = targetShape.CSSStyle() + renderedSides := sideShape.Render() + + return borderMask + mainShapeRendered + renderedSides + renderedBorder +} + +func drawShape(writer, appendixWriter io.Writer, diagramHash string, targetShape d2target.Shape, jsRunner jsrunner.JSRunner, inlineTheme *d2themes.Theme) (labelMask string, err error) { + closingTag := "" + if targetShape.Link != "" { + + fmt.Fprintf(writer, ``, svg.EscapeText(targetShape.Link)) + closingTag += "" + } + // Opacity is a unique style, it applies to everything for a shape + opacityStyle := "" + if targetShape.Opacity != 1.0 { + opacityStyle = fmt.Sprintf(" style='opacity:%f'", targetShape.Opacity) + } + + // this clipPath must be defined outside `g` element + if targetShape.BorderRadius != 0 && (targetShape.Type == d2target.ShapeClass || targetShape.Type == d2target.ShapeSQLTable) { + fmt.Fprint(writer, clipPathForBorderRadius(diagramHash, targetShape)) + } + classes := []string{base64.URLEncoding.EncodeToString([]byte(svg.EscapeText(targetShape.ID)))} + if targetShape.Animated { + classes = append(classes, "animated-shape") + } + classes = append(classes, targetShape.Classes...) + classStr := fmt.Sprintf(` class="%s"`, strings.Join(classes, " ")) + fmt.Fprintf(writer, ``, classStr, opacityStyle) + tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)) + width := float64(targetShape.Width) + height := float64(targetShape.Height) + fill, stroke := d2themes.ShapeTheme(targetShape) + style := targetShape.CSSStyle() + shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[targetShape.Type] + + s := shape.NewShape(shapeType, geo.NewBox(tl, width, height)) + if shapeType == shape.CLOUD_TYPE && targetShape.ContentAspectRatio != nil { + s.SetInnerBoxAspectRatio(*targetShape.ContentAspectRatio) + } + + var shadowAttr string + if targetShape.Shadow { + switch targetShape.Type { + case d2target.ShapeText, + d2target.ShapeCode, + d2target.ShapeClass, + d2target.ShapeSQLTable: + default: + shadowAttr = `filter="url(#shadow-filter)" ` + } + } + + var blendModeClass string + if targetShape.Blend { + blendModeClass = " blend" + } + + fmt.Fprintf(writer, ``, blendModeClass, shadowAttr) + + var multipleTL *geo.Point + if targetShape.Multiple { + multipleTL = tl.AddVector(multipleOffset) + } + + switch targetShape.Type { + case d2target.ShapeClass: + if jsRunner != nil { + out, err := d2sketch.Class(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + drawClass(writer, diagramHash, targetShape, inlineTheme) + } + addAppendixItems(appendixWriter, diagramHash, targetShape, s) + fmt.Fprint(writer, ``) + fmt.Fprint(writer, closingTag) + return labelMask, nil + case d2target.ShapeSQLTable: + if jsRunner != nil { + out, err := d2sketch.Table(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + drawTable(writer, diagramHash, targetShape, inlineTheme) + } + addAppendixItems(appendixWriter, diagramHash, targetShape, s) + fmt.Fprint(writer, ``) + fmt.Fprint(writer, closingTag) + return labelMask, nil + case d2target.ShapeOval: + if targetShape.DoubleBorder { + if targetShape.Multiple { + fmt.Fprint(writer, renderDoubleOval(multipleTL, width, height, fill, "", stroke, style, inlineTheme)) + } + if jsRunner != nil { + out, err := d2sketch.DoubleOval(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + fmt.Fprint(writer, renderDoubleOval(tl, width, height, fill, targetShape.FillPattern, stroke, style, inlineTheme)) + } + } else { + if targetShape.Multiple { + fmt.Fprint(writer, renderOval(multipleTL, width, height, fill, "", stroke, style, inlineTheme)) + } + if jsRunner != nil { + out, err := d2sketch.Oval(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + fmt.Fprint(writer, renderOval(tl, width, height, fill, targetShape.FillPattern, stroke, style, inlineTheme)) + } + } + + case d2target.ShapeImage: + el := d2themes.NewThemableElement("image", inlineTheme) + 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()) + + // TODO should standardize "" to rectangle + case d2target.ShapeRectangle, d2target.ShapeSequenceDiagram, d2target.ShapeHierarchy, "": + borderRadius := math.MaxFloat64 + if targetShape.BorderRadius != 0 { + borderRadius = float64(targetShape.BorderRadius) + } + if targetShape.ThreeDee { + fmt.Fprint(writer, render3DRect(diagramHash, targetShape, inlineTheme)) + } else { + if !targetShape.DoubleBorder { + if targetShape.Multiple { + el := d2themes.NewThemableElement("rect", inlineTheme) + 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 + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + } + if jsRunner != nil { + out, err := d2sketch.Rect(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + el := d2themes.NewThemableElement("rect", inlineTheme) + 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.FillPattern = targetShape.FillPattern + el.Stroke = stroke + el.Style = style + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + } + } else { + if targetShape.Multiple { + el := d2themes.NewThemableElement("rect", inlineTheme) + 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.FillPattern = targetShape.FillPattern + el.Stroke = stroke + el.Style = style + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + + el = d2themes.NewThemableElement("rect", inlineTheme) + el.X = float64(targetShape.Pos.X + 10 + d2target.INNER_BORDER_OFFSET) + el.Y = float64(targetShape.Pos.Y - 10 + d2target.INNER_BORDER_OFFSET) + el.Width = float64(targetShape.Width - 2*d2target.INNER_BORDER_OFFSET) + el.Height = float64(targetShape.Height - 2*d2target.INNER_BORDER_OFFSET) + el.Fill = fill + el.Stroke = stroke + el.Style = style + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + } + if jsRunner != nil { + out, err := d2sketch.DoubleRect(jsRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + el := d2themes.NewThemableElement("rect", inlineTheme) + 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.FillPattern = targetShape.FillPattern + el.Stroke = stroke + el.Style = style + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + + el = d2themes.NewThemableElement("rect", inlineTheme) + el.X = float64(targetShape.Pos.X + d2target.INNER_BORDER_OFFSET) + el.Y = float64(targetShape.Pos.Y + d2target.INNER_BORDER_OFFSET) + el.Width = float64(targetShape.Width - 2*d2target.INNER_BORDER_OFFSET) + el.Height = float64(targetShape.Height - 2*d2target.INNER_BORDER_OFFSET) + el.Fill = "transparent" + el.Stroke = stroke + el.Style = style + el.Rx = borderRadius + fmt.Fprint(writer, el.Render()) + } + } + } + case d2target.ShapeHexagon: + if targetShape.ThreeDee { + fmt.Fprint(writer, render3DHexagon(diagramHash, targetShape, inlineTheme)) + } else { + if targetShape.Multiple { + multiplePathData := shape.NewShape(shapeType, geo.NewBox(multipleTL, width, height)).GetSVGPathData() + el := d2themes.NewThemableElement("path", inlineTheme) + el.Fill = fill + el.Stroke = stroke + el.Style = style + for _, pathData := range multiplePathData { + el.D = pathData + fmt.Fprint(writer, el.Render()) + } + } + + if jsRunner != nil { + out, err := d2sketch.Paths(jsRunner, targetShape, s.GetSVGPathData()) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + el := d2themes.NewThemableElement("path", inlineTheme) + el.Fill = fill + el.FillPattern = targetShape.FillPattern + el.Stroke = stroke + el.Style = style + for _, pathData := range s.GetSVGPathData() { + el.D = pathData + fmt.Fprint(writer, el.Render()) + } + } + } + case d2target.ShapeText, d2target.ShapeCode: + default: + if targetShape.Multiple { + multiplePathData := shape.NewShape(shapeType, geo.NewBox(multipleTL, width, height)).GetSVGPathData() + el := d2themes.NewThemableElement("path", inlineTheme) + el.Fill = fill + el.Stroke = stroke + el.Style = style + for _, pathData := range multiplePathData { + el.D = pathData + fmt.Fprint(writer, el.Render()) + } + } + + if jsRunner != nil { + out, err := d2sketch.Paths(jsRunner, targetShape, s.GetSVGPathData()) + if err != nil { + return "", err + } + fmt.Fprint(writer, out) + } else { + el := d2themes.NewThemableElement("path", inlineTheme) + el.Fill = fill + el.FillPattern = targetShape.FillPattern + el.Stroke = stroke + el.Style = style + for _, pathData := range s.GetSVGPathData() { + el.D = pathData + fmt.Fprint(writer, el.Render()) + } + } + } + + // // to examine shape's innerBox + // innerBox := s.GetInnerBox() + // el := d2themes.NewThemableElement("rect", inlineTheme) + // el.X = float64(innerBox.TopLeft.X) + // el.Y = float64(innerBox.TopLeft.Y) + // el.Width = float64(innerBox.Width) + // el.Height = float64(innerBox.Height) + // el.Style = "fill:rgba(255,0,0,0.5);" + // fmt.Fprint(writer, el.Render()) + + // Closes the class=shape + fmt.Fprint(writer, ``) + + if targetShape.Icon != nil && targetShape.Type != d2target.ShapeImage && targetShape.Opacity != 0 { + iconPosition := label.FromString(targetShape.IconPosition) + var box *geo.Box + if iconPosition.IsOutside() { + box = s.GetBox() + } else { + box = s.GetInnerBox() + } + iconSize := d2target.GetIconSize(box, targetShape.IconPosition) + + tl := iconPosition.GetPointOnBox(box, label.PADDING, float64(iconSize), float64(iconSize)) + + fmt.Fprintf(writer, ``, + html.EscapeString(targetShape.Icon.String()), + tl.X, + tl.Y, + iconSize, + iconSize, + ) + } + + if targetShape.Label != "" && targetShape.Opacity != 0 { + labelPosition := label.FromString(targetShape.LabelPosition) + var box *geo.Box + if labelPosition.IsOutside() { + box = s.GetBox().Copy() + // if it is 3d/multiple, place label using box around those + if targetShape.ThreeDee { + offsetY := d2target.THREE_DEE_OFFSET + if targetShape.Type == d2target.ShapeHexagon { + offsetY /= 2 + } + box.TopLeft.Y -= float64(offsetY) + box.Height += float64(offsetY) + box.Width += d2target.THREE_DEE_OFFSET + } else if targetShape.Multiple { + box.TopLeft.Y -= d2target.MULTIPLE_OFFSET + box.Height += d2target.MULTIPLE_OFFSET + box.Width += d2target.MULTIPLE_OFFSET + } + } else { + box = s.GetInnerBox() + } + labelTL := labelPosition.GetPointOnBox(box, label.PADDING, + float64(targetShape.LabelWidth), + float64(targetShape.LabelHeight), + ) + labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight, 0.75) + + fontClass := "text" + if targetShape.FontFamily == "mono" { + fontClass = "text-mono" + } + if targetShape.Bold { + fontClass += "-bold" + } else if targetShape.Italic { + fontClass += "-italic" + } + if targetShape.Underline { + fontClass += " text-underline" + } + + if targetShape.Language == "latex" { + render, err := d2latex.Render(targetShape.Label) + if err != nil { + return labelMask, err + } + gEl := d2themes.NewThemableElement("g", inlineTheme) + + labelPosition := label.FromString(targetShape.LabelPosition) + if labelPosition == label.Unset { + labelPosition = label.InsideMiddleCenter + } + 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), + ) + gEl.SetTranslate(labelTL.X, labelTL.Y) + + gEl.Color = targetShape.Stroke + gEl.Content = render + fmt.Fprint(writer, gEl.Render()) + } else if targetShape.Language == "markdown" { + render, err := textmeasure.RenderMarkdown(targetShape.Label) + if err != nil { + return labelMask, err + } + + labelPosition := label.FromString(targetShape.LabelPosition) + if labelPosition == label.Unset { + labelPosition = label.InsideMiddleCenter + } + 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), + ) + + fmt.Fprintf(writer, ``, + labelTL.X, labelTL.Y, targetShape.LabelWidth, targetShape.LabelHeight, + ) + + // we need the self closing form in this svg/xhtml context + render = strings.ReplaceAll(render, "
", "
") + + mdEl := d2themes.NewThemableElement("div", inlineTheme) + mdEl.ClassName = "md" + mdEl.Content = render + + // We have to set with styles since within foreignObject, we're in html + // land and not SVG attributes + var styles []string + if targetShape.FontSize != textmeasure.MarkdownFontSize { + styles = append(styles, fmt.Sprintf("font-size:%vpx", targetShape.FontSize)) + } + if targetShape.Fill != "" && targetShape.Fill != "transparent" { + styles = append(styles, fmt.Sprintf(`background-color:%s`, targetShape.Fill)) + } + if !color.IsThemeColor(targetShape.Color) { + styles = append(styles, fmt.Sprintf(`color:%s`, targetShape.Color)) + } else { + styles = append(styles, fmt.Sprintf(`color:%s`, d2themes.ResolveThemeColor(*inlineTheme, targetShape.Color))) + } + + mdEl.Style = strings.Join(styles, ";") + + fmt.Fprint(writer, mdEl.Render()) + fmt.Fprint(writer, `
`) + } else if targetShape.Language != "" { + lexer := lexers.Get(targetShape.Language) + if lexer == nil { + lexer = lexers.Fallback + } + for _, isLight := range []bool{true, false} { + theme := "github" + if !isLight { + theme = "catppuccin-mocha" + } + style := styles.Get(theme) + if style == nil { + return labelMask, errors.New(`code snippet style "github" not found`) + } + formatter := formatters.Get("svg") + if formatter == nil { + return labelMask, errors.New(`code snippet formatter "svg" not found`) + } + iterator, err := lexer.Tokenise(nil, targetShape.Label) + if err != nil { + return labelMask, err + } + + svgStyles := styleToSVG(style) + class := "light-code" + if !isLight { + class = "dark-code" + } + var fontSize string + if targetShape.FontSize != d2fonts.FONT_SIZE_M { + fontSize = fmt.Sprintf(` style="font-size:%v"`, targetShape.FontSize) + } + fmt.Fprintf(writer, ``, + box.TopLeft.X, box.TopLeft.Y, class, fontSize, + ) + rectEl := d2themes.NewThemableElement("rect", inlineTheme) + rectEl.Width = float64(targetShape.Width) + rectEl.Height = float64(targetShape.Height) + rectEl.Stroke = targetShape.Stroke + rectEl.ClassName = "shape" + rectEl.Style = fmt.Sprintf(`fill:%s;stroke-width:%d;`, + style.Get(chroma.Background).Background.String(), + targetShape.StrokeWidth, + ) + fmt.Fprint(writer, rectEl.Render()) + // Padding = 0.5em + padding := float64(targetShape.FontSize) / 2. + fmt.Fprintf(writer, ``, padding, padding) + + lineHeight := textmeasure.CODE_LINE_HEIGHT + for index, tokens := range chroma.SplitTokensIntoLines(iterator.Tokens()) { + fmt.Fprintf(writer, "", 1+float64(index)*lineHeight) + for _, token := range tokens { + text := svgEscaper.Replace(token.String()) + attr := styleAttr(svgStyles, token.Type) + if attr != "" { + text = fmt.Sprintf("%s", attr, text) + } + fmt.Fprint(writer, text) + } + fmt.Fprint(writer, "") + } + fmt.Fprint(writer, "") + } + } else { + if targetShape.LabelFill != "" { + rectEl := d2themes.NewThemableElement("rect", inlineTheme) + rectEl.X = labelTL.X + rectEl.Y = labelTL.Y + rectEl.Width = float64(targetShape.LabelWidth) + rectEl.Height = float64(targetShape.LabelHeight) + rectEl.Fill = targetShape.LabelFill + fmt.Fprint(writer, rectEl.Render()) + } + textEl := d2themes.NewThemableElement("text", inlineTheme) + textEl.X = labelTL.X + float64(targetShape.LabelWidth)/2 + // text is vertically positioned at its baseline which is at labelTL+FontSize + textEl.Y = labelTL.Y + float64(targetShape.FontSize) + textEl.Fill = targetShape.GetFontColor() + textEl.ClassName = 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()) + if targetShape.Blend { + labelMask = makeLabelMask(labelTL, targetShape.LabelWidth, targetShape.LabelHeight-d2graph.INNER_LABEL_PADDING, 1) + } + } + } + if targetShape.Tooltip != "" { + fmt.Fprintf(writer, `%s`, + svg.EscapeText(targetShape.Tooltip), + ) + } + addAppendixItems(appendixWriter, diagramHash, targetShape, s) + + fmt.Fprint(writer, closingTag) + return labelMask, nil +} + +func addAppendixItems(writer io.Writer, diagramHash string, targetShape d2target.Shape, s shape.Shape) { + var p1, p2 *geo.Point + if targetShape.Tooltip != "" || targetShape.Link != "" { + bothIcons := targetShape.Tooltip != "" && targetShape.Link != "" + corner := geo.NewPoint(float64(targetShape.Pos.X+targetShape.Width), float64(targetShape.Pos.Y)) + center := geo.NewPoint( + float64(targetShape.Pos.X)+float64(targetShape.Width)/2., + float64(targetShape.Pos.Y)+float64(targetShape.Height)/2., + ) + offset := geo.Vector{-2 * appendixIconRadius, 0} + var leftOnShape bool + switch s.GetType() { + case shape.STEP_TYPE, shape.HEXAGON_TYPE, shape.QUEUE_TYPE, shape.PAGE_TYPE: + // trace straight left for these + center.Y = float64(targetShape.Pos.Y) + case shape.PACKAGE_TYPE: + // trace straight down + center.X = float64(targetShape.Pos.X + targetShape.Width) + case shape.CIRCLE_TYPE, shape.OVAL_TYPE, shape.DIAMOND_TYPE, + shape.PERSON_TYPE, shape.CLOUD_TYPE, shape.CYLINDER_TYPE: + if bothIcons { + leftOnShape = true + corner = corner.AddVector(offset) + } + } + v1 := center.VectorTo(corner) + p1 = shape.TraceToShapeBorder(s, corner, corner.AddVector(v1)) + if bothIcons { + if leftOnShape { + // these shapes should have p1 on shape border + p2 = p1.AddVector(offset.Reverse()) + p1, p2 = p2, p1 + } else { + p2 = p1.AddVector(offset) + } + } + } + + if targetShape.Tooltip != "" { + x := int(math.Ceil(p1.X)) + y := int(math.Ceil(p1.Y)) + + fmt.Fprintf(writer, `%s%s`, + x-appendixIconRadius, + y-appendixIconRadius, + svg.EscapeText(targetShape.Tooltip), + fmt.Sprintf(TooltipIcon, diagramHash, svg.SVGID(targetShape.ID)), + ) + } + if targetShape.Link != "" { + if p2 == nil { + p2 = p1 + } + x := int(math.Ceil(p2.X)) + y := int(math.Ceil(p2.Y)) + fmt.Fprintf(writer, `%s`, + x-appendixIconRadius, + y-appendixIconRadius, + fmt.Sprintf(LinkIcon, diagramHash, svg.SVGID(targetShape.ID)), + ) + } +} + +func RenderText(text string, x, height float64) string { + if !strings.Contains(text, "\n") { + return svg.EscapeText(text) + } + rendered := []string{} + lines := strings.Split(text, "\n") + for i, line := range lines { + dy := height / float64(len(lines)) + if i == 0 { + dy = 0 + } + escaped := svg.EscapeText(line) + 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(`%s`, x, dy, escaped)) + } + return strings.Join(rendered, "") +} + +func EmbedFonts(buf *bytes.Buffer, diagramHash, source string, fontFamily *d2fonts.FontFamily, corpus string) { + fmt.Fprint(buf, ``) +} + +func appendOnTrigger(buf *bytes.Buffer, source string, triggers []string, newContent string) { + for _, trigger := range triggers { + if strings.Contains(source, trigger) { + fmt.Fprint(buf, newContent) + break + } + } +} + +var DEFAULT_DARK_THEME *int64 = nil // no theme selected + +func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) { + var jsRunner jsrunner.JSRunner + pad := DEFAULT_PADDING + themeID := d2themescatalog.NeutralDefault.ID + darkThemeID := DEFAULT_DARK_THEME + var scale *float64 + if opts != nil { + if opts.Pad != nil { + pad = int(*opts.Pad) + } + if opts.Sketch != nil && *opts.Sketch { + jsRunner = jsrunner.NewJSRunner() + err := d2sketch.LoadJS(jsRunner) + if err != nil { + return nil, err + } + } + if opts.ThemeID != nil { + themeID = *opts.ThemeID + } + darkThemeID = opts.DarkThemeID + scale = opts.Scale + } else { + opts = &RenderOpts{} + } + + buf := &bytes.Buffer{} + + // only define shadow filter if a shape uses it + for _, s := range diagram.Shapes { + if s.Shadow { + defineShadowFilter(buf) + break + } + } + + if color.IsGradient(diagram.Root.Fill) { + defineGradients(buf, diagram.Root.Fill) + } + if color.IsGradient(diagram.Root.Stroke) { + defineGradients(buf, diagram.Root.Stroke) + } + for _, s := range diagram.Shapes { + if color.IsGradient(s.Fill) { + defineGradients(buf, s.Fill) + } + if color.IsGradient(s.Stroke) { + defineGradients(buf, s.Stroke) + } + if color.IsGradient(s.Color) { + defineGradients(buf, s.Color) + } + } + for _, c := range diagram.Connections { + if color.IsGradient(c.Stroke) { + defineGradients(buf, c.Stroke) + } + if color.IsGradient(c.Fill) { + defineGradients(buf, c.Fill) + } + } + + // Apply hash on IDs for targeting, to be specific for this diagram + diagramHash, err := diagram.HashID(opts.Salt) + if err != nil { + return nil, err + } + // Some targeting is still per-board, like masks for connections + isolatedDiagramHash := diagramHash + if opts != nil && opts.MasterID != "" { + diagramHash = opts.MasterID + } + + // 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 + idToShape := make(map[string]d2target.Shape) + allObjects := make([]DiagramObject, 0, len(diagram.Shapes)+len(diagram.Connections)) + for _, s := range diagram.Shapes { + idToShape[s.ID] = s + allObjects = append(allObjects, s) + } + for _, c := range diagram.Connections { + allObjects = append(allObjects, c) + } + + sortObjects(allObjects) + + appendixItemBuf := &bytes.Buffer{} + + var labelMasks []string + markers := map[string]struct{}{} + var inlineTheme *d2themes.Theme + // We only want to inline when no dark theme is specified, otherwise the inline style will override the CSS + if darkThemeID == nil { + inlineTheme = go2.Pointer(d2themescatalog.Find(themeID)) + inlineTheme.ApplyOverrides(opts.ThemeOverrides) + } + for _, obj := range allObjects { + if c, is := obj.(d2target.Connection); is { + labelMask, err := drawConnection(buf, isolatedDiagramHash, c, markers, idToShape, jsRunner, inlineTheme) + if err != nil { + return nil, err + } + if labelMask != "" { + labelMasks = append(labelMasks, labelMask) + } + } else if s, is := obj.(d2target.Shape); is { + labelMask, err := drawShape(buf, appendixItemBuf, diagramHash, s, jsRunner, inlineTheme) + if err != nil { + return nil, err + } else if labelMask != "" { + labelMasks = append(labelMasks, labelMask) + } + } else { + return nil, fmt.Errorf("unknown object of type %T", obj) + } + } + // add all appendix items afterwards so they are always on top + fmt.Fprint(buf, appendixItemBuf) + + // Render legend if present + if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { + legendBuf := &bytes.Buffer{} + err := renderLegend(legendBuf, diagram, diagramHash, inlineTheme) + if err != nil { + return nil, err + } + fmt.Fprint(buf, legendBuf) + } + + // Note: we always want this since we reference it on connections even if there end up being no masked labels + left, top, w, h := dimensions(diagram, pad) + + // Adjust the width and height if the legend is present to ensure it's fully visible + if diagram.Legend != nil && (len(diagram.Legend.Shapes) > 0 || len(diagram.Legend.Connections) > 0) { + // Calculate dimensions needed for legend + tl, br := diagram.BoundingBox() + // Start with top padding and title height + totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING + maxLabelWidth := 0 + itemCount := 0 + ruler, _ := textmeasure.NewRuler() + if ruler != nil { + // Calculate height and width of legend + for _, s := range diagram.Legend.Shapes { + if s.Label == "" { + continue + } + mtext := &d2target.MText{ + Text: s.Label, + FontSize: LEGEND_FONT_SIZE, + } + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + for _, c := range diagram.Legend.Connections { + if c.Label == "" { + continue + } + mtext := &d2target.MText{ + Text: c.Label, + FontSize: LEGEND_FONT_SIZE, + } + dims := d2graph.GetTextDimensions(nil, ruler, mtext, nil) + maxLabelWidth = go2.IntMax(maxLabelWidth, dims.Width) + totalHeight += go2.IntMax(dims.Height, LEGEND_ICON_SIZE) + LEGEND_ITEM_SPACING + itemCount++ + } + + // If we have items, remove the extra spacing from the last item and add bottom padding + if itemCount > 0 { + totalHeight -= LEGEND_ITEM_SPACING / 2 // Remove some of the last spacing + } + + // Add bottom padding + totalHeight += LEGEND_PADDING + + if totalHeight > 0 && maxLabelWidth > 0 { + legendWidth := LEGEND_PADDING*2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth + + // Calculate legend vertical position (centered) + legendY := tl.Y + (br.Y-tl.Y-totalHeight)/2 + if legendY < tl.Y { + legendY = tl.Y + } + + // Expand width to include legend to the right + legendRight := br.X + LEGEND_CORNER_PADDING + legendWidth + if left+w < legendRight { + w = legendRight - left + pad/2 + } + + // Make sure the top of the legend is visible + if legendY < top { + diffY := top - legendY + top -= diffY + h += diffY + } + + // Make sure the bottom of the legend is visible + legendBottom := legendY + totalHeight + if top+h < legendBottom { + h = legendBottom - top + pad/2 + } + } + } + } + fmt.Fprint(buf, strings.Join([]string{ + fmt.Sprintf(``, + isolatedDiagramHash, left, top, w, h, + ), + fmt.Sprintf(``, + left, top, w, h, + ), + strings.Join(labelMasks, "\n"), + ``, + }, "\n")) + + // generate style elements that will be appended to the SVG tag + upperBuf := &bytes.Buffer{} + if opts.MasterID == "" { + EmbedFonts(upperBuf, diagramHash, buf.String(), diagram.FontFamily, diagram.GetCorpus()) // EmbedFonts *must* run before `d2sketch.DefineFillPatterns`, but after all elements are appended to `buf` + themeStylesheet, err := ThemeCSS(diagramHash, &themeID, darkThemeID, opts.ThemeOverrides, opts.DarkThemeOverrides) + if err != nil { + return nil, err + } + fmt.Fprintf(upperBuf, ``, BaseStylesheet, themeStylesheet) + + hasMarkdown := false + for _, s := range diagram.Shapes { + if s.Language == "markdown" { + hasMarkdown = true + break + } + } + if hasMarkdown { + css := MarkdownCSS + css = strings.ReplaceAll(css, ".md", fmt.Sprintf(".%s .md", diagramHash)) + css = strings.ReplaceAll(css, "font-italic", fmt.Sprintf("%s-font-italic", diagramHash)) + css = strings.ReplaceAll(css, "font-bold", fmt.Sprintf("%s-font-bold", diagramHash)) + css = strings.ReplaceAll(css, "font-mono", fmt.Sprintf("%s-font-mono", diagramHash)) + css = strings.ReplaceAll(css, "font-regular", fmt.Sprintf("%s-font-regular", diagramHash)) + css = strings.ReplaceAll(css, "font-semibold", fmt.Sprintf("%s-font-semibold", diagramHash)) + fmt.Fprintf(upperBuf, ``, css) + } + + if jsRunner != nil { + d2sketch.DefineFillPatterns(upperBuf, diagramHash) + } + } + + // This shift is for background el to envelop the diagram + left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + backgroundEl := d2themes.NewThemableElement("rect", inlineTheme) + // We don't want to change the document viewbox, only the background el + backgroundEl.X = float64(left) + backgroundEl.Y = float64(top) + backgroundEl.Width = float64(w) + backgroundEl.Height = float64(h) + backgroundEl.Fill = diagram.Root.Fill + backgroundEl.Stroke = diagram.Root.Stroke + backgroundEl.FillPattern = diagram.Root.FillPattern + backgroundEl.Rx = float64(diagram.Root.BorderRadius) + if diagram.Root.StrokeDash != 0 { + dashSize, gapSize := svg.GetStrokeDashAttributes(float64(diagram.Root.StrokeWidth), diagram.Root.StrokeDash) + backgroundEl.StrokeDashArray = fmt.Sprintf("%f, %f", dashSize, gapSize) + } + backgroundEl.Attributes = fmt.Sprintf(`stroke-width="%d"`, diagram.Root.StrokeWidth) + + // This shift is for viewbox to envelop the background el + left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + + doubleBorderElStr := "" + if diagram.Root.DoubleBorder { + offset := d2target.INNER_BORDER_OFFSET + + left -= int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)) + offset + top -= int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)) + offset + w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)*2.) + 2*offset + h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.)*2.) + 2*offset + + backgroundEl2 := backgroundEl.Copy() + // No need to double-paint + backgroundEl.Fill = "transparent" + + backgroundEl2.X = float64(left) + backgroundEl2.Y = float64(top) + backgroundEl2.Width = float64(w) + backgroundEl2.Height = float64(h) + doubleBorderElStr = backgroundEl2.Render() + + left -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + top -= int(math.Ceil(float64(diagram.Root.StrokeWidth) / 2.)) + w += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + h += int(math.Ceil(float64(diagram.Root.StrokeWidth)/2.) * 2.) + } + + bufStr := buf.String() + patternDefs := "" + for _, pattern := range d2ast.FillPatterns { + if strings.Contains(bufStr, fmt.Sprintf("%s-overlay", pattern)) || diagram.Root.FillPattern == pattern { + if patternDefs == "" { + fmt.Fprint(upperBuf, ``) + fmt.Fprint(upperBuf, "") + fmt.Fprint(upperBuf, patternDefs) + fmt.Fprint(upperBuf, "") + } + + var dimensions string + if scale != nil { + dimensions = fmt.Sprintf(` width="%d" height="%d"`, + int(math.Ceil((*scale)*float64(w))), + int(math.Ceil((*scale)*float64(h))), + ) + } + + alignment := "xMinYMin" + if opts.Center != nil && *opts.Center { + alignment = "xMidYMid" + } + fitToScreenWrapperOpening := "" + xmlTag := "" + fitToScreenWrapperClosing := "" + idAttr := "" + tag := "g" + // Many things change when this is rendering for animation + if opts.MasterID == "" { + fitToScreenWrapperOpening = fmt.Sprintf(``, + version.Version, + alignment, + w, h, + dimensions, + ) + if opts.NoXMLTag == nil || !*opts.NoXMLTag { + xmlTag = `` + } + fitToScreenWrapperClosing = "" + idAttr = `d2-svg` + tag = "svg" + } + + // TODO minify + docRendered := fmt.Sprintf(`%s%s<%s class="%s" width="%d" height="%d" viewBox="%d %d %d %d">%s%s%s%s%s`, + xmlTag, + fitToScreenWrapperOpening, + tag, + strings.Join([]string{diagramHash, idAttr}, " "), + w, h, left, top, w, h, + doubleBorderElStr, + backgroundEl.Render(), + upperBuf.String(), + buf.String(), + tag, + fitToScreenWrapperClosing, + ) + return []byte(docRendered), nil +} + +// TODO include only colors that are being used to reduce size +func ThemeCSS(diagramHash string, themeID *int64, darkThemeID *int64, overrides, darkOverrides *d2target.ThemeOverrides) (stylesheet string, err error) { + if themeID == nil { + themeID = &d2themescatalog.NeutralDefault.ID + } + out, err := singleThemeRulesets(diagramHash, *themeID, overrides) + if err != nil { + return "", err + } + + if darkThemeID != nil { + darkOut, err := singleThemeRulesets(diagramHash, *darkThemeID, darkOverrides) + if err != nil { + return "", err + } + out += fmt.Sprintf("@media screen and (prefers-color-scheme:dark){%s}", darkOut) + } + + return out, nil +} + +func singleThemeRulesets(diagramHash string, themeID int64, overrides *d2target.ThemeOverrides) (rulesets string, err error) { + out := "" + theme := d2themescatalog.Find(themeID) + theme.ApplyOverrides(overrides) + + // Global theme colors + for _, property := range []string{"fill", "stroke", "background-color", "color"} { + out += fmt.Sprintf(` + .%s .%s-N1{%s:%s;} + .%s .%s-N2{%s:%s;} + .%s .%s-N3{%s:%s;} + .%s .%s-N4{%s:%s;} + .%s .%s-N5{%s:%s;} + .%s .%s-N6{%s:%s;} + .%s .%s-N7{%s:%s;} + .%s .%s-B1{%s:%s;} + .%s .%s-B2{%s:%s;} + .%s .%s-B3{%s:%s;} + .%s .%s-B4{%s:%s;} + .%s .%s-B5{%s:%s;} + .%s .%s-B6{%s:%s;} + .%s .%s-AA2{%s:%s;} + .%s .%s-AA4{%s:%s;} + .%s .%s-AA5{%s:%s;} + .%s .%s-AB4{%s:%s;} + .%s .%s-AB5{%s:%s;}`, + diagramHash, + property, property, theme.Colors.Neutrals.N1, + diagramHash, + property, property, theme.Colors.Neutrals.N2, + diagramHash, + property, property, theme.Colors.Neutrals.N3, + diagramHash, + property, property, theme.Colors.Neutrals.N4, + diagramHash, + property, property, theme.Colors.Neutrals.N5, + diagramHash, + property, property, theme.Colors.Neutrals.N6, + diagramHash, + property, property, theme.Colors.Neutrals.N7, + diagramHash, + property, property, theme.Colors.B1, + diagramHash, + property, property, theme.Colors.B2, + diagramHash, + property, property, theme.Colors.B3, + diagramHash, + property, property, theme.Colors.B4, + diagramHash, + property, property, theme.Colors.B5, + diagramHash, + property, property, theme.Colors.B6, + diagramHash, + property, property, theme.Colors.AA2, + diagramHash, + property, property, theme.Colors.AA4, + diagramHash, + property, property, theme.Colors.AA5, + diagramHash, + property, property, theme.Colors.AB4, + diagramHash, + property, property, theme.Colors.AB5, + ) + } + + // Appendix + out += fmt.Sprintf(".appendix text.text{fill:%s}", theme.Colors.Neutrals.N1) + + // Markdown specific rulesets + 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, + theme.Colors.B2, theme.Colors.B2, + theme.Colors.Neutrals.N2, // TODO or N3 --color-attention-subtle + "red", + ) + + // Sketch style specific rulesets + // B + lc, err := color.LuminanceCategory(theme.Colors.B1) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B1, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B2) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B2, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B3) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B3, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B4, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B5, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.B6) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.B6, lc, diagramHash, blendMode(lc)) + + // AA + lc, err = color.LuminanceCategory(theme.Colors.AA2) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA2, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.AA4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA4, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.AA5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AA5, lc, diagramHash, blendMode(lc)) + + // AB + lc, err = color.LuminanceCategory(theme.Colors.AB4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AB4, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.AB5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.AB5, lc, diagramHash, blendMode(lc)) + + // Neutrals + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N1) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N1, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N2) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N2, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N3) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N3, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N4) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N4, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N5) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N5, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N6) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N6, lc, diagramHash, blendMode(lc)) + lc, err = color.LuminanceCategory(theme.Colors.Neutrals.N7) + if err != nil { + return "", err + } + out += fmt.Sprintf(".sketch-overlay-%s{fill:url(#streaks-%s-%s);mix-blend-mode:%s}", color.N7, lc, diagramHash, blendMode(lc)) + + if theme.IsDark() { + out += ".light-code{display: none}" + out += ".dark-code{display: block}" + } else { + out += ".light-code{display: block}" + out += ".dark-code{display: none}" + } + + return out, nil +} + +func blendMode(lc string) string { + switch lc { + case "bright": + return "darken" + case "normal": + return "color-burn" + case "dark": + return "overlay" + case "darker": + return "lighten" + } + panic("invalid luminance category") +} + +type DiagramObject interface { + GetID() string + GetZIndex() int +} + +// 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 +func sortObjects(allObjects []DiagramObject) { + 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 + } + + // then, if both are shapes, parents come before their children + 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 + }) +} + +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()) +} + +func RenderMultiboard(diagram *d2target.Diagram, opts *RenderOpts) ([][]byte, error) { + var boards [][]byte + for _, dl := range diagram.Layers { + childrenBoards, err := RenderMultiboard(dl, opts) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + for _, dl := range diagram.Scenarios { + childrenBoards, err := RenderMultiboard(dl, opts) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + for _, dl := range diagram.Steps { + childrenBoards, err := RenderMultiboard(dl, opts) + if err != nil { + return nil, err + } + boards = append(boards, childrenBoards...) + } + + if !diagram.IsFolderOnly { + out, err := Render(diagram, opts) + if err != nil { + return boards, err + } + boards = append([][]byte{out}, boards...) + return boards, nil + } + return boards, nil +} diff --git a/d2target/d2target.go b/d2target/d2target.go index 3da7cec08..1965b4b51 100644 --- a/d2target/d2target.go +++ b/d2target/d2target.go @@ -456,6 +456,16 @@ func (diagram Diagram) GetCorpus() string { } } + if diagram.Legend != nil { + corpus += "Legend" + for _, s := range diagram.Legend.Shapes { + corpus += s.Label + } + for _, c := range diagram.Legend.Connections { + corpus += c.Label + } + } + return corpus } diff --git a/e2etests/testdata/txtar/legend/dagre/board.exp.json b/e2etests/testdata/txtar/legend/dagre/board.exp.json new file mode 100644 index 000000000..838cefcc8 --- /dev/null +++ b/e2etests/testdata/txtar/legend/dagre/board.exp.json @@ -0,0 +1,725 @@ +{ + "name": "", + "config": { + "sketch": false, + "themeID": 0, + "darkThemeID": null, + "pad": null, + "center": null, + "layoutEngine": null + }, + "isFolderOnly": false, + "fontFamily": "SourceSansPro", + "shapes": [ + { + "id": "api-1", + "type": "rectangle", + "pos": { + "x": 0, + "y": 0 + }, + "width": 81, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-1", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "api-2", + "type": "rectangle", + "pos": { + "x": 141, + "y": 166 + }, + "width": 81, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-2", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "postgres", + "type": "cylinder", + "pos": { + "x": 18, + "y": 332 + }, + "width": 106, + "height": 118, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "AA4", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "postgres", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 61, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "external", + "type": "rectangle", + "pos": { + "x": 18, + "y": 550 + }, + "width": 105, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "external", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 60, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "api-3", + "type": "rectangle", + "pos": { + "x": 0, + "y": 166 + }, + "width": 81, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-3", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(api-1 -> postgres)[0]", + "src": "api-1", + "srcArrow": "none", + "dst": "postgres", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 8, + "y": 66 + }, + { + "x": -30.399999618530273, + "y": 106 + }, + { + "x": -40, + "y": 132.60000610351562 + }, + { + "x": -40, + "y": 157.5 + }, + { + "x": -40, + "y": 182.39999389648438 + }, + { + "x": -27.399999618530273, + "y": 294.3999938964844 + }, + { + "x": 23, + "y": 344 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-2 -> postgres)[0]", + "src": "api-2", + "srcArrow": "none", + "dst": "postgres", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 181.5, + "y": 232 + }, + { + "x": 181.5, + "y": 272 + }, + { + "x": 169, + "y": 294.3999938964844 + }, + { + "x": 119, + "y": 344 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(postgres -> external)[0]", + "src": "postgres", + "srcArrow": "none", + "dst": "external", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "black", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 71, + "y": 450 + }, + { + "x": 70.80000305175781, + "y": 490 + }, + { + "x": 70.75, + "y": 510 + }, + { + "x": 70.75, + "y": 550 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-1 <-> api-2)[0]", + "src": "api-1", + "srcArrow": "triangle", + "dst": "api-2", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 2, + "stroke": "red", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 80.5, + "y": 57 + }, + { + "x": 161.3000030517578, + "y": 104.19999694824219 + }, + { + "x": 181.5, + "y": 126 + }, + { + "x": 181.5, + "y": 166 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-1 -> api-3)[0]", + "src": "api-1", + "srcArrow": "none", + "dst": "api-3", + "dstArrow": "circle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 40.5, + "y": 66 + }, + { + "x": 40.5, + "y": 106 + }, + { + "x": 40.5, + "y": 126 + }, + { + "x": 40.5, + "y": 166 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "N7", + "stroke": "", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + }, + "legend": { + "shapes": [ + { + "id": "a", + "type": "rectangle", + "pos": { + "x": 10, + "y": 10 + }, + "width": 100, + "height": 100, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "Microservice", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 1 + }, + { + "id": "b", + "type": "cylinder", + "pos": { + "x": 10, + "y": 10 + }, + "width": 100, + "height": 100, + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "AA4", + "stroke": "B2", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "Database", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(a <-> b)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 1, + "stroke": "red", + "borderRadius": 10, + "label": "Good relationship", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a -> b)[0]", + "src": "a", + "srcArrow": "none", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "Bad relationship", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a -> b)[1]", + "src": "a", + "srcArrow": "none", + "dst": "b", + "dstArrow": "circle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "Tenuous", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ] + } +} diff --git a/e2etests/testdata/txtar/legend/dagre/sketch.exp.svg b/e2etests/testdata/txtar/legend/dagre/sketch.exp.svg new file mode 100644 index 000000000..47b7b2a65 --- /dev/null +++ b/e2etests/testdata/txtar/legend/dagre/sketch.exp.svg @@ -0,0 +1,106 @@ +api-1api-2postgresexternalapi-3 LegendMicroserviceDatabase Good relationship Bad relationship Tenuous + + + + + + + \ No newline at end of file diff --git a/e2etests/testdata/txtar/legend/elk/board.exp.json b/e2etests/testdata/txtar/legend/elk/board.exp.json new file mode 100644 index 000000000..5376eae0a --- /dev/null +++ b/e2etests/testdata/txtar/legend/elk/board.exp.json @@ -0,0 +1,684 @@ +{ + "name": "", + "config": { + "sketch": false, + "themeID": 0, + "darkThemeID": null, + "pad": null, + "center": null, + "layoutEngine": null + }, + "isFolderOnly": false, + "fontFamily": "SourceSansPro", + "shapes": [ + { + "id": "api-1", + "type": "rectangle", + "pos": { + "x": 45, + "y": 12 + }, + "width": 120, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-1", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "api-2", + "type": "rectangle", + "pos": { + "x": 65, + "y": 158 + }, + "width": 81, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-2", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "postgres", + "type": "cylinder", + "pos": { + "x": 12, + "y": 304 + }, + "width": 106, + "height": 118, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "AA4", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "postgres", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 61, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "external", + "type": "rectangle", + "pos": { + "x": 12, + "y": 492 + }, + "width": 105, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "external", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 60, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "api-3", + "type": "rectangle", + "pos": { + "x": 166, + "y": 158 + }, + "width": 81, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "api-3", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 36, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(api-1 -> postgres)[0]", + "src": "api-1", + "srcArrow": "none", + "dst": "postgres", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 75.75, + "y": 78 + }, + { + "x": 75.75, + "y": 118 + }, + { + "x": 24.249000549316406, + "y": 118 + }, + { + "x": 24, + "y": 311 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-2 -> postgres)[0]", + "src": "api-2", + "srcArrow": "none", + "dst": "postgres", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 82.66600036621094, + "y": 224 + }, + { + "x": 83, + "y": 305 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(postgres -> external)[0]", + "src": "postgres", + "srcArrow": "none", + "dst": "external", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "black", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 65, + "y": 422 + }, + { + "x": 65, + "y": 492 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-1 <-> api-2)[0]", + "src": "api-1", + "srcArrow": "triangle", + "dst": "api-2", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 2, + "stroke": "red", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 105.75, + "y": 78 + }, + { + "x": 105.75, + "y": 158 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(api-1 -> api-3)[0]", + "src": "api-1", + "srcArrow": "none", + "dst": "api-3", + "dstArrow": "circle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 135.75, + "y": 78 + }, + { + "x": 135.75, + "y": 118 + }, + { + "x": 206.75, + "y": 118 + }, + { + "x": 206.75, + "y": 158 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "N7", + "stroke": "", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + }, + "legend": { + "shapes": [ + { + "id": "a", + "type": "rectangle", + "pos": { + "x": 10, + "y": 10 + }, + "width": 100, + "height": 100, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "Microservice", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 1 + }, + { + "id": "b", + "type": "cylinder", + "pos": { + "x": 10, + "y": 10 + }, + "width": 100, + "height": 100, + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "AA4", + "stroke": "B2", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "Database", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(a <-> b)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 2, + "strokeWidth": 1, + "stroke": "red", + "borderRadius": 10, + "label": "Good relationship", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a -> b)[0]", + "src": "a", + "srcArrow": "none", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "Bad relationship", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a -> b)[1]", + "src": "a", + "srcArrow": "none", + "dst": "b", + "dstArrow": "circle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "Tenuous", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 10, + "y": 10 + }, + { + "x": 110, + "y": 10 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ] + } +} diff --git a/e2etests/testdata/txtar/legend/elk/sketch.exp.svg b/e2etests/testdata/txtar/legend/elk/sketch.exp.svg new file mode 100644 index 000000000..1cf312712 --- /dev/null +++ b/e2etests/testdata/txtar/legend/elk/sketch.exp.svg @@ -0,0 +1,106 @@ +api-1api-2postgresexternalapi-3 LegendMicroserviceDatabase Good relationship Bad relationship Tenuous + + + + + + + \ No newline at end of file diff --git a/e2etests/txtar.txt b/e2etests/txtar.txt index ec6be2ce9..591372192 100644 --- a/e2etests/txtar.txt +++ b/e2etests/txtar.txt @@ -1127,3 +1127,45 @@ customer -> email_system: "Sends e-mails to" internet_banking_system.api_app -> email_system: "Sends e-mail using" internet_banking_system.database <-> internet_banking_system.api_app: "Reads from and writes to\n[SQL/TCP]" +-- legend -- +vars: { + d2-legend: { + a: { + label: Microservice + } + b: Database { + shape: cylinder + style.stroke-dash: 2 + } + a <-> b: Good relationship { + style.stroke: red + style.stroke-dash: 2 + style.stroke-width: 1 + } + a -> b: Bad relationship + a -> b: Tenuous { + target-arrowhead.shape: circle + } + } +} + +api-1 +api-2 + +api-1 -> postgres +api-2 -> postgres + +postgres: { + shape: cylinder +} +postgres -> external: { + style.stroke: black +} + +api-1 <-> api-2: { + style.stroke: red + style.stroke-dash: 2 +} +api-1 -> api-3: { + target-arrowhead.shape: circle +}