Skip to content

Commit 89573a7

Browse files
Adding tests
1 parent c0d2a49 commit 89573a7

3 files changed

Lines changed: 248 additions & 62 deletions

File tree

src/Business/Churn/Exporter/SvgTreemapExporter.php

Lines changed: 190 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
*/
1212
class 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&#10;Churn: %s&#10;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&#10;Churn: %s&#10;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
113201
SVG;
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);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\CognitiveCodeAnalysis\Tests\Unit\Business\Churn\Exporter;
6+
7+
use Phauthentic\CognitiveCodeAnalysis\Business\Churn\Exporter\JsonExporter;
8+
use PHPUnit\Framework\Attributes\Test;
9+
10+
/**
11+
*
12+
*/
13+
class SvgTreemapExporterTest extends AbstractExporterTestCase
14+
{
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
$this->exporter = new JsonExporter();
20+
$this->filename = sys_get_temp_dir() . '/test_metrics.json';
21+
}
22+
23+
#[Test]
24+
public function testExport(): void
25+
{
26+
$classes = $this->getTestData();
27+
28+
$this->exporter->export($classes, $this->filename);
29+
30+
$this->assertFileEquals(
31+
expected: __DIR__ . '/SvgTreemapExporterTest.svg',
32+
actual: $this->filename
33+
);
34+
}
35+
}
Lines changed: 23 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)