Skip to content

Commit a169a54

Browse files
committed
Add support for analyzing GopherJS artifact size.
When --analyze_size flag is passed to GopherJS, an additional ${packagename}.js.svg file is creates with a TreeMap diagram that visualizes which packages contributed to the artifact size and how much. The data is obtained by counting bytes written for each section of the file including GopherJS's own prelude and works accurately both in minified and default modes. Most of diagram-related logic is isolated to the build/analysis package with minimal changes to the rest of the code base.
1 parent 1c73b42 commit a169a54

File tree

8 files changed

+846
-0
lines changed

8 files changed

+846
-0
lines changed

build/analysis/rect.go

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
package analysis
2+
3+
import (
4+
"encoding/xml"
5+
"fmt"
6+
"io"
7+
"math"
8+
)
9+
10+
// rect represents a single rectangle area on the SVG rendering of the TreeMap diagram.
11+
type rect struct {
12+
left float64
13+
top float64
14+
right float64
15+
bottom float64
16+
transposed bool
17+
}
18+
19+
func newRect(w, h float64) rect { return rect{0, 0, w, h, false} }
20+
21+
func (r rect) width() float64 { return r.right - r.left }
22+
23+
func (r rect) height() float64 { return r.bottom - r.top }
24+
25+
// long returns the length of the longer side of the rectangle.
26+
func (r rect) long() float64 {
27+
w, h := r.width(), r.height()
28+
if w > h {
29+
return w
30+
}
31+
return h
32+
}
33+
34+
// short returns the length of the shorter side of the rectangle.
35+
func (r rect) short() float64 {
36+
w, h := r.width(), r.height()
37+
if w < h {
38+
return w
39+
}
40+
return h
41+
}
42+
43+
// padding between the current rect's bounds and nested rectangles representing
44+
// child nodes in the TreeMap.
45+
func (r rect) padding() float64 {
46+
const factor = 0.025
47+
padding := r.short() * factor
48+
if padding > 10 {
49+
padding = 10
50+
}
51+
return padding
52+
}
53+
54+
// shrink the rectangle bounds by the given margin on every side.
55+
//
56+
// The center of the shrinked rectangle will be the same. Padding is
57+
// automatically reduced to prevent shrinking to zero if necessary.
58+
func (r rect) shrink(padding float64) rect {
59+
if padding*2 > r.short()/2 {
60+
padding = r.short() / 4
61+
}
62+
shrinked := rect{
63+
left: r.left + padding,
64+
top: r.top + padding,
65+
right: r.right - padding,
66+
bottom: r.bottom - padding,
67+
transposed: r.transposed,
68+
}
69+
return shrinked
70+
}
71+
72+
// aspectRatio returns width divided by height.
73+
func (r rect) aspectRatio() float64 { return r.width() / r.height() }
74+
75+
// transpose flips X and Y coordinates of the rectangle and sets the
76+
// "transposed" flag appropriately.
77+
func (r rect) transpose() rect {
78+
return rect{
79+
left: r.top,
80+
top: r.left,
81+
right: r.bottom,
82+
bottom: r.right,
83+
transposed: !r.transposed,
84+
}
85+
}
86+
87+
// orientHorizontally transposes the rectangle such that the horizontal size is
88+
// always the longer.
89+
//
90+
// When subdividing a rectangle in the diagram we always lay down stacks of
91+
// children along the longer side for better readability, always orienting the
92+
// rectangle horizontally prevents a lot of branching in the layout code.
93+
// Calling restoreOrientation() will place the rectangle in the corrent place
94+
// regardless of how many times it or its parents were transposed.
95+
func (r rect) orientHorizontally() rect {
96+
if r.aspectRatio() < 1 {
97+
return r.transpose()
98+
}
99+
return r
100+
}
101+
102+
// restoreOrientation transposes the rectangle if necessary to return it to the
103+
// original coordinate system.
104+
func (r rect) restoreOrientation() rect {
105+
if r.transposed {
106+
return r.transpose()
107+
}
108+
return r
109+
}
110+
111+
// split the rectangle along the horizontal axis in protortion to the weights.
112+
// transpose() the rectangle first in order to split along the vertical axis.
113+
func (r rect) split(weights ...float64) rects {
114+
var total float64
115+
for _, w := range weights {
116+
total += w
117+
}
118+
119+
parts := []rect{}
120+
var processedFraction float64
121+
for _, w := range weights {
122+
fraction := w / total
123+
parts = append(parts, rect{
124+
left: r.left + r.width()*processedFraction,
125+
top: r.top,
126+
right: r.left + r.width()*(processedFraction+fraction),
127+
bottom: r.bottom,
128+
transposed: r.transposed,
129+
})
130+
processedFraction += fraction
131+
}
132+
133+
return parts
134+
}
135+
136+
// toSVG writes the SVG code to represent the current rect given the display options.
137+
//
138+
// Coordinates and sizes are rounded to 2 decimal digits to reduce chances of
139+
// tests flaking out due to floating point imprecision. This will have no
140+
// visible impact on the rendering since 0.01 unit == 0.01 pixel by default.
141+
func (r rect) toSVG(w io.Writer, opts ...svgOpt) error {
142+
normal := r.restoreOrientation() // Make sure we render the rect in its actual position.
143+
144+
// Populate the default SVG representation of the rect for its coordinates.
145+
data := svgGroup{
146+
Rect: &svgRect{
147+
X: round2(normal.left),
148+
Y: round2(normal.top),
149+
Width: round2(normal.width()),
150+
Height: round2(normal.height()),
151+
Fill: "black",
152+
FillOpacity: 1,
153+
Stroke: "black",
154+
StrokeWidth: 0,
155+
},
156+
Text: &svgText{
157+
X: round2(normal.left + normal.width()/2),
158+
Y: round2(normal.top + normal.height()/2),
159+
TextAnchor: "middle",
160+
Baseline: "middle",
161+
FontSize: 20,
162+
},
163+
}
164+
165+
// Apply all the display options passed by the caller.
166+
for _, o := range opts {
167+
o(&data)
168+
}
169+
170+
// Adjust rect label display such that it is readable and fits rectangle
171+
// bounds. The constants below are empirically determined.
172+
const (
173+
fontFactorX = 1.5
174+
fontFactorY = 0.8
175+
minFont = 8
176+
)
177+
if data.Text.FontSize > normal.short()*fontFactorY {
178+
data.Text.FontSize = normal.short() * fontFactorY
179+
}
180+
if l := float64(len(data.Text.Text)); l*data.Text.FontSize > normal.long()*fontFactorX {
181+
data.Text.FontSize = normal.long() / l * fontFactorX
182+
}
183+
if data.Text.FontSize < minFont {
184+
data.Text.Text = ""
185+
}
186+
data.Text.FontSize = round2(data.Text.FontSize)
187+
if normal.aspectRatio() < 1 {
188+
data.Text.Transform = fmt.Sprintf("rotate(270 %0.2f %0.2f)", data.Text.X, data.Text.Y)
189+
}
190+
191+
if data.Text.Text == "" {
192+
// Remove the text element if there's nothing to show.
193+
data.Text = nil
194+
}
195+
if data.Rect.FillOpacity == 0 && data.Rect.StrokeWidth == 0 {
196+
// Remove the rect element if it isn't supposed to be visible.
197+
data.Rect = nil
198+
}
199+
200+
defer w.Write([]byte("\n"))
201+
return xml.NewEncoder(w).Encode(data)
202+
}
203+
204+
type svgGroup struct {
205+
XMLName struct{} `xml:"g"`
206+
Rect *svgRect
207+
Text *svgText `xml:",omitempty"`
208+
}
209+
210+
type svgRect struct {
211+
XMLName struct{} `xml:"rect"`
212+
X float64 `xml:"x,attr"`
213+
Y float64 `xml:"y,attr"`
214+
Width float64 `xml:"width,attr"`
215+
Height float64 `xml:"height,attr"`
216+
Fill string `xml:"fill,attr,omitempty"`
217+
FillOpacity float64 `xml:"fill-opacity,attr"`
218+
Stroke string `xml:"stroke,attr,omitempty"`
219+
StrokeWidth float64 `xml:"stroke-width,attr,omitempty"`
220+
Title string `xml:"title,omitempty"`
221+
}
222+
223+
type svgText struct {
224+
XMLName struct{} `xml:"text"`
225+
Text string `xml:",chardata"`
226+
X float64 `xml:"x,attr"`
227+
Y float64 `xml:"y,attr"`
228+
FontSize float64 `xml:"font-size,attr"`
229+
Transform string `xml:"transform,attr,omitempty"`
230+
TextAnchor string `xml:"text-anchor,attr,omitempty"`
231+
Baseline string `xml:"dominant-baseline,attr,omitempty"`
232+
}
233+
234+
type svgOpt func(r *svgGroup)
235+
236+
// WithTitle adds a hover tooltip text to the rectangle.
237+
func WithTooltip(t string) svgOpt {
238+
return func(g *svgGroup) { g.Rect.Title = t }
239+
}
240+
241+
// WithText adds a text label over the rectangle.
242+
func WithText(t string) svgOpt {
243+
return func(g *svgGroup) { g.Text.Text = t }
244+
}
245+
246+
// WithFill sets rectangle fill style.
247+
func WithFill(color string, opacity float64) svgOpt {
248+
return func(g *svgGroup) {
249+
g.Rect.Fill = color
250+
g.Rect.FillOpacity = opacity
251+
}
252+
}
253+
254+
// WithStroke sets rectangle outline stroke style.
255+
func WithStroke(color string, width float64) svgOpt {
256+
return func(g *svgGroup) {
257+
g.Rect.Stroke = color
258+
g.Rect.StrokeWidth = width
259+
}
260+
}
261+
262+
// rects is a group of rectangles representing sibling nodes in the tree.
263+
type rects []rect
264+
265+
// maxAspect returns the highest aspect ratio amount the rect group.
266+
//
267+
// Aspect ratios lesser than 1 are inverted to be above 1. The closer the return
268+
// value is to 1, the closer all rectangles in the group are to squares.
269+
func (rr rects) maxAspect() float64 {
270+
var result float64 = 1 // Start as if we have a perfectly square layout.
271+
for _, r := range rr {
272+
aspect := r.aspectRatio()
273+
if aspect < 1 {
274+
aspect = 1 / aspect
275+
}
276+
if aspect > result {
277+
result = aspect
278+
}
279+
}
280+
return result
281+
}
282+
283+
func round2(v float64) float64 { return math.Round(v*100) / 100 }

