Completed
Push — master ( d8b05f...8a2992 )
by Jeff
02:59
created

Agenda::renderHoursColumn()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 20
rs 9.2
cc 4
eloc 14
nc 4
nop 1
1
<?php
2
3
namespace app\models\types;
4
5
use Yii;
6
use ICal\ICal;
7
use app\models\ContentType;
8
9
/**
10
 * This is the model class for Agenda content type.
11
 */
12
class Agenda extends ContentType
13
{
14
    const BASE_CACHE_TIME = 7200; // 2 hours
15
    const DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
16
    const HOUR_MIN = 8;
17
    const HOUR_MAX = 18;
18
19
    public $html = '<div class="agenda">%data%</div>';
20
    public $css = <<<'EO1'
21
%field% .agenda { width: 100%; height: 100%; text-align: center; background-color: white; }
22
%field% .agenda-header { font-weight: bold; }
23
%field% .agenda-contents { width: 100%; height: calc(100% - 1.3em); display: table; table-layout: fixed; border-collapse: collapse; }
24
%field% .agenda-time { display: table-cell; width: 2.2em;  border: solid 1px black; }
25
%field% .agenda-time-header { }
26
%field% .agenda-time-contents { width: 100%; height: calc(100% - 1.3em); display: table; position: relative; }
27
%field% .agenda-time-h { position: absolute; border-top: solid 1px black; width: 100%; }
28
%field% .agenda-time-m { position: absolute; border-top: dotted 1px black; right: 0; }
29
%field% .agenda-time-trace { position: absolute; border-top: dotted 1px gray; width: 100%; }
30
%field% .agenda-day { display: table-cell; border: solid 1px black; }
31
%field% .agenda-day-header { border-bottom: solid 1px black; }
32
%field% .agenda-day-contents { width: 100%; height: calc(100% - 1.3em); display: table; position: relative; }
33
%field% .agenda-event { position: absolute; overflow: hidden; border-bottom: solid 1px black; z-index: 10; }
34
%field% .agenda-event-desc { font-weight: bold; font-size: 1.1em; }
35
%field% .agenda-event-location { font-size: 1.1em; white-space: nowrap; }
36
%field% .agenda-event-name { word-break: break-all; display: block; }
37
EO1;
38
    public $input = 'url';
39
    public $output = 'raw';
40
    public $usable = true;
41
    public $exemple = '@web/images/agenda.preview.jpg';
42
    public $canPreview = true;
43
44
    private static $translit;
45
    private $color = [];
46
    private $opts;
47
    private $overlapScanOffset = 0.1;
48
    private $tz;
49
50
    /**
51
     * {@inheritdoc}
52
     */
53
    public function __construct($config = [])
54
    {
55
        parent::__construct($config);
56
        $this->name = Yii::t('app', 'Agenda');
57
        $this->description = Yii::t('app', 'Display an agenda from an ICal feed.');
58
    }
59
60
    /**
61
     * {@inheritdoc}
62
     */
63
    public function processData($data)
64
    {
65
        $agenda = self::fromCache($data);
66
        if (!$agenda) {
67
            $agenda = $this->genAgenda($data);
68
            if ($agenda !== null) {
69
                self::toCache($data, $agenda);
70
            }
71
        }
72
73
        return $agenda;
74
    }
75
76
    /**
77
     * Extract data from ical event.
78
     *
79
     * @param array $e ical event
80
     *
81
     * @return array parsed event
82
     */
83
    private function parseEvent($e)
84
    {
85
        // Convert timezones
86
        $start = (new \DateTime($e->dtstart))->setTimeZone($this->tz);
87
        $end = (new \DateTime($e->dtend))->setTimeZone($this->tz);
88
89
        // Event info
90
        $event = [
91
            'dow' => $start->format('w') - 1,
92
            'start' => $start->format('G') + ($start->format('i') / 60.0),
93
            'startStr' => $start->format('G:i'),
94
            'end' => $end->format('G') + ($end->format('i') / 60.0),
95
            'endStr' => $end->format('G:i'),
96
            'name' => self::filter($e->summary, 'name'),
97
            'locations' => self::arrayFilter(explode(',', $e->location), 'location'),
98
            'desc' => self::arrayFilter(explode(PHP_EOL, $e->description), 'description'),
99
        ];
100
        $event['duration'] = $event['end'] - $event['start'];
101
102
        return $event;
103
    }
104
105
    private function parseEvents($events)
0 ignored issues
show
Unused Code introduced by
The parameter $events is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
106
    {
107
        return $parsedEvents;
0 ignored issues
show
Bug introduced by
The variable $parsedEvents does not exist. Did you mean $events?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
108
    }
109
110
    /**
111
     * Read .ical data and parse to day-based array.
112
     *
113
     * @param string $data ical raw data
114
     *
115
     * @return array agenda
116
     */
117
    public function parseIcal($data)
118
    {
119
        // Init ICal parser
120
        $ical = new ICal();
121
        $ical->initString($data);
122
123
        // Retrieve event for this week only
124
        $events = $ical->eventsFromRange(self::DAYS[0].' this week', self::DAYS[count(self::DAYS) - 1].' this week 23:59');
125
126
        if (!is_array($events) || !count($events)) {
127
            return null;
128
        }
129
130
        // Use own timezone to display
131
        $this->tz = new \DateTimeZone(ini_get('date.timezone'));
132
        // Always transliterate text contents
133
        self::$translit = \Transliterator::create('Latin-ASCII');
134
        if (!self::$translit) {
135
            return null;
136
        }
137
138
        // Base agenda format info
139
        $info = [
140
            'minHour' => self::HOUR_MIN,
141
            'maxHour' => self::HOUR_MAX,
142
            'days' => [],
143
        ];
144
145
        $parsedEvents = [];
146
147
        foreach ($events as $event) {
148
            $e = $this->parseEvent($event);
149
150
            // Adjust agenda format based on events
151
            if ($e['start'] < $info['minHour']) {
152
                $info['minHour'] = $e['start'];
153
            }
154
            if ($e['end'] > $info['maxHour']) {
155
                $info['maxHour'] = $e['end'];
156
            }
157
158
            // Only add days with events
159
            if (!array_key_exists($e['dow'], $parsedEvents)) {
160
                $parsedEvents[$e['dow']] = [];
161
                $info['days'][$e['dow']] = $start->info('d/m');
0 ignored issues
show
Bug introduced by
The variable $start does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
162
            }
163
164
            $parsedEvents[$e['dow']][] = $e;
165
        }
166
167
        return ['info' => $info, 'events' => $parsedEvents];
168
    }
169
170
    /**
171
     * Loop through day events and tag with overlaps.
172
     *
173
     * @param array $events
174
     * @param int   $from   start hour
175
     * @param int   $to     end hour
176
     *
177
     * @return array tagged events
178
     */
179
    private function tagOverlaps($events, $from, $to)
180
    {
181
        // Scan each 0.1h for overlapping events
182
        for ($i = $from; $i <= $to; $i += $this->overlapScanOffset) {
183
            // $overlap is every overlapping event
184
            $overlap = [];
185
            foreach ($events as $k => $e) {
186
                if ($e['start'] < $i && $i < $e['end']) {
187
                    $overlap[] = $k;
188
                }
189
            }
190
191
            // $overlaps is maximum concurrent overlappings
192
            // Used to fix block width
193
            $overlaps = count($overlap);
194
195
            foreach ($events as $k => $e) {
196
                if ($e['start'] < $i && $i < $e['end']) {
197
                    if (!array_key_exists('overlaps', $e)) {
198
                        $e['overlaps'] = $overlaps;
199
                        $e['overlap'] = $overlap;
200
                    } else {
201
                        if ($overlaps >= $e['overlaps']) {
202
                            $e['overlaps'] = $overlaps;
203
                        }
204
                        // Merge overlap to always get full range of overlapping events
205
                        // Used to calculate block position
206
                        $e['overlap'] = array_unique(array_merge($e['overlap'], $overlap));
207
                    }
208
209
                    $events[$k] = $e;
210
                }
211
            }
212
        }
213
214
        return $events;
215
    }
216
217
    /**
218
     * Loop through day events and scan for open position.
219
     *
220
     * @param array $events
221
     *
222
     * @return array positionned blocks
223
     */
224
    private function positionBlocks($events)
225
    {
226
        foreach ($events as $k => $e) {
227
            if ($e['overlaps'] < 2) {
228
                // No overlap, easy mode
229
                $e['position'] = 0;
230
                $events[$k] = $e;
231
                continue;
232
            }
233
234
            if (array_key_exists('position', $e)) {
235
                // Position already set, don't touch
236
                continue;
237
            }
238
239
            // Find available spots for this event
240
            $spots = range(0, $e['overlaps'] - 1);
241
            $overlapCount = count($e['overlap']);
242
            for ($i = 0; $i < $overlapCount; ++$i) {
243
                $overlaped = $events[$e['overlap'][$i]];
244
                if (array_key_exists('position', $overlaped)) {
245
                    unset($spots[$overlaped['position']]);
246
                }
247
            }
248
249
            // Take first one
250
            $e['position'] = array_shift($spots);
251
252
            $events[$k] = $e;
253
        }
254
255
        return $events;
256
    }
257
258
    /**
259
     * Use agenda events data to build blocks for rendering.
260
     *
261
     * @param array $agenda
262
     *
263
     * @return array blocks
264
     */
265
    public function blockize($agenda)
266
    {
267
        $blocks = [];
268
269
        foreach ($agenda['events'] as $day => $events) {
270
            // Sort by desc first line
271
            usort($events, function ($a, $b) {
272
                return strcmp($a['desc'][0], $b['desc'][0]);
273
            });
274
275
            $events = $this->tagOverlaps($events, $agenda['info']['minHour'], $agenda['info']['maxHour']);
276
277
            $blocks[$day] = $this->positionBlocks($events);
278
        }
279
280
        return $blocks;
281
    }
282
283
    /**
284
     * Render agenda left column with hours.
285
     *
286
     * @param array $agenda
287
     *
288
     * @return string HTML column
289
     */
290
    private function renderHoursColumn($agenda)
291
    {
292
        $hourColumnIntervals = 0.25;
293
        $min = $agenda['info']['minHour'];
294
        $max = $agenda['info']['maxHour'];
295
        $len = $max - $min;
296
297
        $h = '<div class="agenda-time"><div class="agenda-time-header">&nbsp;</div><div class="agenda-time-contents">';
298
        for ($i = floor($min); $i < ceil($max); $i += $hourColumnIntervals) {
299
            if (fmod($i, 1) == 0) {
300
                $h .= '<div class="agenda-time-h" style="top: '.((($i - $min) / $len) * 100).'%;">'.$i.'h</div>';
301
            } else {
302
                $width = fmod($i, 0.5) == 0 ? 40 : 20;
303
                $h .= '<div class="agenda-time-m" style="top: '.((($i - $$min) / $len) * 100).'%; width: '.$width.'%;"></div>';
304
            }
305
        }
306
        $h .= '</div></div>';
307
308
        return $h;
309
    }
310
311
    /**
312
     * Render agenda events blocks columns.
313
     *
314
     * @param array $agenda
315
     *
316
     * @return string HTML blocks columns
317
     */
318
    private function renderEvents($agenda)
319
    {
320
        $hourIntervals = 1;
321
        $min = $agenda['info']['minHour'];
322
        $max = $agenda['info']['maxHour'];
323
        $len = $max - $min;
324
325
        $h = '';
326
        foreach ($agenda['events'] as $day => $events) {
327
            // Draw day header
328
            $h .= '<div class="agenda-day" id="day-'.$day.'">'.
329
                '<div class="agenda-day-header">'.\Yii::t('app', self::DAYS[$day]).' '.$agenda['info']['days'][$day].'</div>'.
330
                '<div class="agenda-day-contents">';
331
332
            // Draw events
333
            foreach ($events as $e) {
334
                $style = [
335
                    'top' => ((($e['start'] - $min) / $len) * 100).'%',
336
                    'bottom' => ((($max - $e['end']) / $len) * 100).'%',
337
                    'left' => ($e['position'] / $e['overlaps'] * 100).'%',
338
                    'right' => ((1 - ($e['position'] + 1) / $e['overlaps']) * 100).'%',
339
                    'background-color' => $this->getColor($e['desc'][0]),
340
                ];
341
                $styleStr = implode('; ', array_map(function ($k, $v) {
342
                    return $k.':'.$v;
343
                }, array_keys($style), $style));
344
345
                $content = [];
346 View Code Duplication
                if (count($e['desc']) && $e['desc'][0]) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
347
                    $content[] = '<span class="agenda-event-desc">'.$e['desc'][0].'</span>';
348
                }
349
                foreach ($e['locations'] as $l) {
350
                    $content[] = ' <span class="agenda-event-location">'.$l.'</span>';
351
                }
352
                if ($e['name']) {
353
                    $content[] = '<span class="agenda-event-name">'.$e['name'].'</span>';
354
                }
355 View Code Duplication
                if ($e['startStr'] && $e['endStr']) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
356
                    $content[] = '<br />'.$e['startStr'].' - '.$e['endStr'];
357
                }
358
359
                $h .= '<div class="agenda-event" style="'.$styleStr.'">'.implode('', $content).'</div>';
360
            }
361
362
            // Draw background hour traces
363
            for ($i = floor($min) + 1; $i < ceil($max); $i += $hourIntervals) {
364
                $h .= '<div class="agenda-time-trace" style="top: '.((($i - $min) / $len) * 100).'%;"></div>';
365
            }
366
367
            $h .= '</div></div>';
368
        }
