Skip to content

Commit 6c99a17

Browse files
Replace Billboard.js charts on HTML report dashboard with server-side rendered SVG bubble charts and CRAP tables
The dashboard now shows two rows (Classes, Methods), each with an SVG bubble chart on the left (coverage vs. complexity, sized by executable lines) and a CRAP index table on the right. This removes the Billboard.js dependency and all client-side chart rendering JavaScript.
1 parent a871ed1 commit 6c99a17

File tree

14 files changed

+794
-2025
lines changed

14 files changed

+794
-2025
lines changed

ChangeLog-14.0.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes are documented in this file using the [Keep a CHANGELOG](htt
1414
### Changed
1515

1616
* [#1142](https://github.com/sebastianbergmann/php-code-coverage/pull/1142): The HTML report now uses a more colorblind-friendly blue/amber/orange palette
17+
* The HTML report's dashboard now uses PHP-rendered SVG bubble charts (coverage vs. complexity vs. executable lines) and CRAP index tables instead of client-side Billboard.js charts
1718
* The report generation classes are now internal; the new `SebastianBergmann\CodeCoverage\Report\Facade` class must be used for report generation
1819
* The format of the file written by `SebastianBergmann\CodeCoverage\Serialization\Serializer` is incompatible with the format of the file that was written by `SebastianBergmann\CodeCoverage\Report\PHP` in the past
1920
* The `<build>` element and its children of the XML report generated by `SebastianBergmann\CodeCoverage\Report\Xml\Facade` require the optional arguments to be passed to the `process()` method

src/Report/Html/Facade.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ private function copyFiles(string $target): void
101101
{
102102
$dir = $this->directory($target . '_css');
103103

104-
copy($this->templatePath . 'css/billboard.min.css', $dir . 'billboard.min.css');
105104
copy($this->templatePath . 'css/bootstrap.min.css', $dir . 'bootstrap.min.css');
106105
copy($this->customCssFile->path(), $dir . 'custom.css');
107106
copy($this->templatePath . 'css/octicons.css', $dir . 'octicons.css');
@@ -111,7 +110,6 @@ private function copyFiles(string $target): void
111110
copy($this->templatePath . 'icons/file-directory.svg', $dir . 'file-directory.svg');
112111

113112
$dir = $this->directory($target . '_js');
114-
copy($this->templatePath . 'js/billboard.pkgd.min.js', $dir . 'billboard.pkgd.min.js');
115113
copy($this->templatePath . 'js/bootstrap.bundle.min.js', $dir . 'bootstrap.bundle.min.js');
116114
copy($this->templatePath . 'js/jquery.min.js', $dir . 'jquery.min.js');
117115
copy($this->templatePath . 'js/file.js', $dir . 'file.js');
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
<?php declare(strict_types=1);
2+
/*
3+
* This file is part of phpunit/php-code-coverage.
4+
*
5+
* (c) Sebastian Bergmann <sebastian@phpunit.de>
6+
*
7+
* For the full copyright and license information, please view the LICENSE
8+
* file that was distributed with this source code.
9+
*/
10+
namespace SebastianBergmann\CodeCoverage\Report\Html;
11+
12+
use const ENT_XML1;
13+
use function array_filter;
14+
use function array_map;
15+
use function ceil;
16+
use function floor;
17+
use function htmlspecialchars;
18+
use function log10;
19+
use function max;
20+
use function sprintf;
21+
use function sqrt;
22+
use function usort;
23+
use SebastianBergmann\CodeCoverage\Report\Thresholds;
24+
25+
/**
26+
* @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
27+
*
28+
* @no-named-arguments Parameter names are not covered by the backward compatibility promise for phpunit/php-code-coverage
29+
*/
30+
final readonly class BubbleChart
31+
{
32+
private Thresholds $thresholds;
33+
34+
public function __construct(Thresholds $thresholds)
35+
{
36+
$this->thresholds = $thresholds;
37+
}
38+
39+
/**
40+
* @param list<array{name: string, coverage: float|int, executableLines: int, complexity: int, link: string}> $items
41+
*/
42+
public function render(array $items): string
43+
{
44+
$items = array_filter(
45+
$items,
46+
static fn (array $item): bool => $item['executableLines'] > 0,
47+
);
48+
49+
if ($items === []) {
50+
return '';
51+
}
52+
53+
$maxExecutableLines = max(array_map(static fn (array $f): int => $f['executableLines'], $items));
54+
$maxComplexity = max(1, max(array_map(static fn (array $f): int => $f['complexity'], $items)));
55+
56+
usort($items, static fn (array $a, array $b): int => $b['executableLines'] <=> $a['executableLines']);
57+
58+
$svgWidth = 800;
59+
$svgHeight = 400;
60+
$paddingLeft = 60;
61+
$paddingRight = 20;
62+
$paddingTop = 20;
63+
$paddingBottom = 50;
64+
$plotWidth = $svgWidth - $paddingLeft - $paddingRight;
65+
$plotHeight = $svgHeight - $paddingTop - $paddingBottom;
66+
$maxRadius = 20;
67+
$minRadius = 4;
68+
69+
// Calculate Y-axis grid step
70+
$yAxisMax = $maxComplexity * 1.1;
71+
$rawStep = $yAxisMax / 5;
72+
$magnitude = 10 ** floor($rawStep > 0 ? log10($rawStep) : 0);
73+
$normalized = $rawStep / $magnitude;
74+
75+
if ($normalized <= 1) {
76+
$gridStep = $magnitude;
77+
} elseif ($normalized <= 2) {
78+
$gridStep = 2 * $magnitude;
79+
} elseif ($normalized <= 5) {
80+
$gridStep = 5 * $magnitude;
81+
} else {
82+
$gridStep = 10 * $magnitude;
83+
}
84+
85+
$gridStep = max(1, (int) $gridStep);
86+
$yAxisMax = $gridStep * (int) ceil($yAxisMax / $gridStep);
87+
88+
if ($yAxisMax === 0) {
89+
$yAxisMax = 10;
90+
}
91+
92+
$svg = sprintf(
93+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" preserveAspectRatio="xMidYMid meet">' . "\n",
94+
$svgWidth,
95+
$svgHeight,
96+
);
97+
98+
$svg .= <<<'CSS'
99+
<style>
100+
.bubble-success { fill: var(--phpunit-success-bar); opacity: 0.7; }
101+
.bubble-warning { fill: var(--phpunit-warning-bar); opacity: 0.7; }
102+
.bubble-danger { fill: var(--phpunit-danger-bar); opacity: 0.7; }
103+
.bubble-success:hover, .bubble-warning:hover, .bubble-danger:hover { opacity: 1; stroke: var(--bs-emphasis-color); stroke-width: 1.5; }
104+
.chart-grid { stroke: var(--bs-border-color); stroke-width: 0.5; stroke-dasharray: 4,4; opacity: 0.5; }
105+
.chart-axis { stroke: var(--bs-border-color); stroke-width: 1; }
106+
.chart-label { fill: var(--bs-body-color); font-size: 11px; font-family: var(--bs-font-sans-serif, sans-serif); }
107+
.chart-title { fill: var(--bs-body-color); font-size: 12px; font-family: var(--bs-font-sans-serif, sans-serif); }
108+
</style>
109+
110+
CSS;
111+
112+
// Vertical grid lines (x-axis: 0%, 20%, 40%, 60%, 80%, 100%)
113+
for ($pct = 0; $pct <= 100; $pct += 20) {
114+
$x = $paddingLeft + ($pct / 100) * $plotWidth;
115+
116+
$svg .= sprintf(
117+
' <line x1="%.1f" y1="%d" x2="%.1f" y2="%d" class="chart-grid"/>' . "\n",
118+
$x,
119+
$paddingTop,
120+
$x,
121+
$paddingTop + $plotHeight,
122+
);
123+
124+
$svg .= sprintf(
125+
' <text x="%.1f" y="%d" text-anchor="middle" class="chart-label">%d%%</text>' . "\n",
126+
$x,
127+
$paddingTop + $plotHeight + 18,
128+
$pct,
129+
);
130+
}
131+
132+
// Horizontal grid lines (y-axis)
133+
for ($val = 0; $val <= $yAxisMax; $val += $gridStep) {
134+
$y = $paddingTop + $plotHeight - ($val / $yAxisMax) * $plotHeight;
135+
136+
$svg .= sprintf(
137+
' <line x1="%d" y1="%.1f" x2="%d" y2="%.1f" class="chart-grid"/>' . "\n",
138+
$paddingLeft,
139+
$y,
140+
$paddingLeft + $plotWidth,
141+
$y,
142+
);
143+
144+
$svg .= sprintf(
145+
' <text x="%d" y="%.1f" text-anchor="end" dominant-baseline="middle" class="chart-label">%d</text>' . "\n",
146+
$paddingLeft - 8,
147+
$y,
148+
$val,
149+
);
150+
}
151+
152+
// Axes
153+
$svg .= sprintf(
154+
' <line x1="%d" y1="%d" x2="%d" y2="%d" class="chart-axis"/>' . "\n",
155+
$paddingLeft,
156+
$paddingTop,
157+
$paddingLeft,
158+
$paddingTop + $plotHeight,
159+
);
160+
161+
$svg .= sprintf(
162+
' <line x1="%d" y1="%d" x2="%d" y2="%d" class="chart-axis"/>' . "\n",
163+
$paddingLeft,
164+
$paddingTop + $plotHeight,
165+
$paddingLeft + $plotWidth,
166+
$paddingTop + $plotHeight,
167+
);
168+
169+
// Axis titles
170+
$svg .= sprintf(
171+
' <text x="%.1f" y="%d" text-anchor="middle" class="chart-title">Line Coverage (%%)</text>' . "\n",
172+
$paddingLeft + $plotWidth / 2,
173+
$svgHeight - 5,
174+
);
175+
176+
$svg .= sprintf(
177+
' <text x="15" y="%.1f" text-anchor="middle" transform="rotate(-90, 15, %.1f)" class="chart-title">Cyclomatic Complexity</text>' . "\n",
178+
$paddingTop + $plotHeight / 2,
179+
$paddingTop + $plotHeight / 2,
180+
);
181+
182+
// Bubbles
183+
foreach ($items as $item) {
184+
$cx = $paddingLeft + ($item['coverage'] / 100) * $plotWidth;
185+
$cy = $paddingTop + $plotHeight - ($item['complexity'] / $yAxisMax) * $plotHeight;
186+
$r = max($minRadius, sqrt($item['executableLines'] / $maxExecutableLines) * $maxRadius);
187+
188+
$colorClass = 'bubble-' . $this->colorLevel($item['coverage']);
189+
$title = htmlspecialchars(
190+
sprintf(
191+
'%s — Coverage: %.1f%% | Lines: %d | Complexity: %d',
192+
$item['name'],
193+
$item['coverage'],
194+
$item['executableLines'],
195+
$item['complexity'],
196+
),
197+
ENT_XML1,
198+
);
199+
200+
$svg .= sprintf(
201+
' <a href="%s"><circle cx="%.1f" cy="%.1f" r="%.1f" class="%s"><title>%s</title></circle></a>' . "\n",
202+
htmlspecialchars($item['link'], ENT_XML1),
203+
$cx,
204+
$cy,
205+
$r,
206+
$colorClass,
207+
$title,
208+
);
209+
}
210+
211+
$svg .= '</svg>';
212+
213+
return $svg;
214+
}
215+
216+
private function colorLevel(float $percent): string
217+
{
218+
if ($percent <= $this->thresholds->lowUpperBound()) {
219+
return 'danger';
220+
}
221+
222+
if ($percent < $this->thresholds->highLowerBound()) {
223+
return 'warning';
224+
}
225+
226+
return 'success';
227+
}
228+
}

0 commit comments

Comments
 (0)