build/analysis/rect_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package analysis
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
8+
"github.com/google/go-cmp/cmp"
9+
"github.com/google/go-cmp/cmp/cmpopts"
10+
)
11+
12+
func TestRectSplit(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
original rect
16+
weights []float64
17+
parts rects
18+
}{
19+
{
20+
name: "single part",
21+
original: rect{0, 1, 10, 2, true},
22+
weights: []float64{42},
23+
parts: rects{{0, 1, 10, 2, true}},
24+
},
25+
{
26+
name: "two parts",
27+
original: rect{0, 1, 10, 2, true},
28+
weights: []float64{4, 6},
29+
parts: rects{{0, 1, 4, 2, true}, {4, 1, 10, 2, true}},
30+
},
31+
{
32+
name: "many parts",
33+
original: rect{0, 1, 10, 2, true},
34+
weights: []float64{2, 4, 6, 8},
35+
parts: rects{{0, 1, 1, 2, true}, {1, 1, 3, 2, true}, {3, 1, 6, 2, true}, {6, 1, 10, 2, true}},
36+
},
37+
}
38+
39+
for _, test := range tests {
40+
t.Run(test.name, func(t *testing.T) {
41+
got := test.original.split(test.weights...)
42+
if diff := cmp.Diff(test.parts, got, cmp.AllowUnexported(rect{}), cmpopts.EquateApprox(0.0001, 0.0001)); diff != "" {
43+
t.Errorf("rect.split(%v) returned diff:\n%s", test.weights, diff)
44+
}
45+
})
46+
}
47+
}
48+
49+
func TestRectToSVG(t *testing.T) {
50+
tests := []struct {
51+
name string
52+
rect rect
53+
opts []svgOpt
54+
want string
55+
}{
56+
{
57+
name: "default",
58+
rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
59+
want: `<g><rect x="1" y="2" width="2" height="2" fill="black" fill-opacity="1" stroke="black"></rect></g>`,
60+
}, {
61+
name: "transposed",
62+
rect: rect{left: 2, top: 1, right: 4, bottom: 3, transposed: true},
63+
want: `<g><rect x="1" y="2" width="2" height="2" fill="black" fill-opacity="1" stroke="black"></rect></g>`,
64+
}, {
65+
name: "red stroke",
66+
rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
67+
opts: []svgOpt{WithStroke("#F00", 0.5)},
68+
want: `<g><rect x="1" y="2" width="2" height="2" fill="black" fill-opacity="1" stroke="#F00" stroke-width="0.5"></rect></g>`,
69+
}, {
70+
name: "red fill",
71+
rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
72+
opts: []svgOpt{WithFill("#F00", 0.5)},
73+
want: `<g><rect x="1" y="2" width="2" height="2" fill="#F00" fill-opacity="0.5" stroke="black"></rect></g>`,
74+
}, {
75+
name: "with text",
76+
rect: rect{left: 10, top: 10, right: 80, bottom: 40, transposed: false},
77+
opts: []svgOpt{WithText("Hello, world!")},
78+
want: `<g><rect x="10" y="10" width="70" height="30" fill="black" fill-opacity="1" stroke="black"></rect>` +
79+
`<text x="45" y="25" font-size="8.08" text-anchor="middle" dominant-baseline="middle">Hello, world!</text></g>`,
80+
}, {
81+
name: "with text vertical",
82+
rect: rect{left: 10, top: 10, right: 40, bottom: 80, transposed: false},
83+
opts: []svgOpt{WithText("Hello, world!")},
84+
want: `<g><rect x="10" y="10" width="30" height="70" fill="black" fill-opacity="1" stroke="black"></rect>` +
85+
`<text x="25" y="45" font-size="8.08" transform="rotate(270 25.00 45.00)" text-anchor="middle" dominant-baseline="middle">Hello, world!</text></g>`,
86+
}, {
87+
name: "with text too small",
88+
rect: rect{left: 1, top: 1, right: 8, bottom: 4, transposed: false},
89+
opts: []svgOpt{WithText("Hello, world!")},
90+
want: `<g><rect x="1" y="1" width="7" height="3" fill="black" fill-opacity="1" stroke="black"></rect></g>`,
91+
}, {
92+
name: "with tooltip",
93+
rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
94+
opts: []svgOpt{WithTooltip("Hello, world!")},
95+
want: `<g><rect x="1" y="2" width="2" height="2" fill="black" fill-opacity="1" stroke="black"><title>Hello, world!</title></rect></g>`,
96+
},
97+
}
98+
99+
for _, test := range tests {
100+
t.Run(test.name, func(t *testing.T) {
101+
b := &bytes.Buffer{}
102+
if err := test.rect.toSVG(b, test.opts...); err != nil {
103+
t.Fatalf("rect.toSVG() returned error: %s", err)
104+
}
105+
got := strings.TrimSpace(b.String())
106+
if diff := cmp.Diff(test.want, got); diff != "" {
107+
t.Errorf("rect.toSVG() returned diff (-want,+got):\n%s", diff)
108+
}
109+
})
110+
}
111+
}

0 commit comments

Comments
 (0)