Completed
Push — master ( d228f2...cb5f1c )
by Victor
07:17
created

IntervalGraph::calcAdjacentIntervals()   C

Complexity

Conditions 13
Paths 34

Size

Total Lines 70
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 13
eloc 36
c 0
b 0
f 0
nc 34
nop 1
dl 0
loc 70
rs 6.6166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 = static function (DateTime $bound) {
64
            return $bound->getTimestamp();
65
        };
66
67
        $this->boundToString = static function (DateTime $bound) {
68
            return $bound->format('Y-m-d');
69
        };
70
71
        $this->valueToNumeric = static function ($v) {
72
            return $v === null ? null : (int)($v * 100);
73
        };
74
75
        $this->valueToString = static function ($v) {
76
            return $v === null ? null : ($v * 100 . '%');
77
        };
78
79
        $this->aggregateFunction = static 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(): self
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(): string
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(): IntervalGraph
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, static 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, static 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] = $max === 0 ? 0 : round($value * 100 / $max, 2);
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
            [$lowBound, $highBound] = $flatIntervals[$numKey];
296
            [$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, static 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(): array
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, static 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): array
372
    {
373
        $discreteValues = array_filter($intervals, static 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): array
402
    {
403
        $bounds = [];
404
        foreach ($intervals as $key => $interval) {
405
            // TODO Get included boolean from interval bound.
406
            $bounds[] = [$interval[1], '-', true, $key, $interval[2] ?? null];
407
            $bounds[] = [$interval[0], '+', true, $key, $interval[2] ?? null];
408
        }
409
        // Order the bounds.
410
        usort($bounds, static 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): array
423
    {
424
        $origIntVals = [];
425
            
426
        // Get the values of the original intervals, including nulls.
427
        foreach ($this->intervals as $interval) {
428
            $origIntVals[] = $interval[2] ?? null;
429
        }
430
431
        $newIntervals = [];
432
        $activeIntervals = [];
433
434
        $boundsCount = count($bounds);
435
        
436
        // Create new intervals for each set of two consecutive bounds,
437
        // and calculate its total value.
438
        for ($i = 1; $i < $boundsCount; $i++) {
439
440
            // Set the current bound.
441
            [$curBoundValue, $curBoundType, $curBoundIncluded, $curBoundIntervalKey] = $bounds[$i - 1];
442
            [$nextBoundValue, $nextBoundType, $nextBoundIncluded] = $bounds[$i];
443
444
            if ($curBoundType === '+') {
445
                // If this is a low bound,
446
                // add the key of the interval to the array of active intervals.
447
                $activeIntervals[$curBoundIntervalKey] = true;
448
            } else {
449
                // If this is an high bound, remove the key.
450
                unset($activeIntervals[$curBoundIntervalKey]);
451
            }
452
453
            if (empty($activeIntervals)) {
454
                // If no intervals are active on this bound,
455
                // the value of this interval is null.
456
                $intervalValue = null;
457
            } else {
458
                // Else, aggregate the values of the corresponding intervals.
459
                $intervalValue = array_reduce(
460
                    array_intersect_key($origIntVals, $activeIntervals),
461
                    $this->aggregateFunction
462
                );
463
            }
464
            
465
            if (
466
                isset($this->addStep, $this->substractStep) && (
467
                    ($nextBoundIncluded && $nextBoundType === '+')
468
                    || (!$nextBoundIncluded && $nextBoundType === '+')
469
                )
470
            ) {
471
                $newHighBound = ($this->substractStep)($nextBoundValue);
472
            } else {
473
                $newHighBound = $nextBoundValue;
474
            }
475
            
476
            if (
477
                isset($this->addStep, $this->substractStep) && $curBoundType === '-' && $curBoundIncluded
478
            ) {
479
                $newLowBound = ($this->addStep)($curBoundValue);
480
            } else {
481
                $newLowBound = $curBoundValue;
482
            }
483
484
            $newIntervals[] = [
485
                $newLowBound,
486
                $newHighBound,
487
                $intervalValue
488
            ];
489
        }
490
491
        return $newIntervals;
492
    }
493
494
    /**
495
     * Compute the numeric values of interval bounds and values.
496
     * 
497
     * @return array
498
     */
499
    public function computeNumericValues(): array
500
    {
501
        $intervals = $this->getFlatIntervals();
502
        
503
        // Extract interval values.
504
        $intervalValues = array_column($intervals, 2);
505
506
        // Convert bounds to numeric values.
507
        foreach ($intervals as $interval) {
508
            $numericIntervals[] = [
509
                ($this->boundToNumeric)($interval[0]),
510
                ($this->boundToNumeric)($interval[1]),
511
            ];
512
        }
513
514
        // Order by high bound.
515
        uasort($numericIntervals, static function (array $i1, array $i2) {
516
            return ($i1[1] < $i2[1]) ? -1 : 1;
517
        });
518
        
519
520
        // Put values back in, along with the formatted bound.
521
        // Since we're using associative sorting functions, we know the keys haven't changed.
522
        foreach (array_keys($numericIntervals) as $index => $numKey) {
523
            
524
            [$lowNumericBound, $highNumericBound] = $numericIntervals[$index];
525
526
            if ($lowNumericBound === $highNumericBound) {
527
                
528
                $numericIntervals[$index] = $lowNumericBound;
529
                
530
            } else {
531
                
532
                $numericIntervals[$index] = [
533
                    $lowNumericBound,
534
                    $highNumericBound,
535
                    $intervalValues[$index] ?: 0
536
                ];
537
                
538
            }
539
        }
540
541
        // Put discrete values at the end and reset indices.
542
        // Reseting indices ensures the processed values are
543
        // serialized as correctly ordered JSON arrays.
544
        usort($numericIntervals, static function ($i) {
545
            return !is_array($i) ? 1 : -1;
546
        });
547
548
        return $numericIntervals;
549
    }
550
551
    /**
552
     * Define the function to convert the interval values to a numeric value
553
     * in order to match them to a color on the palette.
554
     *
555
     * @param Closure $valueToNumeric
556
     * @return IntervalGraph
557
     */
558
    public function setValueToNumeric(Closure $valueToNumeric): IntervalGraph
559
    {
560
        $this->valueToNumeric = $valueToNumeric;
561
        return $this;
562
    }
563
564
    /**
565
     * Define the  function to convert the interval values to strings
566
     * in order to display them in the view.
567
     *
568
     * @param Closure $valueToString
569
     * @return IntervalGraph
570
     */
571
    public function setValueToString(Closure $valueToString): IntervalGraph
572
    {
573
        $this->valueToString = $valueToString;
574
        return $this;
575
    }
576
577
    /**
578
     * Define the function to aggregate interval values.
579
     *
580
     * @param Closure $aggregate
581
     * @return IntervalGraph
582
     */
583
    public function setAggregate(Closure $aggregate): IntervalGraph
584
    {
585
        $this->aggregateFunction = $aggregate;
586
        return $this;
587
    }
588
589
    /**
590
     * Set the function to convert interval bound values to string.
591
     *
592
     * @param Closure $boundToString
593
     * @return IntervalGraph
594
     */
595
    public function setBoundToString($boundToString): IntervalGraph
596
    {
597
        $this->boundToString = $boundToString;
598
        return $this;
599
    }
600
601
    /**
602
     * @return array
603
     */
604
    public function getIntervals(): array
605
    {
606
        return $this->intervals;
607
    }
608
609
    /**
610
     * Set the intervals to be processed.
611
     *
612
     * If another set of intervals was previously processed,
613
     * the processed values will be deleted.
614
     *
615
     * @param array $intervals
616
     * @return IntervalGraph
617
     */
618
    public function setIntervals(array $intervals): IntervalGraph
619
    {
620
        $this->intervals = $intervals;
621
        $this->values = null;
622
        return $this;
623
    }
624
625
    /**
626
     * @return array
627
     */
628
    public function getValues(): array
629
    {
630
        return $this->values;
631
    }
632
633
    /**
634
     * @return string
635
     */
636
    public function getTemplate(): string
637
    {
638
        return $this->template;
639
    }
640
641
    /**
642
     * Set the PHP template to use for rendering.
643
     *
644
     * @param string $template
645
     * @return IntervalGraph
646
     */
647
    public function setTemplate($template): IntervalGraph
648
    {
649
        $this->template = $template;
650
        return $this;
651
    }
652
653
    /**
654
     * @return Palette
655
     */
656
    public function getPalette(): Palette
657
    {
658
        return $this->palette;
659
    }
660
661
    /**
662
     * Set the Palette object to be used to determine colors.
663
     * 
664
     * @param Palette $palette
665
     * @return IntervalGraph
666
     */
667
    public function setPalette($palette): IntervalGraph
668
    {
669
        $this->palette = $palette;
670
        return $this;
671
    }
672
673
    /**
674
     * @param Closure $boundToNumeric
675
     * @return IntervalGraph
676
     */
677
    public function setBoundToNumeric($boundToNumeric): IntervalGraph
678
    {
679
        $this->boundToNumeric = $boundToNumeric;
680
        return $this;
681
    }
682
683
    /**
684
     * Return the array of values to be serialized by json_encode.
685
     *
686
     * @return array
687
     */
688
    public function jsonSerialize(): array
689
    {
690
        if (!isset($this->values)) {
691
            $this->createView();
692
        }
693
        return $this->values;
694
    }
695
}