PlotBuilder   A
last analyzed

Complexity

Total Complexity 25

Size/Duplication

Total Lines 417
Duplicated Lines 0 %

Importance

Changes 9
Bugs 0 Features 0
Metric Value
eloc 179
c 9
b 0
f 0
dl 0
loc 417
rs 10
wmc 25

17 Methods

Rating   Name   Duplication   Size   Complexity  
A initChart() 0 14 1
A drawXAxis() 0 69 3
A drawTitle() 0 9 1
A getMinAndMaxValues() 0 3 1
A interpolateYCoord() 0 9 1
A render() 0 29 1
A withDimensions() 0 5 1
A withData() 0 4 1
A checkOrFail() 0 10 3
A drawYAxis() 0 48 2
A drawData() 0 27 4
A getMaxValue() 0 3 1
A withTitle() 0 4 1
A withYAxis() 0 4 1
A withXAxis() 0 4 1
A calculateCoordinates() 0 34 1
A getMinValue() 0 3 1
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
$maxValue of type double is incompatible with the type integer expected by parameter $maxValue 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 ignore-type  annotation

147
        $this->drawData($img, $coords, $this->data, $minValue, /** @scrutinizer ignore-type */ $maxValue);
Loading history...
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 ignore-type  annotation

147
        $this->drawData($img, $coords, $this->data, /** @scrutinizer ignore-type */ $minValue, $maxValue);
Loading history...
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
Unused Code introduced by
The method getMinAndMaxValues() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
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
Unused Code introduced by
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 ignore-unused  annotation

246
    private function initChart(/** @scrutinizer ignore-unused */ int $width, int $height)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
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 ignore-unused  annotation

246
    private function initChart(int $width, /** @scrutinizer ignore-unused */ int $height)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
247
    {
248
        $img = imagecreatetruecolor($this->width, $this->height);
249
        imageantialias($img, true);
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

249
        imageantialias(/** @scrutinizer ignore-type */ $img, true);
Loading history...
250
251
        // White background fill
252
        imagefill(
253
            $img,
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

253
            /** @scrutinizer ignore-type */ $img,
Loading history...
254
            0,
255
            0,
256
            imagecolorallocate($img, 0xff, 0xff, 0xff)
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

256
            imagecolorallocate(/** @scrutinizer ignore-type */ $img, 0xff, 0xff, 0xff)
Loading history...
257
        );
258
259
        return $img;
260
    }
261
262
    private function drawTitle(&$img, array $coords, string $title): void
0 ignored issues
show
Unused Code introduced by
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 ignore-unused  annotation

262
    private function drawTitle(&$img, array $coords, /** @scrutinizer ignore-unused */ string $title): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
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
Bug introduced by
$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 ignore-type  annotation

361
                /** @scrutinizer ignore-type */ $x,
Loading history...
362
                $coords['x_axis_top_left']['y'] - 1,
363
                $x,
0 ignored issues
show
Bug introduced by
$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 ignore-type  annotation

363
                /** @scrutinizer ignore-type */ $x,
Loading history...
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
Bug introduced by
$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 ignore-type  annotation

377
                /** @scrutinizer ignore-type */ $x - imagefontwidth(self::AXIS_VALUE_FONT_SIZE) * strlen($label) / 2,
Loading history...
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
Performance Best Practice introduced by
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
}
Loading history...
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