diff --git a/build/analysis/rect.go b/build/analysis/rect.go
new file mode 100644
index 00000000..3d4a90eb
--- /dev/null
+++ b/build/analysis/rect.go
@@ -0,0 +1,283 @@
+package analysis
+
+import (
+ "encoding/xml"
+ "fmt"
+ "io"
+ "math"
+)
+
+// rect represents a single rectangle area on the SVG rendering of the TreeMap diagram.
+type rect struct {
+ left float64
+ top float64
+ right float64
+ bottom float64
+ transposed bool
+}
+
+func newRect(w, h float64) rect { return rect{0, 0, w, h, false} }
+
+func (r rect) width() float64 { return r.right - r.left }
+
+func (r rect) height() float64 { return r.bottom - r.top }
+
+// long returns the length of the longer side of the rectangle.
+func (r rect) long() float64 {
+ w, h := r.width(), r.height()
+ if w > h {
+ return w
+ }
+ return h
+}
+
+// short returns the length of the shorter side of the rectangle.
+func (r rect) short() float64 {
+ w, h := r.width(), r.height()
+ if w < h {
+ return w
+ }
+ return h
+}
+
+// padding between the current rect's bounds and nested rectangles representing
+// child nodes in the TreeMap.
+func (r rect) padding() float64 {
+ const factor = 0.025
+ padding := r.short() * factor
+ if padding > 10 {
+ padding = 10
+ }
+ return padding
+}
+
+// shrink the rectangle bounds by the given margin on every side.
+//
+// The center of the shrinked rectangle will be the same. Padding is
+// automatically reduced to prevent shrinking to zero if necessary.
+func (r rect) shrink(padding float64) rect {
+ if padding*2 > r.short()/2 {
+ padding = r.short() / 4
+ }
+ shrinked := rect{
+ left: r.left + padding,
+ top: r.top + padding,
+ right: r.right - padding,
+ bottom: r.bottom - padding,
+ transposed: r.transposed,
+ }
+ return shrinked
+}
+
+// aspectRatio returns width divided by height.
+func (r rect) aspectRatio() float64 { return r.width() / r.height() }
+
+// transpose flips X and Y coordinates of the rectangle and sets the
+// "transposed" flag appropriately.
+func (r rect) transpose() rect {
+ return rect{
+ left: r.top,
+ top: r.left,
+ right: r.bottom,
+ bottom: r.right,
+ transposed: !r.transposed,
+ }
+}
+
+// orientHorizontally transposes the rectangle such that the horizontal size is
+// always the longer.
+//
+// When subdividing a rectangle in the diagram we always lay down stacks of
+// children along the longer side for better readability, always orienting the
+// rectangle horizontally prevents a lot of branching in the layout code.
+// Calling restoreOrientation() will place the rectangle in the corrent place
+// regardless of how many times it or its parents were transposed.
+func (r rect) orientHorizontally() rect {
+ if r.aspectRatio() < 1 {
+ return r.transpose()
+ }
+ return r
+}
+
+// restoreOrientation transposes the rectangle if necessary to return it to the
+// original coordinate system.
+func (r rect) restoreOrientation() rect {
+ if r.transposed {
+ return r.transpose()
+ }
+ return r
+}
+
+// split the rectangle along the horizontal axis in protortion to the weights.
+// transpose() the rectangle first in order to split along the vertical axis.
+func (r rect) split(weights ...float64) rects {
+ var total float64
+ for _, w := range weights {
+ total += w
+ }
+
+ parts := []rect{}
+ var processedFraction float64
+ for _, w := range weights {
+ fraction := w / total
+ parts = append(parts, rect{
+ left: r.left + r.width()*processedFraction,
+ top: r.top,
+ right: r.left + r.width()*(processedFraction+fraction),
+ bottom: r.bottom,
+ transposed: r.transposed,
+ })
+ processedFraction += fraction
+ }
+
+ return parts
+}
+
+// toSVG writes the SVG code to represent the current rect given the display options.
+//
+// Coordinates and sizes are rounded to 2 decimal digits to reduce chances of
+// tests flaking out due to floating point imprecision. This will have no
+// visible impact on the rendering since 0.01 unit == 0.01 pixel by default.
+func (r rect) toSVG(w io.Writer, opts ...svgOpt) error {
+ normal := r.restoreOrientation() // Make sure we render the rect in its actual position.
+
+ // Populate the default SVG representation of the rect for its coordinates.
+ data := svgGroup{
+ Rect: &svgRect{
+ X: round2(normal.left),
+ Y: round2(normal.top),
+ Width: round2(normal.width()),
+ Height: round2(normal.height()),
+ Fill: "black",
+ FillOpacity: 1,
+ Stroke: "black",
+ StrokeWidth: 0,
+ },
+ Text: &svgText{
+ X: round2(normal.left + normal.width()/2),
+ Y: round2(normal.top + normal.height()/2),
+ TextAnchor: "middle",
+ Baseline: "middle",
+ FontSize: 20,
+ },
+ }
+
+ // Apply all the display options passed by the caller.
+ for _, o := range opts {
+ o(&data)
+ }
+
+ // Adjust rect label display such that it is readable and fits rectangle
+ // bounds. The constants below are empirically determined.
+ const (
+ fontFactorX = 1.5
+ fontFactorY = 0.8
+ minFont = 8
+ )
+ if data.Text.FontSize > normal.short()*fontFactorY {
+ data.Text.FontSize = normal.short() * fontFactorY
+ }
+ if l := float64(len(data.Text.Text)); l*data.Text.FontSize > normal.long()*fontFactorX {
+ data.Text.FontSize = normal.long() / l * fontFactorX
+ }
+ if data.Text.FontSize < minFont {
+ data.Text.Text = ""
+ }
+ data.Text.FontSize = round2(data.Text.FontSize)
+ if normal.aspectRatio() < 1 {
+ data.Text.Transform = fmt.Sprintf("rotate(270 %0.2f %0.2f)", data.Text.X, data.Text.Y)
+ }
+
+ if data.Text.Text == "" {
+ // Remove the text element if there's nothing to show.
+ data.Text = nil
+ }
+ if data.Rect.FillOpacity == 0 && data.Rect.StrokeWidth == 0 {
+ // Remove the rect element if it isn't supposed to be visible.
+ data.Rect = nil
+ }
+
+ defer w.Write([]byte("\n"))
+ return xml.NewEncoder(w).Encode(data)
+}
+
+type svgGroup struct {
+ XMLName struct{} `xml:"g"`
+ Rect *svgRect
+ Text *svgText `xml:",omitempty"`
+}
+
+type svgRect struct {
+ XMLName struct{} `xml:"rect"`
+ X float64 `xml:"x,attr"`
+ Y float64 `xml:"y,attr"`
+ Width float64 `xml:"width,attr"`
+ Height float64 `xml:"height,attr"`
+ Fill string `xml:"fill,attr,omitempty"`
+ FillOpacity float64 `xml:"fill-opacity,attr"`
+ Stroke string `xml:"stroke,attr,omitempty"`
+ StrokeWidth float64 `xml:"stroke-width,attr,omitempty"`
+ Title string `xml:"title,omitempty"`
+}
+
+type svgText struct {
+ XMLName struct{} `xml:"text"`
+ Text string `xml:",chardata"`
+ X float64 `xml:"x,attr"`
+ Y float64 `xml:"y,attr"`
+ FontSize float64 `xml:"font-size,attr"`
+ Transform string `xml:"transform,attr,omitempty"`
+ TextAnchor string `xml:"text-anchor,attr,omitempty"`
+ Baseline string `xml:"dominant-baseline,attr,omitempty"`
+}
+
+type svgOpt func(r *svgGroup)
+
+// WithTooltip adds a hover tooltip text to the rectangle.
+func WithTooltip(t string) svgOpt {
+ return func(g *svgGroup) { g.Rect.Title = t }
+}
+
+// WithText adds a text label over the rectangle.
+func WithText(t string) svgOpt {
+ return func(g *svgGroup) { g.Text.Text = t }
+}
+
+// WithFill sets rectangle fill style.
+func WithFill(color string, opacity float64) svgOpt {
+ return func(g *svgGroup) {
+ g.Rect.Fill = color
+ g.Rect.FillOpacity = opacity
+ }
+}
+
+// WithStroke sets rectangle outline stroke style.
+func WithStroke(color string, width float64) svgOpt {
+ return func(g *svgGroup) {
+ g.Rect.Stroke = color
+ g.Rect.StrokeWidth = width
+ }
+}
+
+// rects is a group of rectangles representing sibling nodes in the tree.
+type rects []rect
+
+// maxAspect returns the highest aspect ratio amount the rect group.
+//
+// Aspect ratios lesser than 1 are inverted to be above 1. The closer the return
+// value is to 1, the closer all rectangles in the group are to squares.
+func (rr rects) maxAspect() float64 {
+ var result float64 = 1 // Start as if we have a perfectly square layout.
+ for _, r := range rr {
+ aspect := r.aspectRatio()
+ if aspect < 1 {
+ aspect = 1 / aspect
+ }
+ if aspect > result {
+ result = aspect
+ }
+ }
+ return result
+}
+
+func round2(v float64) float64 { return math.Round(v*100) / 100 }
diff --git a/build/analysis/rect_test.go b/build/analysis/rect_test.go
new file mode 100644
index 00000000..5a5aa36d
--- /dev/null
+++ b/build/analysis/rect_test.go
@@ -0,0 +1,111 @@
+package analysis
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+)
+
+func TestRectSplit(t *testing.T) {
+ tests := []struct {
+ name string
+ original rect
+ weights []float64
+ parts rects
+ }{
+ {
+ name: "single part",
+ original: rect{0, 1, 10, 2, true},
+ weights: []float64{42},
+ parts: rects{{0, 1, 10, 2, true}},
+ },
+ {
+ name: "two parts",
+ original: rect{0, 1, 10, 2, true},
+ weights: []float64{4, 6},
+ parts: rects{{0, 1, 4, 2, true}, {4, 1, 10, 2, true}},
+ },
+ {
+ name: "many parts",
+ original: rect{0, 1, 10, 2, true},
+ weights: []float64{2, 4, 6, 8},
+ parts: rects{{0, 1, 1, 2, true}, {1, 1, 3, 2, true}, {3, 1, 6, 2, true}, {6, 1, 10, 2, true}},
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got := test.original.split(test.weights...)
+ if diff := cmp.Diff(test.parts, got, cmp.AllowUnexported(rect{}), cmpopts.EquateApprox(0.0001, 0.0001)); diff != "" {
+ t.Errorf("rect.split(%v) returned diff:\n%s", test.weights, diff)
+ }
+ })
+ }
+}
+
+func TestRectToSVG(t *testing.T) {
+ tests := []struct {
+ name string
+ rect rect
+ opts []svgOpt
+ want string
+ }{
+ {
+ name: "default",
+ rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
+ want: ``,
+ }, {
+ name: "transposed",
+ rect: rect{left: 2, top: 1, right: 4, bottom: 3, transposed: true},
+ want: ``,
+ }, {
+ name: "red stroke",
+ rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
+ opts: []svgOpt{WithStroke("#F00", 0.5)},
+ want: ``,
+ }, {
+ name: "red fill",
+ rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
+ opts: []svgOpt{WithFill("#F00", 0.5)},
+ want: ``,
+ }, {
+ name: "with text",
+ rect: rect{left: 10, top: 10, right: 80, bottom: 40, transposed: false},
+ opts: []svgOpt{WithText("Hello, world!")},
+ want: `` +
+ `Hello, world!`,
+ }, {
+ name: "with text vertical",
+ rect: rect{left: 10, top: 10, right: 40, bottom: 80, transposed: false},
+ opts: []svgOpt{WithText("Hello, world!")},
+ want: `` +
+ `Hello, world!`,
+ }, {
+ name: "with text too small",
+ rect: rect{left: 1, top: 1, right: 8, bottom: 4, transposed: false},
+ opts: []svgOpt{WithText("Hello, world!")},
+ want: ``,
+ }, {
+ name: "with tooltip",
+ rect: rect{left: 1, top: 2, right: 3, bottom: 4, transposed: false},
+ opts: []svgOpt{WithTooltip("Hello, world!")},
+ want: `Hello, world!`,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ b := &bytes.Buffer{}
+ if err := test.rect.toSVG(b, test.opts...); err != nil {
+ t.Fatalf("rect.toSVG() returned error: %s", err)
+ }
+ got := strings.TrimSpace(b.String())
+ if diff := cmp.Diff(test.want, got); diff != "" {
+ t.Errorf("rect.toSVG() returned diff (-want,+got):\n%s", diff)
+ }
+ })
+ }
+}
diff --git a/build/analysis/tree.go b/build/analysis/tree.go
new file mode 100644
index 00000000..d9465ce7
--- /dev/null
+++ b/build/analysis/tree.go
@@ -0,0 +1,245 @@
+package analysis
+
+import (
+ "crypto/md5"
+ "fmt"
+ "image/color"
+ "io"
+ "math"
+ "math/bits"
+ "sort"
+ "strings"
+
+ "github.com/dustin/go-humanize"
+)
+
+// treeMapNode represents a single node in a Tree Map diagram.
+//
+// Each node has a label and own size associated with it, as well as a list of
+// direct descendants.
+//
+// Even though this type can be used for building Tree Map diagrams of anything
+// (e.g. file system) it has extra features to handle dependency graph use case.
+// Node's children must form a directed acyclic graph, not necessarily a tree.
+// It will be later transformed into a tree by a organizeTree() call.
+type treeMapNode struct {
+ Label string
+ Size float64
+ Children nodeGroup
+
+ // The following fields are populated from the tree structure by organizeTree()
+ depth int // Shortest distance between the node and the tree root.
+ parent *treeMapNode // Selected tree parent for the current node.
+ effectiveChildren nodeGroup // Nodes for which this node was selected as a parent sorted by cumulative size.
+ cumulativeSize float64
+}
+
+// organizeTree transforms the acyclic import graph into a spanning tree.
+//
+// In order to visualize import graph and package sizes as a Tree Map diagram we
+// need to reduce it to a tree to make sure we don't double-count size
+// contribution of transitive dependencies even if they are imported by multiple
+// other packages. We solve this by selecting a single parent for every node of
+// the import graph and rendering the node only under the selected parent.
+// Empirically, the diagram is most readable if we selected the parent with the
+// shortest distance from the tree root (main package).
+func (root *treeMapNode) organizeTree() *treeMapNode {
+ // Mark all nodes as initially not visited.
+ var prepare func(node *treeMapNode)
+ prepare = func(node *treeMapNode) {
+ node.depth = 1<<(bits.UintSize-1) - 1 // Max int
+ for _, c := range node.Children {
+ prepare(c)
+ }
+ }
+ prepare(root)
+ root.depth = 0
+
+ // Use Dijkstra's algorithm to build a spanning tree.
+ queue := []*treeMapNode{root}
+ processNode := func(node *treeMapNode) {
+ // Sort children by name to ensure deterministic result.
+ sort.Slice(node.Children, func(i, j int) bool {
+ return node.Children[i].Label < node.Children[j].Label
+ })
+ // Select provisional parents for all children.
+ for _, child := range node.Children {
+ if child.depth > node.depth+1 {
+ child.depth = node.depth + 1
+ child.parent = node
+ }
+ queue = append(queue, child)
+ }
+ }
+ for len(queue) > 0 {
+ processNode(queue[0])
+ queue = queue[1:]
+ }
+
+ // Precompute some numbers we'll been to build the diagram.
+ var precompute func(node *treeMapNode)
+ precompute = func(node *treeMapNode) {
+ node.cumulativeSize = node.Size
+ for _, child := range node.Children {
+ if child.parent == node {
+ precompute(child) // Compute all the metrics for the child first.
+ node.effectiveChildren = append(node.effectiveChildren, child)
+ node.cumulativeSize += child.cumulativeSize
+ }
+ }
+ // Sort effective children by size descending, this tends to produce better-looking diagrams.
+ sort.Slice(node.effectiveChildren, func(i, j int) bool {
+ if node.effectiveChildren[i].cumulativeSize != node.effectiveChildren[j].cumulativeSize {
+ return node.effectiveChildren[i].cumulativeSize > node.effectiveChildren[j].cumulativeSize
+ }
+ return node.effectiveChildren[i].Label < node.effectiveChildren[j].Label // To guarantee determinism.
+ })
+ }
+ precompute(root)
+ return root
+}
+
+// color assigns a deterministic pseudo-random color to the node. Colors are in
+// light pastel gamma to provide good readable contrast for text labels.
+func (n *treeMapNode) color() string {
+ hash := md5.Sum([]byte(n.Label))
+ cb := hash[0]
+ cr := hash[1]
+ y := uint8(256 * (0.25 + math.Pow(0.9, float64(n.depth))/2))
+ r, g, b := color.YCbCrToRGB(y, cb, cr)
+ return fmt.Sprintf("#%02x%02x%02x", r, g, b)
+}
+
+// renderSelf renders a labeled rectangle for the node itself within the given
+// bounding area and returns a sub-area where children nodes should be rendered.
+// Bounding area is split between self and children based on the relative
+// weights.
+func (n *treeMapNode) renderSelf(w io.Writer, bounds rect) (rect, error) {
+ // Output own bounding rect into the SVG stream.
+ if err := bounds.toSVG(w, WithTooltip(n.Describe()), WithFill(n.color(), 0.1), WithStroke("black", 0.5)); err != nil {
+ return rect{}, fmt.Errorf("failed to render self to svg: %s", err)
+ }
+
+ // Create a bit of a border for better visualization of the tree hierarchy.
+ bounds = bounds.shrink(bounds.padding())
+
+ // Reserve some space to represent own size.
+ ownSplit := bounds.split(n.Size, n.cumulativeSize-n.Size)
+ ownSplit[0].toSVG(w, WithFill("none", 0), WithText(fmt.Sprintf("%s — %s", n.Label, humanize.IBytes(uint64(n.Size)))))
+
+ // The rest will be allocated to children.
+ bounds = ownSplit[1]
+ return bounds, nil
+}
+
+// toSVG renders Tree Map diagram for the node and its children in the given bounding area.
+//
+// This function implements a simplified version of the Squarified Treemaps [1]
+// algorithm to avoid generating a lot of thin and long nodes, which are
+// difficult to read and label.
+//
+// Note that organizeTree() needs to be called prior to this method to ensure
+// every element in the graph is rendered exactly once.
+//
+// [1]: Bruls, Mark, Kees Huizing, and Jarke J. Van Wijk. "Squarified treemaps."
+// Data visualization 2000. Springer, Vienna, 2000. 33-42.
+func (n *treeMapNode) toSVG(w io.Writer, bounds rect) error {
+ // Reserve a fraction of the bounding area to represent node's own weight.
+ bounds, err := n.renderSelf(w, bounds)
+ if err != nil {
+ return fmt.Errorf("failed to render self: %s", err)
+ }
+
+ if len(n.effectiveChildren) == 0 {
+ return nil
+ }
+
+ // Renders a stack of child nodes within the corresponding bounding areas.
+ renderStack := func(stack nodeGroup, bounds rects) error {
+ if len(stack) != len(bounds) {
+ // This should never happen ™.
+ panic(fmt.Sprintf("Tried to lay out node stack %v inside %d bounding rects, need %d", stack, len(bounds), len(stack)))
+ }
+ for i, node := range stack {
+ node.toSVG(w, bounds[i])
+ }
+ return nil
+ }
+
+ bounds = bounds.orientHorizontally()
+ stack := nodeGroup{n.effectiveChildren[0]} // Seed the first stack of children.
+ queue := n.effectiveChildren[1:] // Queue the remaining children.
+ split := bounds.split(stack.totalSize(), queue.totalSize()) // Split the available space between the stack and the remainder.
+ stackBounds := split[0].split(stack.cumulativeSizes()...) // Subdivide stack space for each stack element.
+
+ // Attempt to arrange children into one or more "stacks" inside the bounding
+ // area such that each child's area is as square as possible. We do it
+ // greedily by adding children into stacks as long as it improves aspect
+ // ratios within the stack.
+ for len(queue) > 0 {
+ var candidate *treeMapNode
+ candidate, queue = queue[0], queue[1:]
+ newStack := append(stack, candidate)
+ newSplit := bounds.split(newStack.totalSize(), queue.totalSize())
+ newStackBounds := newSplit[0].orientHorizontally().split(newStack.cumulativeSizes()...)
+
+ if newStackBounds.maxAspect() > stackBounds.maxAspect() {
+ // The new stack made the layout less square, so we commit the the
+ // previous version of the stack and seed a new stack.
+ if err := renderStack(stack, stackBounds); err != nil {
+ return fmt.Errorf("failed to lay out children of %q: %s", n.Label, err)
+ }
+
+ bounds = split[1] // Reduce the available layout space to that's left.
+ stack = nodeGroup{candidate} // Seed the new stack.
+ split = bounds.split(stack.totalSize(), queue.totalSize())
+ stackBounds = split[0].split(stack.cumulativeSizes()...)
+ } else {
+ // The new stack looks better, accept it and keep adding to it.
+ stack = newStack
+ split = newSplit
+ stackBounds = newStackBounds
+ }
+ }
+
+ // Render the last stack as it is.
+ if err := renderStack(stack, stackBounds); err != nil {
+ return fmt.Errorf("failed to lay out children of %q: %s", n.Label, err)
+ }
+
+ return nil
+}
+
+// Describe the node itself.
+func (n *treeMapNode) Describe() string {
+ return fmt.Sprintf("%s — %s own size, %s with children", n.Label, humanize.IBytes(uint64(n.Size)), humanize.IBytes(uint64(n.cumulativeSize)))
+}
+
+// String representation of the node and its effective children.
+func (n *treeMapNode) String() string {
+ s := &strings.Builder{}
+ fmt.Fprintf(s, " %s %s\n", strings.Repeat(" |", n.depth), n.Describe())
+ for _, c := range n.effectiveChildren {
+ s.WriteString(c.String())
+ }
+ return s.String()
+}
+
+// nodeGroup represents a group of adjacent tree nodes.
+type nodeGroup []*treeMapNode
+
+func (ng nodeGroup) totalSize() float64 {
+ var size float64
+ for _, c := range ng {
+ size += c.cumulativeSize
+ }
+ return size
+}
+
+func (ng nodeGroup) cumulativeSizes() []float64 {
+ sizes := []float64{}
+ for _, c := range ng {
+ sizes = append(sizes, c.cumulativeSize)
+ }
+ return sizes
+}
diff --git a/build/analysis/tree_test.go b/build/analysis/tree_test.go
new file mode 100644
index 00000000..f9da11db
--- /dev/null
+++ b/build/analysis/tree_test.go
@@ -0,0 +1,72 @@
+package analysis
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestTreeNodeOrganize(t *testing.T) {
+ io := &treeMapNode{Label: "io", Size: 10 * 1024}
+ strconv := &treeMapNode{Label: "strconv", Size: 1 * 1024}
+ fmt := &treeMapNode{Label: "fmt", Size: 50 * 1024, Children: nodeGroup{io, strconv}}
+ main := &treeMapNode{Label: "main", Size: 1024 * 1024, Children: nodeGroup{io, fmt}}
+
+ main.organizeTree()
+
+ // Text representation of the spanning tree for the acyclic dependency graph.
+ // Note that "io" is assigned as effective child of "main", since this is a
+ // shorter path to the tree root.
+ want := "" +
+ " main — 1.0 MiB own size, 1.1 MiB with children\n" +
+ " | fmt — 50 KiB own size, 51 KiB with children\n" +
+ " | | strconv — 1.0 KiB own size, 1.0 KiB with children\n" +
+ " | io — 10 KiB own size, 10 KiB with children\n"
+ got := main.String()
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("main.organizeTree() produced unexpected structure (-want,+got):\n%s", diff)
+ }
+}
+
+func TestTreeNodeToSVG(t *testing.T) {
+ // Build a dependency graph for a very simple project.
+ cpu := &treeMapNode{Label: "internal/cpu", Size: 342}
+ bytealg := &treeMapNode{Label: "internal/bytealg", Size: 468, Children: nodeGroup{cpu}}
+ sys := &treeMapNode{Label: "runtime/internal/sys", Size: 350}
+ js := &treeMapNode{Label: "gopherjs/js", Size: 5.2 * 1024}
+ runtime := &treeMapNode{Label: "runtime", Size: 3.8 * 1024, Children: nodeGroup{js, bytealg, sys}}
+ prelude := &treeMapNode{Label: "$prelude", Size: 32 * 1024}
+ main := &treeMapNode{Label: "main", Size: 434}
+ artifact := &treeMapNode{Label: "$artifact", Size: 0, Children: nodeGroup{prelude, runtime, main}}
+
+ artifact.organizeTree()
+ b := &bytes.Buffer{}
+ if err := artifact.toSVG(b, newRect(1000, 1000)); err != nil {
+ t.Fatalf("artifact.toSVG() returned error: %s", err)
+ }
+
+ want := `$artifact — 0 B own size, 43 KiB with children
+
+$prelude — 32 KiB own size, 32 KiB with children
+$prelude — 32 KiB
+runtime — 3.8 KiB own size, 10 KiB with children
+runtime — 3.8 KiB
+gopherjs/js — 5.2 KiB own size, 5.2 KiB with children
+gopherjs/js — 5.2 KiB
+internal/bytealg — 468 B own size, 810 B with children
+
+internal/cpu — 342 B own size, 342 B with children
+internal/cpu — 342 B
+runtime/internal/sys — 350 B own size, 350 B with children
+
+main — 434 B own size, 434 B with children
+
+`
+ got := b.String()
+ fmt.Println(got)
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("artifact.toSVG() returned diff (-want,+got):\n%s", diff)
+ }
+}
diff --git a/build/analysis/visualize.go b/build/analysis/visualize.go
new file mode 100644
index 00000000..3cb89316
--- /dev/null
+++ b/build/analysis/visualize.go
@@ -0,0 +1,80 @@
+package analysis
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/goplusjs/gopherjs/compiler"
+)
+
+// Visualizer renders a TreeMap diagram representing the generated JS file and
+// how different packages contributed it its size.
+type Visualizer struct {
+ File *os.File
+ Main *compiler.Archive
+ Deps []*compiler.Archive
+ PrintStats bool
+}
+
+// Render a Tree Map diagram for the given package sizes.
+//
+// Stats contains amount of bytes written that correspond to a given package.
+func (v *Visualizer) Render(stats map[string]int) error {
+ tree := v.buildTree(stats)
+ // This roughly corresponds to browser viewport dimensions on a 1080p screen.
+ const w = 1500
+ const h = 700
+ fmt.Fprintf(v.File, `
+
+\n")
+
+ if v.PrintStats {
+ fmt.Println(tree)
+ }
+ return nil
+}
+
+func (v *Visualizer) buildTree(stats map[string]int) *treeMapNode {
+ deps := map[string]*compiler.Archive{}
+
+ for _, dep := range v.Deps {
+ deps[dep.ImportPath] = dep
+ }
+
+ nodes := map[string]*treeMapNode{} // List of all nodes in the tree.
+ roots := map[string]*treeMapNode{} // Nodes that are not someone's dependency.
+ for name, size := range stats {
+ nodes[name] = &treeMapNode{
+ Label: name,
+ Size: float64(size),
+ }
+ roots[name] = nodes[name]
+ }
+
+ for name, node := range nodes {
+ if dep, ok := deps[name]; ok {
+ for _, depName := range dep.Imports {
+ node.Children = append(node.Children, nodes[depName])
+ delete(roots, depName)
+ }
+ }
+ }
+
+ topLevel := []*treeMapNode{}
+ for _, node := range roots {
+ topLevel = append(topLevel, node)
+ }
+
+ return (&treeMapNode{
+ Label: "$artifact",
+ Size: 0, // No own size, just children.
+ Children: topLevel,
+ depth: 0,
+ }).organizeTree()
+}
diff --git a/build/build.go b/build/build.go
index 112e1314..0757e135 100644
--- a/build/build.go
+++ b/build/build.go
@@ -22,6 +22,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
+ "github.com/goplusjs/gopherjs/build/analysis"
"github.com/goplusjs/gopherjs/compiler"
"github.com/goplusjs/gopherjs/compiler/gopherjspkg"
"github.com/goplusjs/gopherjs/compiler/natives"
@@ -480,6 +481,7 @@ type Options struct {
Quiet bool
Watch bool
CreateMapFile bool
+ AnalyzeSize bool
MapToLocalDisk bool
Minify bool
Color bool
@@ -956,6 +958,25 @@ func (s *Session) WriteCommandPackage(archive *compiler.Archive, pkgObj string)
if err != nil {
return err
}
+
+ if s.options.AnalyzeSize {
+ f, err := os.Create(pkgObj + ".svg")
+ if err != nil {
+ return fmt.Errorf("failed to create artifact size diagram file: %s", err)
+ }
+ defer f.Close()
+ visualizer := &analysis.Visualizer{
+ Main: archive,
+ Deps: deps,
+ File: f,
+ PrintStats: s.options.Verbose && !s.options.Quiet,
+ }
+ defer func() {
+ if err := visualizer.Render(sourceMapFilter.BytesWritten); err != nil {
+ fmt.Fprintf(os.Stderr, "Failed to render artifact size diagram: %s", err)
+ }
+ }()
+ }
return compiler.WriteProgramCode(deps, sourceMapFilter)
}
diff --git a/compiler/compiler.go b/compiler/compiler.go
index dc72bcd4..e4b59442 100644
--- a/compiler/compiler.go
+++ b/compiler/compiler.go
@@ -150,6 +150,9 @@ func WriteProgramCode(pkgs []*Archive, w *SourceMapFilter) error {
}
}
+ leaveSection := w.EnterSection("$prelude")
+ defer leaveSection()
+
if _, err := w.Write([]byte("\"use strict\";\n(function() {\n\n")); err != nil {
return err
}
@@ -179,6 +182,9 @@ func WriteProgramCode(pkgs []*Archive, w *SourceMapFilter) error {
}
func WritePkgCode(pkg *Archive, dceSelection map[*Decl]struct{}, minify bool, w *SourceMapFilter) error {
+ leaveSection := w.EnterSection(pkg.ImportPath)
+ defer leaveSection()
+
if w.MappingCallback != nil && pkg.FileSet != nil {
w.fileSet = token.NewFileSet()
if err := w.fileSet.Read(json.NewDecoder(bytes.NewReader(pkg.FileSet)).Decode); err != nil {
@@ -260,9 +266,30 @@ type SourceMapFilter struct {
line int
column int
fileSet *token.FileSet
+ // BytesWritten per program section.
+ //
+ // This information is used to produce artifact size report. The key is
+ // a Go package name or one of a special IDs: $prelude.
+ BytesWritten map[string]int
+ // The current program section being written. Empty string means "UNKNOWN".
+ currentSection string
}
+// Write program code and populate source map if available.
+//
+// The passed byte slice is written verbatim, except when it contains an ASCII
+// "backspace" character (\b, 0x08). In that case the 4 bytes following the
+// backspace character are interpreted as big-endian-encoded token.Pos that must
+// be present in the fileSet. The position is interpreted as the position in the
+// original source code for the JS code that is about to be written (?) and will
+// be passed to the MappingCallback in order to emit the appropriate source map.
+// The sequence of \b and the following 4 bytes are then omitted from the actual
+// output.
func (f *SourceMapFilter) Write(p []byte) (n int, err error) {
+ if f.BytesWritten == nil {
+ f.BytesWritten = map[string]int{}
+ }
+
var n2 int
for {
i := bytes.IndexByte(p, '\b')
@@ -273,6 +300,7 @@ func (f *SourceMapFilter) Write(p []byte) (n int, err error) {
n2, err = f.Writer.Write(w)
n += n2
+ f.BytesWritten[f.currentSection] += n2
for {
i := bytes.IndexByte(w, '\n')
if i == -1 {
@@ -294,3 +322,14 @@ func (f *SourceMapFilter) Write(p []byte) (n int, err error) {
n += 5
}
}
+
+// EnterSection marks a beginning of a new program section.
+//
+// All bytes written between entering and leaving a section will be attributed
+// to the specified section in the artifact size report. The returned callback
+// restores the previous section, allowing for sections to nest.
+func (f *SourceMapFilter) EnterSection(s string) func() {
+ previousSection := f.currentSection
+ f.currentSection = s
+ return func() { f.currentSection = previousSection }
+}
diff --git a/go.mod b/go.mod
index 2b93c685..4facd229 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,10 @@ module github.com/goplusjs/gopherjs
go 1.13
require (
+ github.com/dustin/go-humanize v1.0.0
github.com/fsnotify/fsnotify v1.4.7
- github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
+ github.com/google/go-cmp v0.5.4
+ github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00
github.com/kisielk/gotool v1.0.0
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab
diff --git a/go.sum b/go.sum
index b47ee860..808c57e9 100644
--- a/go.sum
+++ b/go.sum
@@ -8,11 +8,15 @@ github.com/cpuguy83/go-md2man v1.0.8/go.mod h1:N6JayAiVKtlHSnuTCeuLSQVs75hb8q+dY
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-delve/delve v1.3.2 h1:K8VjV+Q2YnBYlPq0ctjrvc9h7h03wXszlszzfGW5Tog=
github.com/go-delve/delve v1.3.2/go.mod h1:LLw6qJfIsRK9WcwV2IRRqsdlgrqzOeuGrQOCOIhDpt8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de h1:F7WD09S8QB4LrkEpka0dFPLSotH11HRpCsLIbIcJ7sU=
github.com/gopherjs/gopherjs v0.0.0-20191106031601-ce3c9ade29de/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0=
@@ -119,6 +123,8 @@ golang.org/x/tools v0.0.0-20200131143746-097c1f2eed26/go.mod h1:TB2adYChydJhpapK
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/tool.go b/tool.go
index f7fe29c9..f0ddf534 100644
--- a/tool.go
+++ b/tool.go
@@ -82,6 +82,7 @@ func main() {
compilerFlags.StringVar(&tags, "tags", "", "a list of build tags to consider satisfied during the build")
compilerFlags.BoolVar(&options.MapToLocalDisk, "localmap", false, "use local paths for sourcemap")
compilerFlags.BoolVarP(&options.Rebuild, "force", "a", false, "force rebuilding of packages that are already up-to-date")
+ compilerFlags.BoolVar(&options.AnalyzeSize, "analyze_size", false, "produce an Tree Map diagram visualizing each package's contribution to the output artifact size")
flagWatch := pflag.NewFlagSet("", 0)
flagWatch.BoolVarP(&options.Watch, "watch", "w", false, "watch for changes to the source files")