Skip to content

Commit 4d6ac0f

Browse files
Refactoring the treemap exporter
1 parent 67b1dff commit 4d6ac0f

2 files changed

Lines changed: 277 additions & 220 deletions

File tree

src/Business/Churn/Exporter/SvgTreemapExporter.php

Lines changed: 15 additions & 220 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,9 @@
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>
201153
SVG;
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

Comments
 (0)