Passed
Push — master ( 2af71a...76363e )
by Victor
02:41
created

IntervalGraph::intervalsToSignedDates()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 7
nc 5
nop 1
dl 0
loc 12
rs 9.6111
c 0
b 0
f 0

1 Method

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