Passed
Push — master ( 13b287...8ae221 )
by Victor
02:00 queued 12s
created

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