|
| 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 } |
0 commit comments