99/**
1010 * Exports churn data as an SVG treemap.
1111 *
12- * The size of the rectangles in the treemap is scaled proportionally to the "churn" value of each class. The
13- * layoutTreemap method calculates the dimensions of each rectangle based on the relative churn values of the items,
14- * ensuring that the total area of the rectangles corresponds to the total churn.
15- *
16- * The algorithm uses a slice-and-dice approach to divide the space recursively, alternating between vertical
17- * and horizontal splits.
12+ * The size of the rectangles in the treemap is scaled proportionally to the "churn" value of each class.
13+ * Mathematical calculations are delegated to the TreemapMath class, which handles score normalization,
14+ * color mapping, and treemap layout calculations using a slice-and-dice algorithm.
1815 *
1916 * @SuppressWarnings("PHPMD.ShortVariable")
2017 */
@@ -23,8 +20,13 @@ class SvgTreemapExporter implements DataExporterInterface
2320 private const SVG_WIDTH = 1200 ;
2421 private const SVG_HEIGHT = 800 ;
2522 private const PADDING = 2 ;
26- private const COLOR_MIN = 0 ;
27- private const COLOR_MAX = 10 ;
23+
24+ private TreemapMath $ treemapMath ;
25+
26+ public function __construct ()
27+ {
28+ $ this ->treemapMath = new TreemapMath ();
29+ }
2830
2931 /**
3032 * @param array<string, array<string, mixed>> $classes
@@ -48,19 +50,17 @@ public function export(array $classes, string $filename): void
4850 */
4951 private function generateSvgTreemap (array $ classes ): string
5052 {
51- $ items = $ this ->prepareItems ($ classes );
53+ $ items = $ this ->treemapMath -> prepareItems ($ classes );
5254
53- [$ minScore , $ maxScore ] = $ this ->findScoreRange ($ items );
55+ [$ minScore , $ maxScore ] = $ this ->treemapMath -> findScoreRange ($ items );
5456
55- $ rects = [];
56- $ this ->layoutTreemap (
57+ $ rects = $ this ->treemapMath ->calculateTreemapLayout (
5758 items: $ items ,
5859 x: 0 ,
5960 y: 0 ,
6061 width: self ::SVG_WIDTH ,
6162 height: self ::SVG_HEIGHT ,
6263 vertical: true ,
63- rects: $ rects ,
6464 padding: self ::PADDING
6565 );
6666
@@ -69,54 +69,6 @@ private function generateSvgTreemap(array $classes): string
6969 return $ this ->wrapSvg (rectsSvg: $ svgRects );
7070 }
7171
72- /**
73- * Prepares and filters items for the treemap.
74- *
75- * @param array<string, array<string, mixed>> $classes
76- * @return array<int, array{class: string, churn: float, score: float}>
77- */
78- private function prepareItems (array $ classes ): array
79- {
80- $ items = [];
81- foreach ($ classes as $ class => $ data ) {
82- $ churn = (float )($ data ['churn ' ] ?? 0 );
83- $ score = (float )($ data ['score ' ] ?? 0 );
84- if ($ churn > 0 ) {
85- $ items [] = [
86- 'class ' => $ class ,
87- 'churn ' => $ churn ,
88- 'score ' => $ score ,
89- ];
90- }
91- }
92- usort ($ items , fn ($ a , $ b ) => $ b ['churn ' ] <=> $ a ['churn ' ]);
93-
94- return $ items ;
95- }
96-
97- /**
98- * Finds the minimum and maximum score for normalization.
99- *
100- * @param array<int, array{class: string, churn: float, score: float}> $items
101- * @return array{float, float}
102- */
103- private function findScoreRange (array $ items ): array
104- {
105- $ scores = array_column ($ items , 'score ' );
106- if (empty ($ scores )) {
107- return [self ::COLOR_MIN , self ::COLOR_MAX ];
108- }
109-
110- $ minScore = min ($ scores );
111- $ maxScore = max ($ scores );
112-
113- if ($ minScore === $ maxScore ) {
114- return [self ::COLOR_MIN , self ::COLOR_MAX ];
115- }
116-
117- return [$ minScore , $ maxScore ];
118- }
119-
12072 /**
12173 * Renders SVG rectangles for the treemap.
12274 *
@@ -129,7 +81,7 @@ private function renderSvgRects(array $rects, float $minScore, float $maxScore):
12981 {
13082 $ svgRects = [];
13183 foreach ($ rects as $ rect ) {
132- $ normalizedScore = $ this ->normalizeScore (score: $ rect ['score ' ], minScore: $ minScore , maxScore: $ maxScore );
84+ $ normalizedScore = $ this ->treemapMath -> normalizeScore (score: $ rect ['score ' ], minScore: $ minScore , maxScore: $ maxScore );
13385 $ svgRects [] = $ this ->renderSvgRect (rect: $ rect , normalizedScore: $ normalizedScore );
13486 }
13587
@@ -149,7 +101,7 @@ private function renderSvgRect(array $rect, float $normalizedScore): string
149101 $ y = $ rect ['y ' ] + self ::PADDING ;
150102 $ width = max (0 , $ rect ['width ' ] - self ::PADDING * 2 );
151103 $ height = max (0 , $ rect ['height ' ] - self ::PADDING * 2 );
152- $ color = $ this ->scoreToColor (score: $ normalizedScore );
104+ $ color = $ this ->treemapMath -> scoreToColor (score: $ normalizedScore );
153105 $ class = htmlspecialchars ($ rect ['class ' ]);
154106 $ churn = $ rect ['churn ' ];
155107 $ score = $ rect ['score ' ];
@@ -200,161 +152,4 @@ private function wrapSvg(string $rectsSvg): string
200152</svg>
201153SVG ;
202154 }
203-
204- /**
205- * Normalizes a score to a 0-10 range for color mapping.
206- *
207- * @param float $score
208- * @param float $minScore
209- * @param float $maxScore
210- * @return float
211- */
212- private function normalizeScore (float $ score , float $ minScore , float $ maxScore ): float
213- {
214- if ($ maxScore > $ minScore ) {
215- return self ::COLOR_MAX * ($ score - $ minScore ) / ($ maxScore - $ minScore );
216- }
217-
218- return 0.0 ;
219- }
220-
221- /**
222- * Recursively layout rectangles for a slice-and-dice treemap.
223- *
224- * @param array<int, array{class: string, churn: float, score: float}> $items
225- * @param float $x
226- * @param float $y
227- * @param float $width
228- * @param float $height
229- * @param bool $vertical
230- * @param array<int, array<string, mixed>> $rects
231- * @param int $padding
232- * @return void
233- */
234- private function layoutTreemap (
235- array $ items ,
236- float $ x ,
237- float $ y ,
238- float $ width ,
239- float $ height ,
240- bool $ vertical ,
241- array &$ rects ,
242- int $ padding
243- ): void {
244- if (empty ($ items )) {
245- return ;
246- }
247-
248- if (count ($ items ) === 1 ) {
249- $ item = $ items [0 ];
250- $ rects [] = [
251- 'x ' => $ x ,
252- 'y ' => $ y ,
253- 'width ' => $ width ,
254- 'height ' => $ height ,
255- 'class ' => $ item ['class ' ],
256- 'churn ' => $ item ['churn ' ],
257- 'score ' => $ item ['score ' ],
258- ];
259-
260- return ;
261- }
262-
263- $ sum = array_sum (array_column ($ items , 'churn ' ));
264- $ splitIdx = $ this ->findSplitIndex (
265- items: $ items ,
266- sum: $ sum
267- );
268-
269- $ first = array_slice ($ items , 0 , $ splitIdx );
270- $ second = array_slice ($ items , $ splitIdx );
271-
272- $ firstSum = array_sum (array_column ($ first , 'churn ' ));
273-
274- if ($ vertical ) {
275- $ w1 = $ width * ($ firstSum / $ sum );
276- $ w2 = $ width - $ w1 ;
277- $ this ->layoutTreemap (
278- items: $ first ,
279- x: $ x ,
280- y: $ y ,
281- width: $ w1 ,
282- height: $ height ,
283- vertical: false ,
284- rects: $ rects ,
285- padding: $ padding
286- );
287- $ this ->layoutTreemap (
288- items: $ second ,
289- x: $ x + $ w1 ,
290- y: $ y ,
291- width: $ w2 ,
292- height: $ height ,
293- vertical: false ,
294- rects: $ rects ,
295- padding: $ padding
296- );
297- return ;
298- }
299-
300- $ h1 = $ height * ($ firstSum / $ sum );
301- $ h2 = $ height - $ h1 ;
302- $ this ->layoutTreemap (
303- items: $ first ,
304- x: $ x ,
305- y: $ y ,
306- width: $ width ,
307- height: $ h1 ,
308- vertical: true ,
309- rects: $ rects ,
310- padding: $ padding
311- );
312- $ this ->layoutTreemap (
313- items: $ second ,
314- x: $ x ,
315- y: $ y + $ h1 ,
316- width: $ width ,
317- height: $ h2 ,
318- vertical: true ,
319- rects: $ rects ,
320- padding: $ padding
321- );
322- }
323-
324- /**
325- * Finds the index to split the items for the treemap layout.
326- *
327- * @param array<int, array{class: string, churn: float, score: float}> $items
328- * @param float $sum
329- * @return int
330- */
331- private function findSplitIndex (array $ items , float $ sum ): int
332- {
333- $ accum = 0 ;
334- foreach ($ items as $ i => $ item ) {
335- $ accum += $ item ['churn ' ];
336- if ($ accum >= $ sum / 2 ) {
337- $ splitIdx = $ i + 1 ;
338- return ($ splitIdx <= 0 || $ splitIdx >= count ($ items )) ? 1 : $ splitIdx ;
339- }
340- }
341-
342- return 1 ;
343- }
344-
345- /**
346- * Maps a score (0-10) to a color from green to red.
347- *
348- * @param float $score
349- * @return string
350- */
351- private function scoreToColor (float $ score ): string
352- {
353- $ score = max (self ::COLOR_MIN , min (self ::COLOR_MAX , $ score ));
354- $ r = (int )(255 * ($ score / self ::COLOR_MAX ));
355- $ g = (int )(180 * (1 - $ score / self ::COLOR_MAX ));
356- $ b = 80 ;
357-
358- return sprintf ('rgb(%d,%d,%d) ' , $ r , $ g , $ b );
359- }
360155}
0 commit comments