1111 */
1212class SvgTreemapExporter implements DataExporterInterface
1313{
14+ private const SVG_WIDTH = 1200 ;
15+ private const SVG_HEIGHT = 800 ;
16+ private const PADDING = 2 ;
17+ private const COLOR_MIN = 0 ;
18+ private const COLOR_MAX = 10 ;
19+
1420 /**
1521 * @param array<string, array<string, mixed>> $classes
1622 * @param string $filename
@@ -33,13 +39,38 @@ public function export(array $classes, string $filename): void
3339 */
3440 private function generateSvgTreemap (array $ classes ): string
3541 {
36- $ width = 1200 ;
37- $ height = 800 ;
38- $ padding = 2 ;
42+ $ items = $ this ->prepareItems ($ classes );
43+ $ totalChurn = $ this ->calculateTotalChurn ($ items );
44+
45+ [$ minScore , $ maxScore ] = $ this ->findScoreRange ($ items );
46+
47+ $ rects = [];
48+ $ this ->layoutTreemap (
49+ $ items ,
50+ 0 ,
51+ 0 ,
52+ self ::SVG_WIDTH ,
53+ self ::SVG_HEIGHT ,
54+ $ totalChurn ,
55+ true ,
56+ $ rects ,
57+ self ::PADDING
58+ );
59+
60+ $ svgRects = $ this ->renderSvgRects ($ rects , $ minScore , $ maxScore );
3961
40- // Prepare data: sort by churn descending, filter out zero churn
62+ return $ this ->wrapSvg ($ svgRects );
63+ }
64+
65+ /**
66+ * Prepares and filters items for the treemap.
67+ *
68+ * @param array<string, array<string, mixed>> $classes
69+ * @return array<int, array{class: string, churn: float, score: float}>
70+ */
71+ private function prepareItems (array $ classes ): array
72+ {
4173 $ items = [];
42- $ scores = [];
4374 foreach ($ classes as $ class => $ data ) {
4475 $ churn = (float )($ data ['churn ' ] ?? 0 );
4576 $ score = (float )($ data ['score ' ] ?? 0 );
@@ -49,52 +80,109 @@ private function generateSvgTreemap(array $classes): string
4980 'churn ' => $ churn ,
5081 'score ' => $ score ,
5182 ];
52- $ scores [] = $ score ;
5383 }
5484 }
5585 usort ($ items , fn ($ a , $ b ) => $ b ['churn ' ] <=> $ a ['churn ' ]);
86+ return $ items ;
87+ }
5688
89+ /**
90+ * Calculates the total churn value.
91+ *
92+ * @param array<int, array{class: string, churn: float, score: float}> $items
93+ * @return float
94+ */
95+ private function calculateTotalChurn (array $ items ): float
96+ {
5797 $ totalChurn = array_sum (array_column ($ items , 'churn ' ));
58- if ($ totalChurn <= 0 ) {
59- $ totalChurn = 1 ;
60- }
98+ return $ totalChurn > 0 ? $ totalChurn : 1.0 ;
99+ }
61100
62- // --- NEW: Find min/max score for normalization ---
63- $ minScore = !empty ($ scores ) ? min ($ scores ) : 0 ;
64- $ maxScore = !empty ($ scores ) ? max ($ scores ) : 10 ;
101+ /**
102+ * Finds the minimum and maximum score for normalization.
103+ *
104+ * @param array<int, array{class: string, churn: float, score: float}> $items
105+ * @return array{float, float}
106+ */
107+ private function findScoreRange (array $ items ): array
108+ {
109+ $ scores = array_column ($ items , 'score ' );
110+ if (empty ($ scores )) {
111+ return [self ::COLOR_MIN , self ::COLOR_MAX ];
112+ }
113+ $ minScore = min ($ scores );
114+ $ maxScore = max ($ scores );
65115 if ($ minScore === $ maxScore ) {
66- $ minScore = 0 ;
67- $ maxScore = 10 ;
116+ return [self ::COLOR_MIN , self ::COLOR_MAX ];
68117 }
118+ return [$ minScore , $ maxScore ];
119+ }
69120
70- // Recursively layout rectangles
71- $ rects = [];
72- $ this ->layoutTreemap ($ items , 0 , 0 , $ width , $ height , $ totalChurn , true , $ rects , $ padding );
73-
74- // Render SVG
121+ /**
122+ * Renders SVG rectangles for the treemap.
123+ *
124+ * @param array<int, array<string, mixed>> $rects
125+ * @param float $minScore
126+ * @param float $maxScore
127+ * @return string
128+ */
129+ private function renderSvgRects (array $ rects , float $ minScore , float $ maxScore ): string
130+ {
75131 $ svgRects = [];
76132 foreach ($ rects as $ rect ) {
77- // --- NEW: Normalize score for color ---
78- $ normalizedScore = ($ maxScore > $ minScore )
79- ? 10 * ($ rect ['score ' ] - $ minScore ) / ($ maxScore - $ minScore )
80- : 0 ;
81- $ svgRects [] = sprintf (
82- '<g><rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="#222" stroke-width="1"/><title>%s Churn: %s Score: %s</title><text x="%.2f" y="%.2f" font-size="13" fill="#000">%s</text></g> ' ,
83- $ rect ['x ' ] + $ padding ,
84- $ rect ['y ' ] + $ padding ,
85- max (0 , $ rect ['width ' ] - $ padding * 2 ),
86- max (0 , $ rect ['height ' ] - $ padding * 2 ),
87- $ this ->scoreToColor ($ normalizedScore ),
88- htmlspecialchars ($ rect ['class ' ]),
89- $ rect ['churn ' ],
90- $ rect ['score ' ],
91- $ rect ['x ' ] + $ padding + 4 ,
92- $ rect ['y ' ] + 18 ,
93- htmlspecialchars (mb_strimwidth ($ rect ['class ' ], 0 , 40 , '… ' ))
94- );
133+ $ normalizedScore = $ this ->normalizeScore ($ rect ['score ' ], $ minScore , $ maxScore );
134+ $ svgRects [] = $ this ->renderSvgRect ($ rect , $ normalizedScore );
95135 }
96- $ rectsSvg = implode ("\n" , $ svgRects );
136+ return implode ("\n" , $ svgRects );
137+ }
138+
139+ /**
140+ * Renders a single SVG rectangle.
141+ *
142+ * @param array<string, mixed> $rect
143+ * @param float $normalizedScore
144+ * @return string
145+ */
146+ private function renderSvgRect (array $ rect , float $ normalizedScore ): string
147+ {
148+ $ x = $ rect ['x ' ] + self ::PADDING ;
149+ $ y = $ rect ['y ' ] + self ::PADDING ;
150+ $ width = max (0 , $ rect ['width ' ] - self ::PADDING * 2 );
151+ $ height = max (0 , $ rect ['height ' ] - self ::PADDING * 2 );
152+ $ color = $ this ->scoreToColor ($ normalizedScore );
153+ $ class = htmlspecialchars ($ rect ['class ' ]);
154+ $ churn = $ rect ['churn ' ];
155+ $ score = $ rect ['score ' ];
156+ $ textX = $ x + 4 ;
157+ $ textY = $ y + 18 ;
158+ $ label = htmlspecialchars (mb_strimwidth ($ rect ['class ' ], 0 , 40 , '… ' ));
159+
160+ return sprintf (
161+ '<g><rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s" stroke="#222" stroke-width="1"/><title>%s Churn: %s Score: %s</title><text x="%.2f" y="%.2f" font-size="13" fill="#000">%s</text></g> ' ,
162+ $ x ,
163+ $ y ,
164+ $ width ,
165+ $ height ,
166+ $ color ,
167+ $ class ,
168+ $ churn ,
169+ $ score ,
170+ $ textX ,
171+ $ textY ,
172+ $ label
173+ );
174+ }
97175
176+ /**
177+ * Wraps SVG rectangles in the SVG document.
178+ *
179+ * @param string $rectsSvg
180+ * @return string
181+ */
182+ private function wrapSvg (string $ rectsSvg ): string
183+ {
184+ $ width = self ::SVG_WIDTH ;
185+ $ height = self ::SVG_HEIGHT ;
98186 return <<<SVG
99187<?xml version="1.0" encoding="UTF-8"?>
100188<svg width=" {$ width }" height=" {$ height }" xmlns="http://www.w3.org/2000/svg">
@@ -113,20 +201,47 @@ private function generateSvgTreemap(array $classes): string
113201SVG ;
114202 }
115203
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+ return 0.0 ;
218+ }
219+
116220 /**
117221 * Recursively layout rectangles for a slice-and-dice treemap.
118- * @param array $items
222+ *
223+ * @param array<int, array{class: string, churn: float, score: float}> $items
119224 * @param float $x
120225 * @param float $y
121226 * @param float $width
122227 * @param float $height
123228 * @param float $totalChurn
124229 * @param bool $vertical
125- * @param array $rects
230+ * @param array<int, array<string, mixed>> $rects
126231 * @param int $padding
232+ * @return void
127233 */
128- private function layoutTreemap (array $ items , float $ x , float $ y , float $ width , float $ height , float $ totalChurn , bool $ vertical , array &$ rects , int $ padding ): void
129- {
234+ private function layoutTreemap (
235+ array $ items ,
236+ float $ x ,
237+ float $ y ,
238+ float $ width ,
239+ float $ height ,
240+ float $ totalChurn ,
241+ bool $ vertical ,
242+ array &$ rects ,
243+ int $ padding
244+ ): void {
130245 if (empty ($ items )) {
131246 return ;
132247 }
@@ -142,23 +257,12 @@ private function layoutTreemap(array $items, float $x, float $y, float $width, f
142257 'churn ' => $ item ['churn ' ],
143258 'score ' => $ item ['score ' ],
144259 ];
260+
145261 return ;
146262 }
147263
148264 $ sum = array_sum (array_column ($ items , 'churn ' ));
149- $ accum = 0 ;
150- $ splitIdx = 0 ;
151- foreach ($ items as $ i => $ item ) {
152- $ accum += $ item ['churn ' ];
153- if ($ accum >= $ sum / 2 ) {
154- $ splitIdx = $ i + 1 ;
155- break ;
156- }
157- }
158-
159- if ($ splitIdx <= 0 || $ splitIdx >= count ($ items )) {
160- $ splitIdx = 1 ;
161- }
265+ $ splitIdx = $ this ->findSplitIndex ($ items , $ sum );
162266
163267 $ first = array_slice ($ items , 0 , $ splitIdx );
164268 $ second = array_slice ($ items , $ splitIdx );
@@ -169,24 +273,48 @@ private function layoutTreemap(array $items, float $x, float $y, float $width, f
169273 if ($ vertical ) {
170274 $ w1 = $ width * ($ firstSum / $ sum );
171275 $ w2 = $ width - $ w1 ;
172- $ this ->layoutTreemap ($ first , $ x , $ y , $ w1 , $ height , $ firstSum , ! $ vertical , $ rects , $ padding );
173- $ this ->layoutTreemap ($ second , $ x + $ w1 , $ y , $ w2 , $ height , $ secondSum , ! $ vertical , $ rects , $ padding );
276+ $ this ->layoutTreemap ($ first , $ x , $ y , $ w1 , $ height , $ firstSum , false , $ rects , $ padding );
277+ $ this ->layoutTreemap ($ second , $ x + $ w1 , $ y , $ w2 , $ height , $ secondSum , false , $ rects , $ padding );
174278 } else {
175279 $ h1 = $ height * ($ firstSum / $ sum );
176280 $ h2 = $ height - $ h1 ;
177- $ this ->layoutTreemap ($ first , $ x , $ y , $ width , $ h1 , $ firstSum , !$ vertical , $ rects , $ padding );
178- $ this ->layoutTreemap ($ second , $ x , $ y + $ h1 , $ width , $ h2 , $ secondSum , !$ vertical , $ rects , $ padding );
281+ $ this ->layoutTreemap ($ first , $ x , $ y , $ width , $ h1 , $ firstSum , true , $ rects , $ padding );
282+ $ this ->layoutTreemap ($ second , $ x , $ y + $ h1 , $ width , $ h2 , $ secondSum , true , $ rects , $ padding );
283+ }
284+ }
285+
286+ /**
287+ * Finds the index to split the items for the treemap layout.
288+ *
289+ * @param array<int, array{class: string, churn: float, score: float}> $items
290+ * @param float $sum
291+ * @return int
292+ */
293+ private function findSplitIndex (array $ items , float $ sum ): int
294+ {
295+ $ accum = 0 ;
296+ foreach ($ items as $ i => $ item ) {
297+ $ accum += $ item ['churn ' ];
298+ if ($ accum >= $ sum / 2 ) {
299+ $ splitIdx = $ i + 1 ;
300+ return ($ splitIdx <= 0 || $ splitIdx >= count ($ items )) ? 1 : $ splitIdx ;
301+ }
179302 }
303+
304+ return 1 ;
180305 }
181306
182307 /**
183308 * Maps a score (0-10) to a color from green to red.
309+ *
310+ * @param float $score
311+ * @return string
184312 */
185313 private function scoreToColor (float $ score ): string
186314 {
187- $ score = max (0 , min (10 , $ score ));
188- $ r = (int )(255 * ($ score / 10 ));
189- $ g = (int )(180 * (1 - $ score / 10 ));
315+ $ score = max (self :: COLOR_MIN , min (self :: COLOR_MAX , $ score ));
316+ $ r = (int )(255 * ($ score / self :: COLOR_MAX ));
317+ $ g = (int )(180 * (1 - $ score / self :: COLOR_MAX ));
190318 $ b = 80 ;
191319
192320 return sprintf ('rgb(%d,%d,%d) ' , $ r , $ g , $ b );
0 commit comments