369
370
        return $h;
371
    }
372
373
    /**
374
     * Render agenda to HTML.
375
     *
376
     * @param array $agenda
377
     *
378
     * @return string HTML result
379
     */
380
    public function render($agenda)
381
    {
382
        $h = '<div class="agenda-header">%name%</div><div class="agenda-contents">';
383
384
        $h .= $this->renderHoursColumn($agenda);
385
386
        $h .= $this->renderEvents($agenda);
387
388
        $h .= '</div>';
389
390
        return $h;
391
    }
392
393
    /**
394
     * Generate agenda HTML from .ical url.
395
     *
396
     * @param string $url ical url
397
     *
398
     * @return string|null HTML agenda
399
     */
400
    public function genAgenda($url)
401
    {
402
        $this->opts = \Yii::$app->params['agenda'];
403
404
        $content = self::downloadContent($url);
405
406
        $agenda = $this->parseIcal($content);
407
        if (!$agenda) {
408
            return null;
409
        }
410
411
        $agenda['events'] = $this->blockize($agenda);
412
413
        return $this->render($agenda);
414
    }
415
416
    /**
417
     * Apply self::filter() to each array member.
418
     *
419
     * @param array  $arr  input
420
     * @param string $type array type
421
     *
422
     * @return array filtered output
423
     */
