1 | <?php |
||||||
2 | |||||||
3 | namespace Afonso\Plotta; |
||||||
4 | |||||||
5 | use \RuntimeException; |
||||||
6 | |||||||
7 | class PlotBuilder |
||||||
8 | { |
||||||
9 | const PLOT_MARGIN = 10; |
||||||
10 | |||||||
11 | const TITLE_FONT_SIZE = 5; |
||||||
12 | |||||||
13 | const AXIS_VALUE_FONT_SIZE = 2; |
||||||
14 | |||||||
15 | const INTER_ELEMENT_SPACING = 10; |
||||||
16 | |||||||
17 | const N_TICKS_Y_AXIS = 10; |
||||||
18 | |||||||
19 | const COLORS = [ |
||||||
20 | [0x00, 0x00, 0xff], |
||||||
21 | [0xff, 0x00, 0x00], |
||||||
22 | [0x00, 0xff, 0x00], |
||||||
23 | ]; |
||||||
24 | |||||||
25 | /** |
||||||
26 | * @var int |
||||||
27 | */ |
||||||
28 | private $width; |
||||||
29 | |||||||
30 | /** |
||||||
31 | * @var int |
||||||
32 | */ |
||||||
33 | private $height; |
||||||
34 | |||||||
35 | /** |
||||||
36 | * @var string |
||||||
37 | */ |
||||||
38 | private $title; |
||||||
39 | |||||||
40 | /** |
||||||
41 | * @var \Afonso\Plotta\XAxisConfig |
||||||
42 | */ |
||||||
43 | private $xAxisConfig; |
||||||
44 | |||||||
45 | /** |
||||||
46 | * @var \Afonso\Plotta\YAxisConfig |
||||||
47 | */ |
||||||
48 | private $yAxisConfig; |
||||||
49 | |||||||
50 | /** |
||||||
51 | * @var array |
||||||
52 | */ |
||||||
53 | private $data = []; |
||||||
54 | |||||||
55 | /** |
||||||
56 | * Set the dimensions of the chart, in pixels. |
||||||
57 | * |
||||||
58 | * @param int $width |
||||||
59 | * @param int $height |
||||||
60 | * @return self |
||||||
61 | */ |
||||||
62 | public function withDimensions(int $width, int $height): PlotBuilder |
||||||
63 | { |
||||||
64 | $this->width = $width; |
||||||
65 | $this->height = $height; |
||||||
66 | return $this; |
||||||
67 | } |
||||||
68 | |||||||
69 | /** |
||||||
70 | * Set the title of the chart. |
||||||
71 | * |
||||||
72 | * @param string $title |
||||||
73 | * @return self |
||||||
74 | */ |
||||||
75 | public function withTitle(string $title): PlotBuilder |
||||||
76 | { |
||||||
77 | $this->title = $title; |
||||||
78 | return $this; |
||||||
79 | } |
||||||
80 | |||||||
81 | /** |
||||||
82 | * Set the configuration of the X axis. |
||||||
83 | * |
||||||
84 | * @param \Afonso\Plotta\XAxisConfig $xAxisConfig |
||||||
85 | * @return self |
||||||
86 | */ |
||||||
87 | public function withXAxis(XAxisConfig $xAxisConfig): PlotBuilder |
||||||
88 | { |
||||||
89 | $this->xAxisConfig = $xAxisConfig; |
||||||
90 | return $this; |
||||||
91 | } |
||||||
92 | |||||||
93 | /** |
||||||
94 | * Set the configuration of the Y axis. |
||||||
95 | * |
||||||
96 | * @param \Afonso\Plotta\YAxisConfig $yAxisConfig |
||||||
97 | * @return self |
||||||
98 | */ |
||||||
99 | public function withYAxis(YAxisConfig $yAxisConfig): PlotBuilder |
||||||
100 | { |
||||||
101 | $this->yAxisConfig = $yAxisConfig; |
||||||
102 | return $this; |
||||||
103 | } |
||||||
104 | |||||||
105 | /** |
||||||
106 | * Add a time series to the data to be plotted. |
||||||
107 | * |
||||||
108 | * @param array $data |
||||||
109 | * @return self |
||||||
110 | */ |
||||||
111 | public function withData(array $data): PlotBuilder |
||||||
112 | { |
||||||
113 | $this->data[] = $data; |
||||||
114 | return $this; |
||||||
115 | } |
||||||
116 | |||||||
117 | /** |
||||||
118 | * Generate the chart and save it as a PNG to the specified location. |
||||||
119 | * |
||||||
120 | * @param string $path |
||||||
121 | */ |
||||||
122 | public function render(string $path): void |
||||||
123 | { |
||||||
124 | // Check that everything is okay before proceeding |
||||||
125 | $this->checkOrFail(); |
||||||
126 | |||||||
127 | // Determine the minimum and maximum values of the data series |
||||||
128 | $minValue = $this->yAxisConfig->min ?? $this->getMinValue($this->data); |
||||||
129 | $maxValue = $this->yAxisConfig->max ?? $this->getMaxValue($this->data); |
||||||
130 | |||||||
131 | // Calculate the key coordinates for the chart components |
||||||
132 | $coords = $this->calculateCoordinates(); |
||||||
133 | |||||||
134 | // Initialize chart |
||||||
135 | $img = $this->initChart($this->width, $this->height); |
||||||
136 | |||||||
137 | // Title |
||||||
138 | $this->drawTitle($img, $coords, $this->title); |
||||||
139 | |||||||
140 | // Y Axis |
||||||
141 | $this->drawYAxis($img, $coords, $this->yAxisConfig, $minValue, $maxValue); |
||||||
142 | |||||||
143 | // X Axis |
||||||
144 | $this->drawXAxis($img, $coords, $this->xAxisConfig); |
||||||
145 | |||||||
146 | // Data |
||||||
147 | $this->drawData($img, $coords, $this->data, $minValue, $maxValue); |
||||||
0 ignored issues
–
show
Bug
introduced
by
![]() $minValue of type double is incompatible with the type integer expected by parameter $minValue of Afonso\Plotta\PlotBuilder::drawData() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
148 | |||||||
149 | // Write to file |
||||||
150 | imagepng($img, $path); |
||||||
151 | } |
||||||
152 | |||||||
153 | private function checkOrFail(): void |
||||||
154 | { |
||||||
155 | // Check that the number of data points and labels are the same |
||||||
156 | $maxDataPoints = max(array_map('count', $this->data)); |
||||||
157 | $minDataPoints = min(array_map('count', $this->data)); |
||||||
158 | $nLabels = count($this->xAxisConfig->labels); |
||||||
159 | if ($maxDataPoints != $minDataPoints) { |
||||||
160 | throw new RuntimeException("Data series do not have the same number of items. Min: ${minDataPoints}, max: ${maxDataPoints}"); |
||||||
161 | } elseif ($maxDataPoints != $nLabels) { |
||||||
162 | throw new RuntimeException("Number of X axis labels does not match the number of data items. Data points: ${maxDataPoints}, labels: ${nLabels}"); |
||||||
163 | } |
||||||
164 | } |
||||||
165 | |||||||
166 | /** |
||||||
167 | * Return the min and max values across all data series. |
||||||
168 | * |
||||||
169 | * @param float[][] $series |
||||||
170 | * @return float[] |
||||||
171 | */ |
||||||
172 | private function getMinAndMaxValues(array $series): array |
||||||
0 ignored issues
–
show
|
|||||||
173 | { |
||||||
174 | return [$this->getMinValue($series), $this->getMaxValue($series)]; |
||||||
175 | } |
||||||
176 | |||||||
177 | /** |
||||||
178 | * Return the min value across all data series. |
||||||
179 | * |
||||||
180 | * @param float[][] $series |
||||||
181 | * @return float |
||||||
182 | */ |
||||||
183 | private function getMinValue(array $series): float |
||||||
184 | { |
||||||
185 | return min(array_map('min', $series)); |
||||||
186 | } |
||||||
187 | |||||||
188 | /** |
||||||
189 | * Return the max values across all data series. |
||||||
190 | * |
||||||
191 | * @param float[][] $series |
||||||
192 | * @return float |
||||||
193 | */ |
||||||
194 | private function getMaxValue(array $series): float |
||||||
195 | { |
||||||
196 | return max(array_map('max', $series)); |
||||||
197 | } |
||||||
198 | |||||||
199 | private function interpolateYCoord( |
||||||
200 | int $areaTopY, |
||||||
201 | int $areaBottomY, |
||||||
202 | int $value, |
||||||
203 | int $maxValue, |
||||||
204 | int $minValue |
||||||
205 | ): int { |
||||||
206 | $pct = 1 - ($value - $minValue) / ($maxValue - $minValue); |
||||||
207 | return $areaTopY + ($areaBottomY - $areaTopY) * $pct; |
||||||
208 | } |
||||||
209 | |||||||
210 | private function calculateCoordinates(): array |
||||||
211 | { |
||||||
212 | $coords = []; |
||||||
213 | |||||||
214 | $coords['title_top_left'] = [ |
||||||
215 | 'x' => $this->width / 2 - imagefontwidth(self::TITLE_FONT_SIZE) * strlen($this->title) / 2, |
||||||
216 | 'y' => self::PLOT_MARGIN |
||||||
217 | ]; |
||||||
218 | $coords['y_axis_top_left'] = [ |
||||||
219 | 'x' => self::PLOT_MARGIN, |
||||||
220 | 'y' => self::PLOT_MARGIN + imagefontheight(self::TITLE_FONT_SIZE) + self::INTER_ELEMENT_SPACING |
||||||
221 | ]; |
||||||
222 | $coords['y_axis_bottom_right'] = [ |
||||||
223 | 'x' => self::PLOT_MARGIN + 75, |
||||||
224 | 'y' => $this->height - self::PLOT_MARGIN - (imagefontheight(self::AXIS_VALUE_FONT_SIZE) + self::INTER_ELEMENT_SPACING) * 2 |
||||||
225 | ]; |
||||||
226 | $coords['x_axis_top_left'] = [ |
||||||
227 | 'x' => $coords['y_axis_bottom_right']['x'], |
||||||
228 | 'y' => $coords['y_axis_bottom_right']['y'] |
||||||
229 | ]; |
||||||
230 | $coords['x_axis_bottom_right'] = [ |
||||||
231 | 'x' => $this->width - self::PLOT_MARGIN, |
||||||
232 | 'y' => $this->height - self::PLOT_MARGIN, |
||||||
233 | ]; |
||||||
234 | $coords['chart_area_top_left'] = [ |
||||||
235 | 'x' => $coords['y_axis_bottom_right']['x'], |
||||||
236 | 'y' => $coords['y_axis_top_left']['y'] |
||||||
237 | ]; |
||||||
238 | $coords['chart_area_bottom_right'] = [ |
||||||
239 | 'x' => $coords['x_axis_bottom_right']['x'], |
||||||
240 | 'y' => $coords['x_axis_top_left']['y'] |
||||||
241 | ]; |
||||||
242 | |||||||
243 | return $coords; |
||||||
244 | } |
||||||
245 | |||||||
246 | private function initChart(int $width, int $height) |
||||||
0 ignored issues
–
show
The parameter
$width is not used and could be removed.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() The parameter
$height is not used and could be removed.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||||
247 | { |
||||||
248 | $img = imagecreatetruecolor($this->width, $this->height); |
||||||
249 | imageantialias($img, true); |
||||||
0 ignored issues
–
show
It seems like
$img can also be of type false ; however, parameter $image of imageantialias() does only seem to accept resource , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
250 | |||||||
251 | // White background fill |
||||||
252 | imagefill( |
||||||
253 | $img, |
||||||
0 ignored issues
–
show
It seems like
$img can also be of type false ; however, parameter $image of imagefill() does only seem to accept resource , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
254 | 0, |
||||||
255 | 0, |
||||||
256 | imagecolorallocate($img, 0xff, 0xff, 0xff) |
||||||
0 ignored issues
–
show
It seems like
$img can also be of type false ; however, parameter $image of imagecolorallocate() does only seem to accept resource , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
257 | ); |
||||||
258 | |||||||
259 | return $img; |
||||||
260 | } |
||||||
261 | |||||||
262 | private function drawTitle(&$img, array $coords, string $title): void |
||||||
0 ignored issues
–
show
The parameter
$title is not used and could be removed.
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check looks for parameters that have been defined for a function or method, but which are not used in the method body. ![]() |
|||||||
263 | { |
||||||
264 | imagestring( |
||||||
265 | $img, |
||||||
266 | self::TITLE_FONT_SIZE, |
||||||
267 | $coords['title_top_left']['x'], |
||||||
268 | $coords['title_top_left']['y'], |
||||||
269 | $this->title, |
||||||
270 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
271 | ); |
||||||
272 | } |
||||||
273 | |||||||
274 | private function drawYAxis(&$img, array $coords, YAxisConfig $yAxisConfig, float $minValue, float $maxValue): void |
||||||
275 | { |
||||||
276 | // Main Y axis line |
||||||
277 | imageline( |
||||||
278 | $img, |
||||||
279 | $coords['chart_area_top_left']['x'], |
||||||
280 | $coords['chart_area_top_left']['y'], |
||||||
281 | $coords['chart_area_top_left']['x'], |
||||||
282 | $coords['chart_area_bottom_right']['y'], |
||||||
283 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
284 | ); |
||||||
285 | |||||||
286 | // Ticks and values |
||||||
287 | $tickSpacing = ($coords['y_axis_bottom_right']['y'] - $coords['y_axis_top_left']['y']) / self::N_TICKS_Y_AXIS; |
||||||
288 | $valueInterval = ($maxValue - $minValue) / self::N_TICKS_Y_AXIS; |
||||||
289 | $valueYOffset = imagefontheight(self::AXIS_VALUE_FONT_SIZE) / 2; |
||||||
290 | for ($i = 0; $i < self::N_TICKS_Y_AXIS; $i++) { |
||||||
291 | $y = $coords['y_axis_top_left']['y'] + $i * $tickSpacing; |
||||||
292 | imageline( |
||||||
293 | $img, |
||||||
294 | $coords['y_axis_bottom_right']['x'] - 1, |
||||||
295 | $y, |
||||||
296 | $coords['y_axis_bottom_right']['x'] + 1, |
||||||
297 | $y, |
||||||
298 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
299 | ); |
||||||
300 | |||||||
301 | $label = $maxValue - $i * $valueInterval; |
||||||
302 | imagestring( |
||||||
303 | $img, |
||||||
304 | self::AXIS_VALUE_FONT_SIZE, |
||||||
305 | $coords['y_axis_bottom_right']['x'] - imagefontwidth(self::AXIS_VALUE_FONT_SIZE) * strlen($label) - self::INTER_ELEMENT_SPACING, |
||||||
306 | $y - $valueYOffset, |
||||||
307 | $label, |
||||||
308 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
309 | ); |
||||||
310 | } |
||||||
311 | |||||||
312 | // Name |
||||||
313 | imagestringup( |
||||||
314 | $img, |
||||||
315 | self::AXIS_VALUE_FONT_SIZE, |
||||||
316 | $coords['y_axis_top_left']['x'], |
||||||
317 | $coords['y_axis_top_left']['y'] |
||||||
318 | + ($coords['y_axis_bottom_right']['y'] - $coords['y_axis_top_left']['y']) / 2 |
||||||
319 | + (imagefontwidth(self::AXIS_VALUE_FONT_SIZE) * strlen($yAxisConfig->name)) / 2, |
||||||
320 | $yAxisConfig->name, |
||||||
321 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
322 | ); |
||||||
323 | } |
||||||
324 | |||||||
325 | private function drawXAxis(&$img, array $coords, XAxisConfig $xAxisConfig): void |
||||||
326 | { |
||||||
327 | // Main X axis line |
||||||
328 | imageline( |
||||||
329 | $img, |
||||||
330 | $coords['chart_area_top_left']['x'], |
||||||
331 | $coords['chart_area_bottom_right']['y'], |
||||||
332 | $coords['chart_area_bottom_right']['x'], |
||||||
333 | $coords['chart_area_bottom_right']['y'], |
||||||
334 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
335 | ); |
||||||
336 | |||||||
337 | // ================ |
||||||
338 | // Ticks and labels |
||||||
339 | // ================ |
||||||
340 | // The total amount of labels |
||||||
341 | $nLabels = count($xAxisConfig->labels); |
||||||
342 | // The total number of ticks and labels that we'll print. The higher |
||||||
343 | // the number of labels, the fewer of them we'll print so that we don't |
||||||
344 | // clutter the axis. |
||||||
345 | // Right now this is a function of the order of magnitude of the number |
||||||
346 | // of labels. From 1 to 9 elements, we skip none. From 10 to 100, we |
||||||
347 | // pick one every ten. From 101 to 1000, we pick one every one hundred, |
||||||
348 | // but this is clearly not ideal. We should probably factor in the |
||||||
349 | // chart's width, the length of the labels, etc. |
||||||
350 | $nTicks = floor($nLabels / (10 ** (floor(log10($nLabels)) - 1))); |
||||||
351 | // The number of ticks and labels we skip in between, so that all items |
||||||
352 | // that will be printed are evenly distributed across the axis. |
||||||
353 | $tickOffset = floor($nLabels / ($nTicks - 1)); |
||||||
354 | // The space in pixels between each tick. |
||||||
355 | $tickSpacing = floor(($coords['x_axis_bottom_right']['x'] - $coords['x_axis_top_left']['x']) / ($nTicks - 1)); |
||||||
356 | |||||||
357 | for ($i = 0; $i < $nTicks; $i++) { |
||||||
358 | $x = $coords['x_axis_top_left']['x'] + $i * $tickSpacing; |
||||||
359 | imageline( |
||||||
360 | $img, |
||||||
361 | $x, |
||||||
0 ignored issues
–
show
$x of type double is incompatible with the type integer expected by parameter $x1 of imageline() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
362 | $coords['x_axis_top_left']['y'] - 1, |
||||||
363 | $x, |
||||||
0 ignored issues
–
show
$x of type double is incompatible with the type integer expected by parameter $x2 of imageline() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
364 | $coords['x_axis_top_left']['y'] + 1, |
||||||
365 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
366 | ); |
||||||
367 | |||||||
368 | // If the X axis configuration specifies a date format, apply it. |
||||||
369 | // Otherwise use the label as-is. |
||||||
370 | $label = $xAxisConfig->labels[$i * $tickOffset]; |
||||||
371 | if ($xAxisConfig->dateFormat !== null) { |
||||||
372 | $label = date($xAxisConfig->dateFormat, $label); |
||||||
373 | } |
||||||
374 | imagestring( |
||||||
375 | $img, |
||||||
376 | self::AXIS_VALUE_FONT_SIZE, |
||||||
377 | $x - imagefontwidth(self::AXIS_VALUE_FONT_SIZE) * strlen($label) / 2, |
||||||
0 ignored issues
–
show
$x - imagefontwidth(self...E) * strlen($label) / 2 of type double is incompatible with the type integer expected by parameter $x of imagestring() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
378 | $coords['x_axis_top_left']['y'] + self::INTER_ELEMENT_SPACING, |
||||||
379 | $label, |
||||||
380 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
381 | ); |
||||||
382 | } |
||||||
383 | |||||||
384 | // Name |
||||||
385 | imagestring( |
||||||
386 | $img, |
||||||
387 | self::AXIS_VALUE_FONT_SIZE, |
||||||
388 | $coords['x_axis_top_left']['x'] |
||||||
389 | + ($coords['x_axis_bottom_right']['x'] - $coords['x_axis_top_left']['x']) / 2 |
||||||
390 | - imagefontwidth(self::AXIS_VALUE_FONT_SIZE) * strlen($xAxisConfig->name) / 2, |
||||||
391 | $coords['x_axis_top_left']['y'] + imagefontheight(self::AXIS_VALUE_FONT_SIZE) + self::INTER_ELEMENT_SPACING * 2, |
||||||
392 | $xAxisConfig->name, |
||||||
393 | imagecolorallocate($img, 0x00, 0x00, 0x00) |
||||||
394 | ); |
||||||
395 | } |
||||||
396 | |||||||
397 | private function drawData(&$img, array $coords, array $data, int $minValue, int $maxValue): void |
||||||
398 | { |
||||||
399 | $plotAreaTopY = $coords['chart_area_top_left']['y']; |
||||||
400 | $plotAreaBottomY = $coords['chart_area_bottom_right']['y']; |
||||||
401 | $nPoints = max(array_map('count', $this->data)); |
||||||
402 | $segmentWidth = ($coords['chart_area_bottom_right']['x'] - $coords['chart_area_top_left']['x']) / ($nPoints - 1); |
||||||
403 | foreach ($data as $idx => $series) { |
||||||
404 | [$r, $g, $b] = self::COLORS[$idx % count(self::COLORS)]; |
||||||
405 | $lineColor = imagecolorallocate($img, $r, $g, $b); |
||||||
406 | |||||||
407 | $fromX = $fromY = null; |
||||||
408 | for ($i = 1; $i < count($series); $i++) { |
||||||
0 ignored issues
–
show
It seems like you are calling the size function
count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.
If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration: for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}
// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
![]() |
|||||||
409 | $fromValue = $series[$i - 1]; |
||||||
410 | $toValue = $series[$i]; |
||||||
411 | |||||||
412 | if ($fromX === null) { |
||||||
413 | $fromX = $coords['chart_area_top_left']['x'] + ($i - 1) * $segmentWidth; |
||||||
414 | $fromY = $this->interpolateYCoord($plotAreaTopY, $plotAreaBottomY, $fromValue, $maxValue, $minValue); |
||||||
415 | } |
||||||
416 | |||||||
417 | $toX = $coords['chart_area_top_left']['x'] + $i * $segmentWidth; |
||||||
418 | $toY = $this->interpolateYCoord($plotAreaTopY, $plotAreaBottomY, $toValue, $maxValue, $minValue); |
||||||
419 | |||||||
420 | imageline($img, $fromX, $fromY, $toX, $toY, $lineColor); |
||||||
421 | |||||||
422 | $fromX = $toX; |
||||||
423 | $fromY = $toY; |
||||||
424 | } |
||||||
425 | } |
||||||
426 | } |
||||||
427 | } |
||||||
428 |