@@ -44,6 +44,13 @@ const (
4444 DEFAULT_PADDING = 100
4545
4646 appendixIconRadius = 16
47+
48+ // Legend constants
49+ LEGEND_PADDING = 20
50+ LEGEND_ITEM_SPACING = 15
51+ LEGEND_ICON_SIZE = 24
52+ LEGEND_FONT_SIZE = 14
53+ LEGEND_CORNER_PADDING = 10
4754)
4855
4956var 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
101108 return left , top , width , height
102109}
103110
111+ func renderLegend (buf * bytes.Buffer , diagram * d2target.Diagram , diagramHash string , theme * d2themes.Theme ) error {
112+ if diagram .Legend == nil || (len (diagram .Legend .Shapes ) == 0 && len (diagram .Legend .Connections ) == 0 ) {
113+ return nil
114+ }
115+
116+ _ , br := diagram .BoundingBox ()
117+
118+ ruler , err := textmeasure .NewRuler ()
119+ if err != nil {
120+ return err
121+ }
122+
123+ totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING
124+ maxLabelWidth := 0
125+
126+ itemCount := 0
127+
128+ for _ , s := range diagram .Legend .Shapes {
129+ if s .Label == "" {
130+ continue
131+ }
132+
133+ mtext := & d2target.MText {
134+ Text : s .Label ,
135+ FontSize : LEGEND_FONT_SIZE ,
136+ }
137+
138+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
139+ maxLabelWidth = go2 .IntMax (maxLabelWidth , dims .Width )
140+ totalHeight += go2 .IntMax (dims .Height , LEGEND_ICON_SIZE ) + LEGEND_ITEM_SPACING
141+ itemCount ++
142+ }
143+
144+ for _ , c := range diagram .Legend .Connections {
145+ if c .Label == "" {
146+ continue
147+ }
148+
149+ mtext := & d2target.MText {
150+ Text : c .Label ,
151+ FontSize : LEGEND_FONT_SIZE ,
152+ }
153+
154+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
155+ maxLabelWidth = go2 .IntMax (maxLabelWidth , dims .Width )
156+ totalHeight += go2 .IntMax (dims .Height , LEGEND_ICON_SIZE ) + LEGEND_ITEM_SPACING
157+ itemCount ++
158+ }
159+
160+ if itemCount > 0 {
161+ totalHeight -= LEGEND_ITEM_SPACING / 2
162+ }
163+
164+ if itemCount > 0 && len (diagram .Legend .Connections ) > 0 {
165+ totalHeight += LEGEND_PADDING * 1.5
166+ } else {
167+ totalHeight += LEGEND_PADDING * 1.2
168+ }
169+
170+ legendWidth := LEGEND_PADDING * 2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth
171+ legendX := br .X + LEGEND_CORNER_PADDING
172+ tl , _ := diagram .BoundingBox ()
173+ legendY := br .Y - totalHeight
174+ if legendY < tl .Y {
175+ legendY = tl .Y
176+ }
177+
178+ shadowEl := d2themes .NewThemableElement ("rect" , theme )
179+ shadowEl .Fill = "#F7F7FA"
180+ shadowEl .Stroke = "#DEE1EB"
181+ shadowEl .Style = "stroke-width: 1px; filter: drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.1))"
182+ shadowEl .X = float64 (legendX )
183+ shadowEl .Y = float64 (legendY )
184+ shadowEl .Width = float64 (legendWidth )
185+ shadowEl .Height = float64 (totalHeight )
186+ shadowEl .Rx = 4
187+ fmt .Fprint (buf , shadowEl .Render ())
188+
189+ legendEl := d2themes .NewThemableElement ("rect" , theme )
190+ legendEl .Fill = "#ffffff"
191+ legendEl .Stroke = "#DEE1EB"
192+ legendEl .Style = "stroke-width: 1px"
193+ legendEl .X = float64 (legendX )
194+ legendEl .Y = float64 (legendY )
195+ legendEl .Width = float64 (legendWidth )
196+ legendEl .Height = float64 (totalHeight )
197+ legendEl .Rx = 4
198+ fmt .Fprint (buf , legendEl .Render ())
199+
200+ fmt .Fprintf (buf , `<text class="text-bold" x="%d" y="%d" style="font-size: %dpx;">Legend</text>` ,
201+ legendX + LEGEND_PADDING , legendY + LEGEND_PADDING + LEGEND_FONT_SIZE , LEGEND_FONT_SIZE + 2 )
202+
203+ currentY := legendY + LEGEND_PADDING * 2 + LEGEND_FONT_SIZE
204+
205+ shapeCount := 0
206+ for _ , s := range diagram .Legend .Shapes {
207+ if s .Label == "" {
208+ continue
209+ }
210+
211+ iconX := legendX + LEGEND_PADDING
212+ iconY := currentY
213+
214+ shapeIcon , err := renderLegendShapeIcon (s , iconX , iconY , diagramHash , theme )
215+ if err != nil {
216+ return err
217+ }
218+ fmt .Fprint (buf , shapeIcon )
219+
220+ mtext := & d2target.MText {
221+ Text : s .Label ,
222+ FontSize : LEGEND_FONT_SIZE ,
223+ }
224+
225+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
226+
227+ rowHeight := go2 .IntMax (dims .Height , LEGEND_ICON_SIZE )
228+ textY := currentY + rowHeight / 2 + int (float64 (dims .Height )* 0.3 )
229+
230+ fmt .Fprintf (buf , `<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>` ,
231+ iconX + LEGEND_ICON_SIZE + LEGEND_PADDING , textY , LEGEND_FONT_SIZE ,
232+ html .EscapeString (s .Label ))
233+
234+ currentY += rowHeight + LEGEND_ITEM_SPACING
235+ shapeCount ++
236+ }
237+
238+ if shapeCount > 0 && len (diagram .Legend .Connections ) > 0 {
239+ currentY += LEGEND_ITEM_SPACING / 2
240+
241+ separatorEl := d2themes .NewThemableElement ("line" , theme )
242+ separatorEl .X1 = float64 (legendX + LEGEND_PADDING )
243+ separatorEl .Y1 = float64 (currentY )
244+ separatorEl .X2 = float64 (legendX + legendWidth - LEGEND_PADDING )
245+ separatorEl .Y2 = float64 (currentY )
246+ separatorEl .Stroke = "#DEE1EB"
247+ separatorEl .StrokeDashArray = "2,2"
248+ fmt .Fprint (buf , separatorEl .Render ())
249+
250+ currentY += LEGEND_ITEM_SPACING
251+ }
252+
253+ for _ , c := range diagram .Legend .Connections {
254+ if c .Label == "" {
255+ continue
256+ }
257+
258+ iconX := legendX + LEGEND_PADDING
259+ iconY := currentY + LEGEND_ICON_SIZE / 2
260+
261+ connIcon , err := renderLegendConnectionIcon (c , iconX , iconY , theme )
262+ if err != nil {
263+ return err
264+ }
265+ fmt .Fprint (buf , connIcon )
266+
267+ mtext := & d2target.MText {
268+ Text : c .Label ,
269+ FontSize : LEGEND_FONT_SIZE ,
270+ }
271+
272+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
273+
274+ rowHeight := go2 .IntMax (dims .Height , LEGEND_ICON_SIZE )
275+ textY := currentY + rowHeight / 2 + int (float64 (dims .Height )* 0.2 )
276+
277+ fmt .Fprintf (buf , `<text class="text" x="%d" y="%d" style="font-size: %dpx;">%s</text>` ,
278+ iconX + LEGEND_ICON_SIZE + LEGEND_PADDING , textY , LEGEND_FONT_SIZE ,
279+ html .EscapeString (c .Label ))
280+
281+ currentY += rowHeight + LEGEND_ITEM_SPACING
282+ }
283+
284+ if shapeCount > 0 && len (diagram .Legend .Connections ) > 0 {
285+ currentY += LEGEND_PADDING / 2
286+ } else {
287+ currentY += LEGEND_PADDING / 4
288+ }
289+
290+ return nil
291+ }
292+
293+ func renderLegendShapeIcon (s d2target.Shape , x , y int , diagramHash string , theme * d2themes.Theme ) (string , error ) {
294+ iconShape := s
295+ const sizeFactor = 5
296+ iconShape .Pos .X = 0
297+ iconShape .Pos .Y = 0
298+ iconShape .Width = LEGEND_ICON_SIZE * sizeFactor
299+ iconShape .Height = LEGEND_ICON_SIZE * sizeFactor
300+ iconShape .Label = ""
301+ buf := & bytes.Buffer {}
302+ appendixBuf := & bytes.Buffer {}
303+ finalBuf := & bytes.Buffer {}
304+ fmt .Fprintf (finalBuf , `<g transform="translate(%d, %d) scale(%f)">` ,
305+ x , y , 1.0 / sizeFactor )
306+ _ , err := drawShape (buf , appendixBuf , diagramHash , iconShape , nil , theme )
307+ if err != nil {
308+ return "" , err
309+ }
310+
311+ fmt .Fprint (finalBuf , buf .String ())
312+
313+ fmt .Fprint (finalBuf , `</g>` )
314+
315+ return finalBuf .String (), nil
316+ }
317+
318+ func renderLegendConnectionIcon (c d2target.Connection , x , y int , theme * d2themes.Theme ) (string , error ) {
319+ finalBuf := & bytes.Buffer {}
320+
321+ buf := & bytes.Buffer {}
322+
323+ const sizeFactor = 2
324+
325+ legendConn := * d2target .BaseConnection ()
326+
327+ legendConn .ID = c .ID
328+ legendConn .SrcArrow = c .SrcArrow
329+ legendConn .DstArrow = c .DstArrow
330+ legendConn .StrokeDash = c .StrokeDash
331+ legendConn .StrokeWidth = c .StrokeWidth
332+ legendConn .Stroke = c .Stroke
333+ legendConn .Fill = c .Fill
334+ legendConn .BorderRadius = c .BorderRadius
335+ legendConn .Opacity = c .Opacity
336+ legendConn .Animated = c .Animated
337+
338+ startX := 0.0
339+ midY := 0.0
340+ width := float64 (LEGEND_ICON_SIZE * sizeFactor )
341+
342+ legendConn .Route = []* geo.Point {
343+ {X : startX , Y : midY },
344+ {X : startX + width , Y : midY },
345+ }
346+
347+ legendHash := fmt .Sprintf ("legend-%s" , hash (fmt .Sprintf ("%s-%d-%d" , c .ID , x , y )))
348+
349+ markers := make (map [string ]struct {})
350+ idToShape := make (map [string ]d2target.Shape )
351+
352+ fmt .Fprintf (finalBuf , `<g transform="translate(%d, %d) scale(%f)">` ,
353+ x , y , 1.0 / sizeFactor )
354+
355+ _ , err := drawConnection (buf , legendHash , legendConn , markers , idToShape , nil , theme )
356+ if err != nil {
357+ return "" , err
358+ }
359+
360+ fmt .Fprint (finalBuf , buf .String ())
361+
362+ fmt .Fprint (finalBuf , `</g>` )
363+
364+ return finalBuf .String (), nil
365+ }
366+
104367func arrowheadMarkerID (diagramHash string , isTarget bool , connection d2target.Connection ) string {
105368 var arrowhead d2target.Arrowhead
106369 if isTarget {
@@ -2085,8 +2348,85 @@ func Render(diagram *d2target.Diagram, opts *RenderOpts) ([]byte, error) {
20852348 // add all appendix items afterwards so they are always on top
20862349 fmt .Fprint (buf , appendixItemBuf )
20872350
2351+ if diagram .Legend != nil && (len (diagram .Legend .Shapes ) > 0 || len (diagram .Legend .Connections ) > 0 ) {
2352+ legendBuf := & bytes.Buffer {}
2353+ err := renderLegend (legendBuf , diagram , diagramHash , inlineTheme )
2354+ if err != nil {
2355+ return nil , err
2356+ }
2357+ fmt .Fprint (buf , legendBuf )
2358+ }
2359+
20882360 // Note: we always want this since we reference it on connections even if there end up being no masked labels
20892361 left , top , w , h := dimensions (diagram , pad )
2362+
2363+ if diagram .Legend != nil && (len (diagram .Legend .Shapes ) > 0 || len (diagram .Legend .Connections ) > 0 ) {
2364+ tl , br := diagram .BoundingBox ()
2365+ totalHeight := LEGEND_PADDING + LEGEND_FONT_SIZE + LEGEND_ITEM_SPACING
2366+ maxLabelWidth := 0
2367+ itemCount := 0
2368+ ruler , _ := textmeasure .NewRuler ()
2369+ if ruler != nil {
2370+ for _ , s := range diagram .Legend .Shapes {
2371+ if s .Label == "" {
2372+ continue
2373+ }
2374+ mtext := & d2target.MText {
2375+ Text : s .Label ,
2376+ FontSize : LEGEND_FONT_SIZE ,
2377+ }
2378+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
2379+ maxLabelWidth = go2 .IntMax (maxLabelWidth , dims .Width )
2380+ totalHeight += go2 .IntMax (dims .Height , LEGEND_ICON_SIZE ) + LEGEND_ITEM_SPACING
2381+ itemCount ++
2382+ }
2383+
2384+ for _ , c := range diagram .Legend .Connections {
2385+ if c .Label == "" {
2386+ continue
2387+ }
2388+ mtext := & d2target.MText {
2389+ Text : c .Label ,
2390+ FontSize : LEGEND_FONT_SIZE ,
2391+ }
2392+ dims := d2graph .GetTextDimensions (nil , ruler , mtext , nil )
2393+ maxLabelWidth = go2 .IntMax (maxLabelWidth , dims .Width )
2394+ totalHeight += go2 .IntMax (dims .Height , LEGEND_ICON_SIZE ) + LEGEND_ITEM_SPACING
2395+ itemCount ++
2396+ }
2397+
2398+ if itemCount > 0 {
2399+ totalHeight -= LEGEND_ITEM_SPACING / 2
2400+ }
2401+
2402+ totalHeight += LEGEND_PADDING
2403+
2404+ if totalHeight > 0 && maxLabelWidth > 0 {
2405+ legendWidth := LEGEND_PADDING * 2 + LEGEND_ICON_SIZE + LEGEND_PADDING + maxLabelWidth
2406+
2407+ legendY := br .Y - totalHeight
2408+ if legendY < tl .Y {
2409+ legendY = tl .Y
2410+ }
2411+
2412+ legendRight := br .X + LEGEND_CORNER_PADDING + legendWidth
2413+ if left + w < legendRight {
2414+ w = legendRight - left + pad / 2
2415+ }
2416+
2417+ if legendY < top {
2418+ diffY := top - legendY
2419+ top -= diffY
2420+ h += diffY
2421+ }
2422+
2423+ legendBottom := legendY + totalHeight
2424+ if top + h < legendBottom {
2425+ h = legendBottom - top + pad / 2
2426+ }
2427+ }
2428+ }
2429+ }
20902430 fmt .Fprint (buf , strings .Join ([]string {
20912431 fmt .Sprintf (`<mask id="%s" maskUnits="userSpaceOnUse" x="%d" y="%d" width="%d" height="%d">` ,
20922432 isolatedDiagramHash , left , top , w , h ,
0 commit comments