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, ` + + + +`, w, h, w, h) + if err := tree.toSVG(v.File, newRect(w, h)); err != nil { + return err + } + 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")