Issues (13)

src/PlotBuilder.php (8 issues)

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
$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...
$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
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)
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 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
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
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
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 ignore-type  annotation

361
                /** @scrutinizer ignore-type */ $x,
Loading history...
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 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
$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++) {
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