IntervalGraph::setFlattener()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 4
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 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 PaletteInterface */
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 ClassPalette();
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
     * Returns 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
     * The array can be returned by reference to be modified if needed.
221
     *
222
     * ⚠ Flat intervals will be purged when setting new original intervals.
223
     *
224
     * @return array[] Array of adjacent, non-overlapping intervals.
225
     */
226
    public function &getFlatIntervals(): array
227
    {
228
        if (empty($this->flattened)){
229
            $this->flattened = $this->flattener->flatten($this->intervals);
230
        }
231
        return $this->flattened;
232
    }
233
234
    /**
235
     * Return an array of adjacent, non-overlapping intervals with aggregated values.
236
     *
237
     * The array can be returned by reference to be modified if needed.
238
     *
239
     * ⚠ Aggregated intervals will be purged when setting new original intervals.
240
     *
241
     * @return array[] Array of adjacent, non-overlapping intervals with aggregated values.
242
     */
243
    public function &getAggregatedIntervals(): array
244
    {
245
        if (empty($this->aggregated)){
246
            $this->aggregated = $this->aggregator->aggregate(
247
                $this->getFlatIntervals(),
248
                $this->intervals
249
            );
250
        }
251
        return $this->aggregated;
252
    }
253
254
    /**
255
     * Process intervals and store processed values.
256
     *
257
     * @return IntervalGraph
258
     */
259
    public function createView(): IntervalGraph
260
    {
261
        $flatIntervals = $this->getAggregatedIntervals();
262
263
        // Extract values.
264
        $originalValues = array_column($flatIntervals, 2);
265
266
        $numVals = [];
267
268
        // Change bounds to numeric values.
269
        foreach ($flatIntervals as $interval) {
270
            $numVals[] = [
271
                ($this->boundToNumeric)($interval[0]),
272
                ($this->boundToNumeric)($interval[1]),
273
            ];
274
        }
275
276
        // Order by low bound.
277
        uasort(
278
            $numVals,
279
            static function (array $i1, array $i2) {
280
                return ($i1[0] < $i2[0]) ? -1 : 1;
281
            }
282
        );
283
284
        // Get the min bound value.
285
        $min = reset($numVals)[0];
286
287
        // Substract min from all bound values.
288
        foreach ($numVals as $key => $numVal) {
289
            $numVals[$key] = [
290
                $numVal[0] - $min,
291
                $numVal[1] - $min
292
            ];
293
        }
294
295
        // Order by high bound.
296
        uasort(
297
            $numVals,
298
            static function (array $i1, array $i2) {
299
                return ($i1[1] < $i2[1]) ? -1 : 1;
300
            }
301
        );
302
303
        // Get max timestamp.
304
        $max = end($numVals)[1];
305
306
        // Calculate percentages.
307
        foreach ($numVals as $i => $numVal) {
308
            foreach ($numVal as $j => $value) {
309
                $numVal[$j] = $max === 0 ? 0 : round($value * 100 / $max, 2);
310
            }
311
            $numVals[$i] = $numVal;
312
        }
313
314
        // Put values back in, along with the formatted bound.
315
        // Since we're using associative sorting functions, we know the keys haven't changed.
316
        $numKeys = array_keys($numVals);
317
        foreach ($numKeys as $numKey) {
318
            [$lowBound, $highBound] = $flatIntervals[$numKey];
319
            [$startPercent, $endPercent] = $numVals[$numKey];
320
321
            if ($lowBound === $highBound) {
322
                $numVals[$numKey] = [
323
                    $startPercent, // Single value position percentage
324
                    ($this->boundToString)($lowBound), // Single value string
325
                ];
326
            } else {
327
                $colval = isset($originalValues[$numKey]) ? ($this->valueToNumeric)($originalValues[$numKey]) : null;
328
                $strval = isset($originalValues[$numKey]) ? ($this->valueToString)($originalValues[$numKey]) : null;
329
330
                $numVals[$numKey] = [
331
                    $startPercent, // Interval start percentage
332
                    100 - $endPercent, // Interval end percentage from right
333
                    // Note: for some reason, using 'widht' instead of 'right'
334
                    // causes the right border to be hidden underneath the next interval.
335
                    !empty($originalValues) ? $this->palette->getColor($colval) : 50, // Interval color
336
                    ($this->boundToString)($lowBound), // Interval start string value
337
                    ($this->boundToString)($highBound), // Interval end string value
338
                    !empty($originalValues) ? $strval : null,// Interval string value
339
                ];
340
            }
341
        }
342
343
        // Put discrete values at the end and reset indices.
344
        // Reseting indices ensures the processed values are
345
        // serialized as correctly ordered JSON arrays.
346
        usort(
347
            $numVals,
348
            static function ($i) {
349
                return count($i) === 2 ? 1 : -1;
350
            }
351
        );
352
353
        $this->values = $numVals;
354
355
        return $this;
356
    }
357
358
    /**
359
     * Compute the numeric values of interval bounds and values.
360
     *
361
     * @return array
362
     */
363
    public function computeNumericValues(): array
