Completed
Push — master ( 761598...d09c54 )
by Victor
02:43
created

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