Skip to content

Commit 3e4397f

Browse files
committedMar 11, 2025··
d2svg: implement legend
1 parent 38851ef commit 3e4397f

File tree

8 files changed

+5005
-0
lines changed

8 files changed

+5005
-0
lines changed
 

‎d2renderers/d2svg/d2svg.go

+340
Original file line numberDiff line numberDiff line change
@@ -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

4956
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
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+
104367
func 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,

‎d2renderers/d2svg/d2svg.go-e

+2,992
Large diffs are not rendered by default.

‎d2target/d2target.go

+10
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,16 @@ func (diagram Diagram) GetCorpus() string {
456456
}
457457
}
458458

459+
if diagram.Legend != nil {
460+
corpus += "Legend"
461+
for _, s := range diagram.Legend.Shapes {
462+
corpus += s.Label
463+
}
464+
for _, c := range diagram.Legend.Connections {
465+
corpus += c.Label
466+
}
467+
}
468+
459469
return corpus
460470
}
461471

‎e2etests/testdata/txtar/legend/dagre/board.exp.json

+725
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎e2etests/testdata/txtar/legend/dagre/sketch.exp.svg

+106
Loading

‎e2etests/testdata/txtar/legend/elk/board.exp.json

+684
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎e2etests/testdata/txtar/legend/elk/sketch.exp.svg

+106
Loading

‎e2etests/txtar.txt

+42
Original file line numberDiff line numberDiff line change
@@ -1127,3 +1127,45 @@ customer -> email_system: "Sends e-mails to"
11271127
internet_banking_system.api_app -> email_system: "Sends e-mail using"
11281128
internet_banking_system.database <-> internet_banking_system.api_app: "Reads from and writes to\n[SQL/TCP]"
11291129

1130+
-- legend --
1131+
vars: {
1132+
d2-legend: {
1133+
a: {
1134+
label: Microservice
1135+
}
1136+
b: Database {
1137+
shape: cylinder
1138+
style.stroke-dash: 2
1139+
}
1140+
a <-> b: Good relationship {
1141+
style.stroke: red
1142+
style.stroke-dash: 2
1143+
style.stroke-width: 1
1144+
}
1145+
a -> b: Bad relationship
1146+
a -> b: Tenuous {
1147+
target-arrowhead.shape: circle
1148+
}
1149+
}
1150+
}
1151+
1152+
api-1
1153+
api-2
1154+
1155+
api-1 -> postgres
1156+
api-2 -> postgres
1157+
1158+
postgres: {
1159+
shape: cylinder
1160+
}
1161+
postgres -> external: {
1162+
style.stroke: black
1163+
}
1164+
1165+
api-1 <-> api-2: {
1166+
style.stroke: red
1167+
style.stroke-dash: 2
1168+
}
1169+
api-1 -> api-3: {
1170+
target-arrowhead.shape: circle
1171+
}

0 commit comments

Comments
 (0)
Please sign in to comment.