Completed
Push — master ( cb5f1c...5f5111 )
by Victor
03:46
created

IntervalGraph::getFlattener()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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