From 2f545e3f61342afec6b1ee828201ace3c0dbc5d0 Mon Sep 17 00:00:00 2001 From: Alexander Wang Date: Thu, 22 Dec 2022 11:06:57 -0800 Subject: [PATCH] tables --- d2renderers/d2sketch/sketch.go | 170 ++++++++++++++++-- d2renderers/d2sketch/sketch_test.go | 52 ++++++ .../d2sketch/testdata/class/sketch.exp.svg | 55 ++++++ .../testdata/sql_tables/sketch.exp.svg | 75 ++++++++ d2renderers/d2svg/class.go | 7 +- d2renderers/d2svg/d2svg.go | 27 +-- d2renderers/d2svg/table.go | 26 +-- d2target/sqltable.go | 13 ++ lib/svg/text.go | 12 ++ 9 files changed, 386 insertions(+), 51 deletions(-) create mode 100644 d2renderers/d2sketch/testdata/class/sketch.exp.svg create mode 100644 d2renderers/d2sketch/testdata/sql_tables/sketch.exp.svg create mode 100644 lib/svg/text.go diff --git a/d2renderers/d2sketch/sketch.go b/d2renderers/d2sketch/sketch.go index a8b6326b1..cbd696505 100644 --- a/d2renderers/d2sketch/sketch.go +++ b/d2renderers/d2sketch/sketch.go @@ -3,13 +3,17 @@ package d2sketch import ( "encoding/json" "fmt" + "strings" _ "embed" "github.com/dop251/goja" "oss.terrastruct.com/d2/d2target" + "oss.terrastruct.com/d2/lib/geo" + "oss.terrastruct.com/d2/lib/label" "oss.terrastruct.com/d2/lib/svg" + "oss.terrastruct.com/util-go/go2" ) //go:embed fillpattern.svg @@ -81,10 +85,7 @@ func Rect(r *Runner, shape d2target.Shape) (string, error) { strokeWidth: %d, %s });`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps) - if _, err := r.run(js); err != nil { - return "", err - } - paths, err := extractPaths(r) + paths, err := computeRoughPaths(r, js) if err != nil { return "", err } @@ -109,10 +110,7 @@ func Oval(r *Runner, shape d2target.Shape) (string, error) { strokeWidth: %d, %s });`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps) - if _, err := r.run(js); err != nil { - return "", err - } - paths, err := extractPaths(r) + paths, err := computeRoughPaths(r, js) if err != nil { return "", err } @@ -140,10 +138,7 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) { strokeWidth: %d, %s });`, path, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps) - if _, err := r.run(js); err != nil { - return "", err - } - sketchPaths, err := extractPaths(r) + sketchPaths, err := computeRoughPaths(r, js) if err != nil { return "", err } @@ -180,10 +175,7 @@ func connectionStyle(connection d2target.Connection) string { func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) { roughness := 1.0 js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness) - if _, err := r.run(js); err != nil { - return "", err - } - paths, err := extractPaths(r) + paths, err := computeRoughPaths(r, js) if err != nil { return "", err } @@ -197,6 +189,152 @@ func Connection(r *Runner, connection d2target.Connection, path, attrs string) ( return output, nil } +// TODO cleanup +func Table(r *Runner, shape d2target.Shape) (string, error) { + output := "" + js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, { + fill: "%s", + stroke: "%s", + strokeWidth: %d, + %s + });`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps) + paths, err := computeRoughPaths(r, js) + if err != nil { + return "", err + } + for _, p := range paths { + output += fmt.Sprintf( + ``, + shape.Pos.X, shape.Pos.Y, p, shapeStyle(shape), + ) + } + + box := geo.NewBox( + geo.NewPoint(float64(shape.Pos.X), float64(shape.Pos.Y)), + float64(shape.Width), + float64(shape.Height), + ) + rowHeight := box.Height / float64(1+len(shape.SQLTable.Columns)) + headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight) + + js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, { + fill: "%s", + %s + });`, shape.Width, rowHeight, shape.Fill, baseRoughProps) + paths, err = computeRoughPaths(r, js) + if err != nil { + return "", err + } + for _, p := range paths { + // TODO header fill + output += fmt.Sprintf( + ``, + shape.Pos.X, shape.Pos.Y, p, "#0a0f25", + ) + } + + if shape.Label != "" { + tl := label.InsideMiddleLeft.GetPointOnBox( + headerBox, + 20, + float64(shape.LabelWidth), + float64(shape.LabelHeight), + ) + + // TODO header font color + output += fmt.Sprintf(`%s`, + "text", + tl.X, + tl.Y+float64(shape.LabelHeight)*3/4, + fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", + "start", + 4+shape.FontSize, + "white", + ), + svg.EscapeText(shape.Label), + ) + } + + var longestNameWidth int + for _, f := range shape.Columns { + longestNameWidth = go2.Max(longestNameWidth, f.Name.LabelWidth) + } + + rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight) + rowBox.TopLeft.Y += headerBox.Height + for _, f := range shape.Columns { + nameTL := label.InsideMiddleLeft.GetPointOnBox( + rowBox, + d2target.NamePadding, + rowBox.Width, + float64(shape.FontSize), + ) + constraintTR := label.InsideMiddleRight.GetPointOnBox( + rowBox, + d2target.TypePadding, + 0, + float64(shape.FontSize), + ) + + // TODO theme based + primaryColor := "rgb(13, 50, 178)" + accentColor := "rgb(74, 111, 243)" + neutralColor := "rgb(103, 108, 126)" + + output += strings.Join([]string{ + fmt.Sprintf(`%s`, + nameTL.X, + nameTL.Y+float64(shape.FontSize)*3/4, + fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), primaryColor), + svg.EscapeText(f.Name.Label), + ), + + // TODO light font + fmt.Sprintf(`%s`, + nameTL.X+float64(longestNameWidth)+2*d2target.NamePadding, + nameTL.Y+float64(shape.FontSize)*3/4, + fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), neutralColor), + svg.EscapeText(f.Type.Label), + ), + + fmt.Sprintf(`%s`, + constraintTR.X, + constraintTR.Y+float64(shape.FontSize)*3/4, + fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;letter-spacing:2px;", "end", float64(shape.FontSize), accentColor), + f.ConstraintAbbr(), + ), + }, "\n") + + rowBox.TopLeft.Y += rowHeight + + js = fmt.Sprintf(`node = rc.line(%f, %f, %f, %f, { + %s + });`, rowBox.TopLeft.X, rowBox.TopLeft.Y, rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y, baseRoughProps) + paths, err = computeRoughPaths(r, js) + if err != nil { + return "", err + } + for _, p := range paths { + output += fmt.Sprintf( + ``, + p, "#0a0f25", + ) + } + } + output += fmt.Sprintf( + ``, + shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height, + ) + return output, nil +} + +func computeRoughPaths(r *Runner, js string) ([]string, error) { + if _, err := r.run(js); err != nil { + return nil, err + } + return extractPaths(r) +} + type attrs struct { D string `json:"d"` } diff --git a/d2renderers/d2sketch/sketch_test.go b/d2renderers/d2sketch/sketch_test.go index cfcb5abdb..87a2e17a2 100644 --- a/d2renderers/d2sketch/sketch_test.go +++ b/d2renderers/d2sketch/sketch_test.go @@ -223,6 +223,58 @@ queue -> package -> step callout -> stored_data -> person diamond -> oval -> circle hexagon -> cloud +`, + }, + { + name: "sql_tables", + script: `users: { + shape: sql_table + id: int + name: string + email: string + password: string + last_login: datetime +} + +products: { + shape: sql_table + id: int + price: decimal + sku: string + name: string +} + +orders: { + shape: sql_table + id: int + user_id: int + product_id: int +} + +shipments: { + shape: sql_table + id: int + order_id: int + tracking_number: string + status: string +} + +users.id <-> orders.user_id +products.id <-> orders.product_id +shipments.order_id <-> orders.id`, + }, + { + name: "class", + script: `manager: BatchManager { + shape: class + -num: int + -timeout: int + -pid + + +getStatus(): Enum + +getJobs(): "Job[]" + +setTimeout(seconds int) +} `, }, } diff --git a/d2renderers/d2sketch/testdata/class/sketch.exp.svg b/d2renderers/d2sketch/testdata/class/sketch.exp.svg new file mode 100644 index 000000000..6c8175350 --- /dev/null +++ b/d2renderers/d2sketch/testdata/class/sketch.exp.svg @@ -0,0 +1,55 @@ + + + + + + +BatchManager- +num +int- +timeout +int- +pid ++ +getStatus() +Enum+ +getJobs() +Job[]+ +setTimeout(seconds int) +void + + + \ No newline at end of file diff --git a/d2renderers/d2sketch/testdata/sql_tables/sketch.exp.svg b/d2renderers/d2sketch/testdata/sql_tables/sketch.exp.svg new file mode 100644 index 000000000..5509ac7ab --- /dev/null +++ b/d2renderers/d2sketch/testdata/sql_tables/sketch.exp.svg @@ -0,0 +1,75 @@ + + + + + + +usersid +int +name +string +email +string +password +string +last_login +datetime +productsid +int +price +decimal +sku +string +name +string +ordersid +int +user_id +int +product_id +int +shipmentsid +int +order_id +int +tracking_number +string +status +string + + + + \ No newline at end of file diff --git a/d2renderers/d2svg/class.go b/d2renderers/d2svg/class.go index af71ac1af..64ac62bab 100644 --- a/d2renderers/d2svg/class.go +++ b/d2renderers/d2svg/class.go @@ -8,6 +8,7 @@ import ( "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/lib/geo" "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/d2/lib/svg" ) func classHeader(box *geo.Box, text string, textWidth, textHeight, fontSize float64) string { @@ -32,7 +33,7 @@ func classHeader(box *geo.Box, text string, textWidth, textHeight, fontSize floa 4+fontSize, "white", ), - escapeText(text), + svg.EscapeText(text), ) } return str @@ -73,14 +74,14 @@ func classRow(box *geo.Box, prefix, nameText, typeText string, fontSize float64) prefixTL.X+prefixWidth, prefixTL.Y+fontSize*3/4, fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, "black"), - escapeText(nameText), + svg.EscapeText(nameText), ), fmt.Sprintf(`%s`, typeTR.X, typeTR.Y+fontSize*3/4, fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "end", fontSize, accentColor), - escapeText(typeText), + svg.EscapeText(typeText), ), }, "\n") } diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 7bd081068..896d1178e 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -5,7 +5,6 @@ package d2svg import ( "bytes" _ "embed" - "encoding/xml" "errors" "fmt" "hash/fnv" @@ -357,7 +356,7 @@ func makeLabelMask(labelTL *geo.Point, width, height int) string { } func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) { - fmt.Fprintf(writer, ``, escapeText(connection.ID)) + fmt.Fprintf(writer, ``, svg.EscapeText(connection.ID)) var markerStart string if connection.SrcArrow != d2target.NoArrowhead { id := arrowheadMarkerID(false, connection) @@ -536,7 +535,7 @@ func render3dRect(targetShape d2target.Shape) string { strings.Join(borderSegments, " "), borderStyle) // create mask from border stroke, to cut away from the shape fills - maskID := fmt.Sprintf("border-mask-%v", escapeText(targetShape.ID)) + maskID := fmt.Sprintf("border-mask-%v", svg.EscapeText(targetShape.ID)) borderMask := strings.Join([]string{ fmt.Sprintf(``, maskID, targetShape.Pos.X, targetShape.Pos.Y-threeDeeOffset, targetShape.Width+threeDeeOffset, targetShape.Height+threeDeeOffset, @@ -583,7 +582,7 @@ func render3dRect(targetShape d2target.Shape) string { } func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, err error) { - fmt.Fprintf(writer, ``, escapeText(targetShape.ID)) + fmt.Fprintf(writer, ``, svg.EscapeText(targetShape.ID)) tl := geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)) width := float64(targetShape.Width) height := float64(targetShape.Height) @@ -622,7 +621,15 @@ func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2ske fmt.Fprintf(writer, ``) return labelMask, nil case d2target.ShapeSQLTable: - drawTable(writer, targetShape) + if sketchRunner != nil { + out, err := d2sketch.Table(sketchRunner, targetShape) + if err != nil { + return "", err + } + fmt.Fprintf(writer, out) + } else { + drawTable(writer, targetShape) + } fmt.Fprintf(writer, ``) return labelMask, nil case d2target.ShapeOval: @@ -823,15 +830,9 @@ func drawShape(writer io.Writer, targetShape d2target.Shape, sketchRunner *d2ske return labelMask, nil } -func escapeText(text string) string { - buf := new(bytes.Buffer) - _ = xml.EscapeText(buf, []byte(text)) - return buf.String() -} - func renderText(text string, x, height float64) string { if !strings.Contains(text, "\n") { - return escapeText(text) + return svg.EscapeText(text) } rendered := []string{} lines := strings.Split(text, "\n") @@ -840,7 +841,7 @@ func renderText(text string, x, height float64) string { if i == 0 { dy = 0 } - escaped := escapeText(line) + escaped := svg.EscapeText(line) if escaped == "" { // if there are multiple newlines in a row we still need text for the tspan to render escaped = " " diff --git a/d2renderers/d2svg/table.go b/d2renderers/d2svg/table.go index 9407c0b82..5b4abaed9 100644 --- a/d2renderers/d2svg/table.go +++ b/d2renderers/d2svg/table.go @@ -8,6 +8,7 @@ import ( "oss.terrastruct.com/d2/d2target" "oss.terrastruct.com/d2/lib/geo" "oss.terrastruct.com/d2/lib/label" + "oss.terrastruct.com/d2/lib/svg" "oss.terrastruct.com/util-go/go2" ) @@ -32,7 +33,7 @@ func tableHeader(box *geo.Box, text string, textWidth, textHeight, fontSize floa 4+fontSize, "white", ), - escapeText(text), + svg.EscapeText(text), ) } return str @@ -64,7 +65,7 @@ func tableRow(box *geo.Box, nameText, typeText, constraintText string, fontSize, nameTL.X, nameTL.Y+fontSize*3/4, fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, primaryColor), - escapeText(nameText), + svg.EscapeText(nameText), ), // TODO light font @@ -72,7 +73,7 @@ func tableRow(box *geo.Box, nameText, typeText, constraintText string, fontSize, nameTL.X+longestNameWidth+2*d2target.NamePadding, nameTL.Y+fontSize*3/4, fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, neutralColor), - escapeText(typeText), + svg.EscapeText(typeText), ), fmt.Sprintf(`%s`, @@ -84,19 +85,6 @@ func tableRow(box *geo.Box, nameText, typeText, constraintText string, fontSize, }, "\n") } -func constraintAbbr(constraint string) string { - switch constraint { - case "primary_key": - return "PK" - case "foreign_key": - return "FK" - case "unique": - return "UNQ" - default: - return "" - } -} - func drawTable(writer io.Writer, targetShape d2target.Shape) { fmt.Fprintf(writer, ``, targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, shapeStyle(targetShape)) @@ -114,15 +102,15 @@ func drawTable(writer io.Writer, targetShape d2target.Shape) { ) var longestNameWidth int - for _, f := range targetShape.SQLTable.Columns { + for _, f := range targetShape.Columns { longestNameWidth = go2.Max(longestNameWidth, f.Name.LabelWidth) } rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight) rowBox.TopLeft.Y += headerBox.Height - for _, f := range targetShape.SQLTable.Columns { + for _, f := range targetShape.Columns { fmt.Fprint(writer, - tableRow(rowBox, f.Name.Label, f.Type.Label, constraintAbbr(f.Constraint), float64(targetShape.FontSize), float64(longestNameWidth)), + tableRow(rowBox, f.Name.Label, f.Type.Label, f.ConstraintAbbr(), float64(targetShape.FontSize), float64(longestNameWidth)), ) rowBox.TopLeft.Y += rowHeight fmt.Fprintf(writer, ``, diff --git a/d2target/sqltable.go b/d2target/sqltable.go index 8642e77a0..ad6b6f61b 100644 --- a/d2target/sqltable.go +++ b/d2target/sqltable.go @@ -31,3 +31,16 @@ func (c SQLColumn) Text() *MText { Shape: "sql_table", } } + +func (c SQLColumn) ConstraintAbbr() string { + switch c.Constraint { + case "primary_key": + return "PK" + case "foreign_key": + return "FK" + case "unique": + return "UNQ" + default: + return "" + } +} diff --git a/lib/svg/text.go b/lib/svg/text.go new file mode 100644 index 000000000..a729d5675 --- /dev/null +++ b/lib/svg/text.go @@ -0,0 +1,12 @@ +package svg + +import ( + "bytes" + "encoding/xml" +) + +func EscapeText(text string) string { + buf := new(bytes.Buffer) + _ = xml.EscapeText(buf, []byte(text)) + return buf.String() +}