364
    {
365
        $intervals = $this->getAggregatedIntervals();
366
367
        // Extract interval values.
368
        $intervalValues = array_column($intervals, 2);
369
        $numericIntervals = [];
370
        // Convert bounds to numeric values.
371
        foreach ($intervals as $interval) {
372
            $numericIntervals[] = [
373
                ($this->boundToNumeric)($interval[0]),
374
                ($this->boundToNumeric)($interval[1]),
375
            ];
376
        }
377
378
        // Order by high bound.
379
        uasort(
380
            $numericIntervals,
381
            static function (array $i1, array $i2) {
382
                return ($i1[1] < $i2[1]) ? -1 : 1;
383
            }
384
        );
385
386
387
        // Put values back in, along with the formatted bound.
388
        // Since we're using associative sorting functions, we know the keys haven't changed.
389
        foreach (array_keys($numericIntervals) as $index => $numKey) {
390
            [$lowNumericBound, $highNumericBound] = $numericIntervals[$index];
391
392
            if ($lowNumericBound === $highNumericBound) {
393
                $numericIntervals[$index] = $lowNumericBound;
394
            } else {
395
                $numericIntervals[$index] = [
396
                    $lowNumericBound,
397
                    $highNumericBound,
398
                    $intervalValues[$index] ?: 0
399
                ];
400
            }
401
        }
402
403
        // Put discrete values at the end and reset indices.
404
        // Reseting indices ensures the processed values are
405
        // serialized as correctly ordered JSON arrays.
406
        usort(
407
            $numericIntervals,
408
            static function ($i) {
409
                return !is_array($i) ? 1 : -1;
410
            }
411
        );
412
413
        return $numericIntervals;
414
    }
415
416
    /**
417
     * Define the function to convert the interval values to a numeric value
418
     * in order to match them to a color on the palette.
419
     *
420
     * @param Closure $valueToNumeric
421
     * @return IntervalGraph
422
     */
423
    public function setValueToNumeric(Closure $valueToNumeric): IntervalGraph
424
    {
425
        $this->valueToNumeric = $valueToNumeric;
426
        return $this;
427
    }
428
429
    /**
430
     * Define the  function to convert the interval values to strings
431
     * in order to display them in the view.
432
     *
433
     * @param Closure $valueToString
434
     * @return IntervalGraph
435
     */
436
    public function setValueToString(Closure $valueToString): IntervalGraph
437
    {
438
        $this->valueToString = $valueToString;
439
        return $this;
440
    }
441
442
    /**
443
     * Set the function to convert interval bound values to string.
444
     *
445
     * @param Closure $boundToString
446
     * @return IntervalGraph
447
     */
448
    public function setBoundToString(Closure $boundToString): IntervalGraph
449
    {
450
        $this->boundToString = $boundToString;
451
        return $this;
452
    }
453
454
    /**
455
     * @return array
456
     */
457
    public function getIntervals(): array
458
    {
459
        return $this->intervals;
460
    }
461
462
    /**
463
     * Set the intervals to be processed.
464
     *
465
     * ⚠ If another set of intervals was previously processed,
466
     * stored flat and aggregated intervals and processed values will be deleted.
467
     *
468
     * @param array $intervals
469
     * @return IntervalGraph
470
     */
471
    public function setIntervals(array $intervals): IntervalGraph
472
    {
473
        $this->intervals = $intervals;
474
        $this->flattened = [];
475
        $this->aggregated = [];
476
        $this->values = [];
477
        return $this;
478
    }
479
480
    /**
481
     * @return array
482
     */
483
    public function getValues(): array
484
    {
485
        return $this->values;
486
    }
487
488
    /**
489
     * @return string
490
     */
491
    public function getTemplate(): string
492
    {
493
        return $this->template;
494
    }
495
496
    /**
497
     * Set the PHP template to use for rendering.
498
     *
499
     * @param string $template
500
     * @return IntervalGraph
501
     */
502
    public function setTemplate(string $template): IntervalGraph
503
    {
504
        $this->template = $template;
505
        return $this;
506
    }
507
508
    /**
509
     * @return PaletteInterface
510
     */
511
    public function getPalette(): PaletteInterface
512
    {
513
        return $this->palette;
514
    }
515
516
    /**
517
     * Set the Palette object to be used to determine colors.
518
     *
519
     * @param PaletteInterface $palette
520
     * @return IntervalGraph
521
     */
522
    public function setPalette(PaletteInterface $palette): IntervalGraph
523
    {
524
        $this->palette = $palette;
525
        return $this;
526
    }
527
528
    /**
529
     * @param Closure $boundToNumeric
530
     * @return IntervalGraph
531
     */
532
    public function setBoundToNumeric(Closure $boundToNumeric): IntervalGraph
533
    {
534
        $this->boundToNumeric = $boundToNumeric;
535
        return $this;
536
    }
537
538
    /**
539
     * Return the array of values to be serialized by json_encode.
540
     *
541
     * @return array
542
     */
543
    public function jsonSerialize(): array
544
    {
545
        if (!isset($this->values)) {
546
            $this->createView();
547
        }
548
        return $this->values;
549
    }
550
551
    /**
552
     * @return AggregatorInterface
553
     */
554
    public function getAggregator(): AggregatorInterface
555
    {
556
        return $this->aggregator;
557
    }
558
559
    /**
560
     * @param AggregatorInterface $aggregator
561
     * @return IntervalGraph
562
     */
563
    public function setAggregator(AggregatorInterface $aggregator): IntervalGraph
564
    {
565
        $this->aggregator = $aggregator;
566
        return $this;
567
    }
568
569
}
570