Passed
Push — master ( 51b511...9aadcc )
by Victor
02:29
created

IntervalGraph::getAggregateFunction()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Vctls\IntervalGraph;
4
5
use Closure;
6
use DateTime;
7
use Exception;
8
use InvalidArgumentException;
9
use JsonSerializable;
10
11
/**
12
 * A class to manipulate and display arrays of weighted intervals.
13
 *
14
 * @package Vctls\IntervalGraph
15
 */
16
class IntervalGraph implements JsonSerializable
17
{
18
19
    /** @var array Initial intervals */
20
    protected $intervals;
21
22
    /** @var array Processed values */
23
    protected $values;
24
25
    /** @var string Path to the template used for rendering. */
26
    protected $template = 'template.php';
27
28
    /** @var Closure Return a numeric value from the inital bound value. */
29
    protected $boundToNumeric;
30
31
    /** @var Closure Return a string from the initial bound value. */
32
    protected $boundToString;
33
34
    /** @var Closure Return a numeric value from an initial interval value. */
35
    protected $valueToNumeric;
36
37
    /** @var Closure Return a string value from an initial interval value. */
38
    protected $valueToString;
39
40
    /** @var Palette */
41
    protected $palette;
42
43
    /** @var Flattener */
44
    private $flattener;
45
46
    /** @var Aggregator */
47
    private $aggregator;
48
49
    /**
50
     * Create an IntervalGraph from intervals carrying values.
51
     *
52
     * @param array[] $intervals An array of intervals,
53
     * with a low bound, high bound and a value.
54
     */
55
    public function __construct($intervals = null)
56
    {
57
        if (isset($intervals)) {
58
            $this->setIntervals($intervals);
59
        }
60
61
        $this->boundToNumeric = static function (DateTime $bound) {
62
            return $bound->getTimestamp();
63
        };
64
65
        $this->boundToString = static function (DateTime $bound) {
66
            return $bound->format('Y-m-d');
67
        };
68
69
        $this->valueToNumeric = static function ($v) {
70
            return $v === null ? null : (int)($v * 100);
71
        };
72
73
        $this->valueToString = static function ($v) {
74
            return $v === null ? null : ($v * 100 . '%');
75
        };
76
77
        $this->aggregator = new Aggregator();
78
        $this->flattener = new Flattener();
79
        $this->palette = new Palette();
80
    }
81
82
    /**
83
     * @return Flattener
84
     */
85
    public function getFlattener(): Flattener
86
    {
87
        return $this->flattener;
88
    }
89
90
    /**
91
     * @param Flattener $flattener
92
     * @return IntervalGraph
93
     */
94
    public function setFlattener(Flattener $flattener): IntervalGraph
95
    {
96
        $this->flattener = $flattener;
97
        return $this;
98
    }
99
100
    /**
101
     * Check that an array of intervals is correctly formatted.
102
     *
103
     * The first element must be the low bound.
104
     *
105
     * The second element must be the high bound.
106
     *
107
     * The third element must be the value.
108
     *
109
     * Inverted end and low bounds will be put back in chronological order.
110
     *
111
     * @return $this
112
     */
113
    public function checkIntervals(): self
114
    {
115
116
        foreach ($this->intervals as $intervalKey => $interval) {
117
118
            // Check that the interval is an array.
119
            if (!is_array($interval)) {
120
                $t = gettype($interval);
121
                throw new InvalidArgumentException(
122
                    "Each element of the '\$intervals' array should be an array, $t given."
123
                );
124
            }
125
126
            // Check that the bounds and value of the interval can be converted to both a numeric
127
            // and string value with the given closures.
128
            foreach ([['Lower bound', 'bound'], ['Higher bound', 'bound'], ['Value', 'value']] as $index => $property) {
129
130
                // Skip value property of valueless intervals.
131
                if ($property[1] === 'value' && !isset($interval[$index])) {
132
                    continue;
133
                }
134
135
                foreach (['numeric', 'string'] as $expectedType) {
136
137
                    $expectedTypeTitle = ucfirst($expectedType);
138
139
                    try {
140
                        $value = ($this->{"$property[1]To$expectedTypeTitle"})($interval[$index]);
141
                    } catch (Exception $exception) {
142
                        // FIXME Handle Type errors?
143
                        throw new PropertyConversionException(
144
                            "$property[0] of interval $intervalKey cannot be converted to a $expectedType value " .
145
                            "with the given '$property[1]To$expectedTypeTitle' function. Error : " .
146
                            $exception->getMessage()
147
                        );
148
                    }
149
150
                    $actualType = gettype($value);
151
152
                    if (!call_user_func("is_$expectedType", $value)) {
153
                        throw new PropertyConversionException(
154
                            "$property[0] of interval $intervalKey is not converted to a $expectedType value " .
155
                            "by the given '$property[1]To$expectedTypeTitle' function. Returned type : $actualType"
156
                        );
157
                    }
158
                }
159
            }
160
161
            // Ensure start and high bounds are in the right order.
162
            if ($interval[0] > $interval [1]) {
163
                $a = $interval[0];
164
                $intervals[$intervalKey][0] = $interval[1];
165
                $intervals[$intervalKey][1] = $a;
166
            }
167
        }
168
169
        // TODO Check that the values can be aggregated with the given closure.
170
171
        return $this;
172
    }
173
174
    /**
175
     * Render an HTML view of the intervalGraph.
176
     *
177
     * @return string
178
     */
179
    public function __toString()
180
    {
181
        try {
182
            $html = $this->draw();
183
        } catch (Exception $e) {
184
            $html = 'Error : ' . $e->getMessage();
185
        }
186
        return $html;
187
    }
188
189
    /**
190
     * Render an HTML view of the intervalGraph.
191
     *
192
     * @return string
193
     */
194
    public function draw(): string
195
    {
196
        if (!isset($this->values)) {
197
            $this->createView();
198
        }
199
200
        /** @noinspection PhpUnusedLocalVariableInspection */
201
        $vs = $this->values;
202
        ob_start();
203
        /** @noinspection PhpIncludeInspection */
204
        include $this->template;
205
206
        // Remove all surplus whitespace.
207
        return preg_replace(
208
            ['/(?<=>)\s+/', '/\s+(?=<)/', '/\s+/'], ['', '', ' '],
209
            ob_get_clean()
210
        );
211
    }
212
213
    /**
214
     * Process intervals and store processed values.
215
     *
216
     * @return IntervalGraph
217
     */
218
    public function createView(): IntervalGraph
219
    {
220
        $flatIntervals = $this->getFlatIntervals();
221
222
        // Extract values.
223
        $originalValues = array_column($flatIntervals, 2);
224
225
        $numVals = [];
226
227
        // Change bounds to numeric values.
228
        foreach ($flatIntervals as $interval) {
229
            $numVals[] = [
230
                ($this->boundToNumeric)($interval[0]),
231
                ($this->boundToNumeric)($interval[1]),
232
            ];
233
        }
234
235
        // Order by low bound.
236
        uasort($numVals, static function (array $i1, array $i2) {
237
            return ($i1[0] < $i2[0]) ? -1 : 1;
238
        });
239
240
        // Get the min bound value.
241
        $min = reset($numVals)[0];
242
243
        // Substract min from all bound values.
244
        foreach ($numVals as $key => $numVal) {
245
            $numVals[$key] = [
246
                $numVal[0] - $min,
247
                $numVal[1] - $min
248
            ];
249
        }
250
251
        // Order by high bound.
252
        uasort($numVals, static function (array $i1, array $i2) {
253
            return ($i1[1] < $i2[1]) ? -1 : 1;
254
        });
255
256
        // Get max timestamp.
257
        $max = end($numVals)[1];
258
259
        // Calculate percentages.
260
        foreach ($numVals as $i => $numVal) {
261
            foreach ($numVal as $j => $value) {
262
                $numVal[$j] = $max === 0 ? 0 : round($value * 100 / $max, 2);
263
            }
264
            $numVals[$i] = $numVal;
265
        }
266
267
        // Put values back in, along with the formatted bound.
268
        // Since we're using associative sorting functions, we know the keys haven't changed.
269
        $numKeys = array_keys($numVals);
270
        foreach ($numKeys as $numKey) {
271
272
            [$lowBound, $highBound] = $flatIntervals[$numKey];
273
            [$startPercent, $endPercent] = $numVals[$numKey];
274
275
            if ($lowBound === $highBound) {
276
277
                $numVals[$numKey] = [
278
                    $startPercent, // Single value position percentage
279
                    ($this->boundToString)($lowBound), // Single value string
280
                ];
281
282
            } else {
283
284
                $colval = isset($originalValues[$numKey]) ? ($this->valueToNumeric)($originalValues[$numKey]) : null;
285
                $strval = isset($originalValues[$numKey]) ? ($this->valueToString)($originalValues[$numKey]) : null;
286
287
                $numVals[$numKey] = [
288
                    $startPercent, // Interval start percentage
289
                    100 - $endPercent, // Interval end percentage from right
290
                    // Note: for some reason, using 'widht' instead of 'right'
291
                    // causes the right border to be hidden underneath the next interval.
292
                    !empty($originalValues) ? $this->palette->getColor($colval) : 50, // Interval color
293
                    ($this->boundToString)($lowBound), // Interval start string value
294
                    ($this->boundToString)($highBound), // Interval end string value
295
                    !empty($originalValues) ? $strval : null,// Interval string value
296
                ];
297
            }
298
        }
299
300
        // Put discrete values at the end and reset indices.
301
        // Reseting indices ensures the processed values are
302
        // serialized as correctly ordered JSON arrays.
303
        usort($numVals, static function ($i) {
304
            return count($i) === 2 ? 1 : -1;
305
        });
306
307
        $this->values = $numVals;
308
309
        return $this;
310
    }
311
312
    /**
313
     * Transform an array of intervals with possible overlapping
314
     * into an array of adjacent intervals with no overlapping.
315
     *
316
     * @return array
317
     */
318
    public function getFlatIntervals(): array
319
    {
320
        $discreteValues = self::extractDiscreteValues($this->intervals);
321
        $adjacentIntervals = $this->flattener->calcAdjacentIntervals($this->intervals);
322
323
        // Remove empty interval generated when two or more intervals share a common bound.
324
        $adjacentIntervals = array_values(array_filter($adjacentIntervals, static function ($i) {
325
            // Use weak comparison in case of object typed bounds.
326
            return $i[0] != $i[1];
327
        }));
328
329
        // Calculate aggregates after adjacent intervals.
330
        $agregated = $this->aggregator->aggregate($adjacentIntervals, $this->intervals);
331
332
        // Push discrete values back into the array.
333
        if (!empty($discreteValues)) {
334
            array_push($agregated, ...$discreteValues);
335
        }
336
337
        return $agregated;
338
    }
339
340
    /**
341
     * Extract discrete values from an array of intervals.
342
     *
343
     * Intervals with the exact same lower and higher bound will be considered as discrete values.
344
     *
345
     * They will be removed from the initial array, and returned in a separate array.
346
     *
347
     * @param array $intervals The initial array.
348
     * @return array An array containing only discrete values.
349
     */
350
    public static function extractDiscreteValues(array &$intervals): array
351
    {
352
        $discreteValues = array_filter($intervals, static function ($interval) {
353
            return $interval[0] === $interval[1];
354
        });
355
356
        $intervals = array_diff_key($intervals, $discreteValues);
357
358
        return $discreteValues;
359
    }
360
361
    /**
362
     * Compute the numeric values of interval bounds and values.
363
     *
364
     * @return array
365
     */
366
    public function computeNumericValues(): array
367
    {
368
        $intervals = $this->getFlatIntervals();
369
370
        // Extract interval values.
371
        $intervalValues = array_column($intervals, 2);
372
373
        // Convert bounds to numeric values.
374
        foreach ($intervals as $interval) {
375
            $numericIntervals[] = [
376
                ($this->boundToNumeric)($interval[0]),
377
                ($this->boundToNumeric)($interval[1]),
378
            ];
379
        }
380
381
        // Order by high bound.
382
        uasort($numericIntervals, static function (array $i1, array $i2) {
383
            return ($i1[1] < $i2[1]) ? -1 : 1;
384
        });
385
386
387
        // Put values back in, along with the formatted bound.
388
        // Since we're using associative sorting functions, we know the keys haven't changed.
389
        foreach (array_keys($numericIntervals) as $index => $numKey) {
390
391
            [$lowNumericBound, $highNumericBound] = $numericIntervals[$index];
392
393
            if ($lowNumericBound === $highNumericBound) {
394
395
                $numericIntervals[$index] = $lowNumericBound;
396
397
            } else {
398
399
                $numericIntervals[$index] = [
400
                    $lowNumericBound,
401
                    $highNumericBound,
402
                    $intervalValues[$index] ?: 0
403
                ];
404
405
            }
406
        }
407
408
        // Put discrete values at the end and reset indices.
409
        // Reseting indices ensures the processed values are
410
        // serialized as correctly ordered JSON arrays.
411
        usort($numericIntervals, static function ($i) {
412
            return !is_array($i) ? 1 : -1;
413
        });
414
415
        return $numericIntervals;
416
    }
417
418
    /**
419
     * Define the function to convert the interval values to a numeric value
420
     * in order to match them to a color on the palette.
421
     *
422
     * @param Closure $valueToNumeric
423
     * @return IntervalGraph
424
     */
425
    public function setValueToNumeric(Closure $valueToNumeric): IntervalGraph
426
    {
427
        $this->valueToNumeric = $valueToNumeric;
428
        return $this;
429
    }
430
431
    /**
432
     * Define the  function to convert the interval values to strings
433
     * in order to display them in the view.
434
     *
435
     * @param Closure $valueToString
436
     * @return IntervalGraph
437
     */
438
    public function setValueToString(Closure $valueToString): IntervalGraph
439
    {
440
        $this->valueToString = $valueToString;
441
        return $this;
442
    }
443
444
    /**
445
     * Set the function to convert interval bound values to string.
446
     *
447
     * @param Closure $boundToString
448
     * @return IntervalGraph
449
     */
450
    public function setBoundToString($boundToString): IntervalGraph
451
    {
452
        $this->boundToString = $boundToString;
453
        return $this;
454
    }
455
456
    /**
457
     * @return array
458
     */
459
    public function getIntervals(): array
460
    {
461
        return $this->intervals;
462
    }
463
464
    /**
465
     * Set the intervals to be processed.
466
     *
467
     * If another set of intervals was previously processed,
468
     * the processed values will be deleted.
469
     *
470
     * @param array $intervals
471
     * @return IntervalGraph
472
     */
473
    public function setIntervals(array $intervals): IntervalGraph
474
    {
475
        $this->intervals = $intervals;
476
        $this->values = null;
477
        return $this;
478
    }
479
480
    /**
481
     * @return array
482
     */
483
    public function getValues(): array
484
    {
485
        return $this->values;
486
    }
487
488
    /**
489
     * @return string
490
     */
491
    public function getTemplate(): string
492
    {
493
        return $this->template;
494
    }
495
496
    /**
497
     * Set the PHP template to use for rendering.
498
     *
499
     * @param string $template
500
     * @return IntervalGraph
501
     */
502
    public function setTemplate($template): IntervalGraph
503
    {
504
        $this->template = $template;
505
        return $this;
506
    }
507
508
    /**
509
     * @return Palette
510
     */
511
    public function getPalette(): Palette
512
    {
513
        return $this->palette;
514
    }
515
516
    /**
517
     * Set the Palette object to be used to determine colors.
518
     *
519
     * @param Palette $palette
520
     * @return IntervalGraph
521
     */
522
    public function setPalette($palette): IntervalGraph
523
    {
524
        $this->palette = $palette;
525
        return $this;
526
    }
527
528
    /**
529
     * @param Closure $boundToNumeric
530
     * @return IntervalGraph
531
     */
532
    public function setBoundToNumeric($boundToNumeric): IntervalGraph
533
    {
534
        $this->boundToNumeric = $boundToNumeric;
535
        return $this;
536
    }
537
538
    /**
539
     * Return the array of values to be serialized by json_encode.
540
     *
541
     * @return array
542
     */
543
    public function jsonSerialize(): array
544
    {
545
        if (!isset($this->values)) {
546
            $this->createView();
547
        }
548
        return $this->values;
549
    }
550
551
    /**
552
     * @return Aggregator
553
     */
554
    public function getAggregator(): Aggregator
555
    {
556
        return $this->aggregator;
557
    }
558
559
    /**
560
     * @param Aggregator $aggregator
561
     * @return IntervalGraph
562
     */
563
    public function setAggregator(Aggregator $aggregator): IntervalGraph
564
    {
565
        $this->aggregator = $aggregator;
566
        return $this;
567
    }
568
569
570
}
571