424
    private static function arrayFilter(array $arr, $type)
425
    {
426
        $res = [];
427
        foreach ($arr as $v) {
428
            $res[] = self::filter($v, $type);
429
        }
430
431
        return array_values(array_filter($res));
432
    }
433
434
    /**
435
     * Filter string from feed.
436
     *
437
     * @param string $str  input string
438
     * @param string $type string type
439
     *
440
     * @return string filtered string
441
     */
442
    private static function filter($str, $type)
443
    {
444
        $str = html_entity_decode($str);
445
446
        if (self::$translit) {
447
            $str = self::$translit->transliterate($str);
448
        }
449
450
        $str = preg_replace([
451
            '/\s{2,}/',
452
            '/\s*\\\,\s*/',
453
            '/\s*\([^\)]*\)/',
454
        ], [
455
            ' ',
456
            ', ',
457
            '',
458
        ], trim($str));
459
460
        switch ($type) {
461
            case 'name':
462
                return preg_replace([
463
                    '/^\d+\s*-/',
464
                    '/^\d+\s+/',
465
                ], [
466
                    '',
467
                    '',
468
                ], $str);
469
            case 'location':
470
                return preg_replace([
471
                    '/(\d) (\d{3}).*/',
472
                ], [
473
                    '\\1-\\2',
474
                ], $str);
475
            case 'description':
476
                return preg_replace([
477
                    '/(modif).*/',
478
                ], [
479
                    '',
480
                ], $str);
481
            default:
482
                return $str;
483
        }
484
    }
485
486
    /**
487
     * Generate color based on string
488
     * Using MD5 to always get the same color for a given string.
489
     *
490
     * @param string $str
491
     *
492
     * @return string color hexcode
493
     */
494
    private function getColor($str)
495
    {
496
        if (array_key_exists($str, $this->color)) {
497
            return $this->color[$str];
498
        }
499
500
        // %140 + 95 make colors brighter
501
        $hash = md5($str);
502
        $this->color[$str] = sprintf(
503
            '#%X%X%X',
504
            hexdec(substr($hash, 0, 2)) % 140 + 95,
505
            hexdec(substr($hash, 2, 2)) % 140 + 95,
506
            hexdec(substr($hash, 4, 2)) % 140 + 95
507
        );
508
509
        return $this->color[$str];
510
    }
511
}
512