Passed
Push — master ( 35c30e...574e45 )
by Victor
03:48
created

IntervalGraph::getFlatIntervals()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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