1<?php
2
3namespace dokuwiki\plugin\structgantt\meta;
4
5use dokuwiki\plugin\struct\meta\Column;
6use dokuwiki\plugin\struct\meta\SearchConfig;
7use dokuwiki\plugin\struct\meta\StructException;
8use dokuwiki\plugin\struct\meta\Value;
9use dokuwiki\plugin\struct\types\Color;
10use dokuwiki\plugin\struct\types\Date;
11use dokuwiki\plugin\struct\types\DateTime;
12
13class Gantt
14{
15
16    /** @var string the Type of renderer used */
17    protected $mode;
18
19    /** @var \Doku_Renderer the DokuWiki renderer used to create the output */
20    protected $renderer;
21
22    /** @var SearchConfig the configured search - gives access to columns etc. */
23    protected $searchConfig;
24
25    /** @var Column[] the list of columns to be displayed */
26    protected $columns;
27
28    /** @var  Value[][] the search result */
29    protected $result;
30
31    /** @var int number of all results */
32    protected $resultCount;
33
34    /** @var string[] the result PIDs for each row */
35    protected $resultPIDs;
36
37    /** @var int column number containing the start date */
38    protected $colrefStart = -1;
39
40    /** @var int column number containing the end date */
41    protected $colrefEnd = -1;
42
43    /** @var int column number containing the color */
44    protected $colrefColor = -1;
45
46    /** @var int column number containing the label */
47    protected $labelRef = -1;
48
49    /** @var int column number containing the title */
50    protected $titleRef = -1;
51
52    /** @var  string first date */
53    protected $minDate;
54
55    /** @var  string last date */
56    protected $maxDate;
57
58    /** @var  \DateTime[] all the days */
59    protected $days;
60
61    /** @var  int number of days */
62    protected $daynum;
63
64    /** @var  bool do not show saturday and sunday */
65    protected $skipWeekends;
66
67    /** @var string[] interval formats used */
68    protected $interval = [
69        'header' => 'j', // shown in header
70        'period' => 'P1D', // smallest shown interval
71        'next' => '+1 day', // one more interval
72        'short' => 'q', // interval label
73        'long' => 'Y-m-d', // interval long label
74        'comp' => 'Y-m-d', // sortable interval
75    ];
76
77    /**
78     * Initialize the Aggregation renderer and executes the search
79     *
80     * You need to call @param string $id
81     * @param string $mode
82     * @param \Doku_Renderer $renderer
83     * @param SearchConfig $searchConfig
84     * @see render() on the resulting object.
85     *
86     */
87    public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig)
88    {
89        $this->mode = $mode;
90        $this->renderer = $renderer;
91        $this->searchConfig = $searchConfig;
92        $this->columns = $searchConfig->getColumns();
93        $this->result = $this->searchConfig->execute();
94        $this->resultCount = $this->searchConfig->getCount();
95
96        $conf = $searchConfig->getConf();
97        $this->skipWeekends = $conf['skipweekends'] ?? false;
98
99        $this->initColumnRefs();
100        $this->initMinMax();
101    }
102
103    /**
104     * Figure out which columns will be used for dates and color
105     *
106     * The first date column is the start, the second is the end
107     *
108     * @todo suport Lookups pointing to dates and colors
109     * @todo handle multi columns
110     */
111    protected function initColumnRefs()
112    {
113        $ref = 0;
114        foreach ($this->columns as $column) {
115            if (
116                is_a($column->getType(), Date::class) ||
117                is_a($column->getType(), DateTime::class)
118            ) {
119                if ($this->colrefStart == -1) {
120                    $this->colrefStart = $ref;
121                } else {
122                    $this->colrefEnd = $ref;
123                }
124            } elseif (is_a($column->getType(), Color::class)) {
125                $this->colrefColor = $ref;
126            } else {
127                if ($this->labelRef == -1) {
128                    $this->labelRef = $ref;
129                } else {
130                    if ($this->titleRef == -1) {
131                        $this->titleRef = $ref;
132                    }
133                }
134            }
135            $ref++;
136        }
137
138        if ($this->colrefStart === -1 || $this->colrefEnd === -1) {
139            throw new StructException('Not enough Date columns selected');
140        }
141
142        if ($this->labelRef === -1) {
143            throw new StructException('No label column found');
144        }
145
146        if ($this->titleRef === -1) {
147            $this->titleRef = $this->labelRef;
148        }
149    }
150
151    /**
152     * Figure out the minimum and maximum dates and number of days inbetween
153     *
154     * @throws StructException when the range is not at least two days
155     */
156    protected function initMinMax()
157    {
158        $min = PHP_INT_MAX;
159        $max = 0;
160
161        /** @var Value[] $row */
162        foreach ($this->result as $row) {
163            $start = $row[$this->colrefStart]->getCompareValue();
164            $start = explode(' ', $start); // cut off time
165            $start = array_shift($start);
166            if ($start && $start < $min) $min = $start;
167            if ($start && $start > $max) $max = $start;
168
169            $end = $row[$this->colrefEnd]->getCompareValue();
170            $end = explode(' ', $end); // cut off time
171            $end = array_shift($end);
172            if ($end && $end < $min) $min = $end;
173            if ($end && $end > $max) $max = $end;
174        }
175
176        $daynum = (new \DateTime($min))->diff(new \DateTime($max))->days;
177        if ($daynum <= 1) {
178            throw new StructException('Not enough variation in dates to create a range');
179        }
180
181        // define the resolution
182        if ($daynum < 14) {
183            $this->interval = [
184                'header' => 'j', // days
185                'period' => 'P1D',
186                'next' => '+1 day',
187                'short' => '\\\\q',
188                'long' => 'Y-m-d',
189                'comp' => 'Y-m-d'
190            ];
191        } elseif ($daynum < 52) {
192            $this->interval = [
193                'header' => '\wW', // week numbers
194                'period' => 'P1D',
195                'next' => '+1 day',
196                'short' => '\\\\q',
197                'long' => 'Y-m-d',
198                'comp' => 'Y-m-d',
199            ];
200        } elseif ($daynum < 360) {
201            $this->interval = [
202                'header' => 'F', // months
203                'period' => 'P1D',
204                'next' => '+1 day',
205                'short' => '\\\\q',
206                'long' => 'Y-m-d',
207                'comp' => 'Y-m-d',
208            ];
209        } elseif ($daynum < 600) {
210            $this->interval = [
211                'header' => 'M \'y', // months and year
212                'period' => 'P1W', // weeks
213                'next' => '+1 week',
214                'short' => '\wW',
215                'long' => '\wW o',
216                'comp' => 'o-W',
217            ];
218            $this->skipWeekends = false;
219        } else {
220            $this->interval = [
221                'header' => '\\\\Q \'y', // quarter and year
222                'period' => 'P1M', // months
223                'next' => '+1 month',
224                'short' => 'M',
225                'long' => 'F Y',
226                'comp' => 'Y-m',
227            ];
228            $this->skipWeekends = false;
229        }
230
231        $this->minDate = $min;
232        $this->maxDate = $max;
233        $this->days = $this->listDays($min, $max);
234        $this->daynum = $daynum;
235    }
236
237    /**
238     * Output the chart
239     */
240    public function render()
241    {
242        if ($this->mode !== 'xhtml') {
243            $this->renderer->cdata('no other renderer than xhtml supported for struct gantt');
244            return;
245        }
246
247        $this->renderer->doc .= '<div class="table">';
248        $this->renderer->doc .= '<table class="plugin_structgantt">';
249        $this->renderer->doc .= '<thead>';
250        $this->renderHeaders();
251        $this->renderer->doc .= '</thead>';
252        $this->renderer->doc .= '<tbody>';
253        foreach ($this->result as $row) {
254            $this->renderRow($row);
255        }
256        $this->renderer->doc .= '</tbody>';
257        $this->renderer->doc .= '<tfoot>';
258        $this->renderDayRow();
259        $this->renderer->doc .= '</tfoot>';
260        $this->renderer->doc .= '</table>';
261        $this->renderer->doc .= '</div>';
262    }
263
264    /**
265     * Get the color to use in this row
266     *
267     * @param Value[] $row
268     * @return string
269     */
270    protected function getColorStyle($row)
271    {
272        if ($this->colrefColor === -1) return '';
273        $color = $row[$this->colrefColor]->getValue();
274        $conf = $row[$this->colrefColor]->getColumn()->getType()->getConfig();
275        if ($color == $conf['default']) return '';
276        return 'style="background-color:' . $color . '"';
277    }
278
279    /**
280     * Render the headers
281     *
282     * Automatically decides on the scale
283     */
284    protected function renderHeaders()
285    {
286        $headers = $this->makeHeaders($this->minDate, $this->maxDate);
287
288        $this->renderer->doc .= '<tr>';
289        $this->renderer->doc .= '<th></th>';
290        foreach ($headers as $name => $days) {
291            $this->renderer->doc .= '<th colspan="' . $days . '">' . $name . '</th>';
292        }
293        $this->renderer->doc .= '</tr>';
294        $this->renderDayRow();
295    }
296
297    /**
298     * Render a row for the days and the today pointer
299     */
300    protected function renderDayRow()
301    {
302        $today = new \DateTime();
303        $this->renderer->doc .= '<tr class="days">';
304        $this->renderer->doc .= '<th></th>';
305        foreach ($this->days as $day) {
306            if ($day->format($this->interval['long']) == $today->format($this->interval['long'])) {
307                $class = 'today';
308            } else {
309                $class = '';
310            }
311            $text = $this->intervalFormat($day, 'short');
312            $title = $this->intervalFormat($day, 'long');
313            $this->renderer->doc .= '<td title="' . $title . '" class="' . $class . '">' . $text . '</td>';
314        }
315        $this->renderer->doc .= '</tr>';
316    }
317
318    /**
319     * Render one row in the  diagram
320     *
321     * @param Value[] $row
322     */
323    protected function renderRow($row)
324    {
325        $start = $row[$this->colrefStart]->getCompareValue();
326        $end = $row[$this->colrefEnd]->getCompareValue();
327
328        if ($start && $end) {
329            $r1 = $this->listDays($this->minDate, $start);
330            $r2 = $this->listDays($start, $end);
331            $r3 = $this->listDays($end, $this->maxDate);
332
333            while ($r1 && ($this->intervalFormat(end($r1), 'comp') >= $this->intervalFormat($r2[0], 'comp'))) {
334                array_pop($r1);
335            }
336            while ($r3 && ($this->intervalFormat($r3[0], 'comp') <= $this->intervalFormat(end($r2), 'comp'))) {
337                array_shift($r3);
338            }
339        } else {
340            $r1 = $this->days;
341            $r2 = 0;
342            $r3 = 0;
343        }
344
345        // header
346        $this->renderer->doc .= '<tr>';
347        $this->renderer->doc .= '<th>';
348        $row[$this->labelRef]->render($this->renderer, $this->mode);
349        $this->renderer->doc .= '</th>';
350
351        // period before the task
352        foreach ($r1 as $day) {
353            $this->renderer->doc .= '<td title="' . $this->intervalFormat($day, 'long') . '"></td>';
354        }
355
356        // the task itself
357        if ($r2) {
358            $style = $this->getColorStyle($row);
359            $this->renderer->doc .= '<td colspan="' . count($r2) . '" class="task" ' . $style . '>';
360            $row[$this->titleRef]->render($this->renderer, $this->mode);
361
362            $this->renderer->doc .= '<dl class="flyout">';
363            foreach ($row as $value) {
364                $this->renderer->doc .= '<dd>';
365                $value->render($this->renderer, $this->mode);
366                $this->renderer->doc .= '<dd>';
367
368            }
369            $this->renderer->doc .= '</dl>';
370
371            $this->renderer->doc .= '</td>';
372        }
373
374        // period after the task
375        foreach ($r3 as $day) {
376            $this->renderer->doc .= '<td title="' . $this->intervalFormat($day, 'long') . '"></td>';
377        }
378
379        $this->renderer->doc .= '</tr>';
380    }
381
382    /**
383     * Returns the interval units in the given period
384     *
385     * @fixme currently it's still called days, but may actually use weeks or months
386     * @link based on http://stackoverflow.com/a/31046319/172068
387     * @param string $start as YYYY-MM-DD
388     * @param string $end as YYYY-MM-DD
389     * @return \DateTime[]
390     */
391    protected function listDays($start, $end)
392    {
393        if ($start > $end) list($start, $end) = array($end, $start);
394        $days = array();
395
396        $period = new \DatePeriod(
397            new \DateTime($start),
398            new \DateInterval($this->interval['period']),
399            (new \DateTime($end))->modify($this->interval['next']) // Include End Date (flag is only available in PHP8)
400        );
401
402        /** @var \DateTime $date */
403        foreach ($period as $date) {
404            if ($this->skipWeekends && (int)$date->format('N') >= 6) {
405                continue;
406            } else {
407                $days[] = $date;
408            }
409        }
410
411        return $days;
412    }
413
414    /**
415     * Returns the headers
416     *
417     * @param string $start as YYYY-MM-DD
418     * @param string $end as YYYY-MM-DD
419     * @return array
420     */
421    protected function makeHeaders($start, $end)
422    {
423        if ($start > $end) list($start, $end) = array($end, $start);
424        $headers = array();
425
426        $period = new \DatePeriod(
427            new \DateTime($start),
428            new \DateInterval($this->interval['period']),
429            (new \DateTime($end))->modify($this->interval['next']) // Include End Date (flag is only available in PHP8)
430        );
431
432        /** @var \DateTime $date */
433        foreach ($period as $date) {
434            if ($this->skipWeekends && (int)$date->format('N') >= 6) {
435                continue;
436            } else {
437                $ident = $this->intervalFormat($date, 'header');
438                if (!isset($headers[$ident])) {
439                    $headers[$ident] = 1;
440                } else {
441                    $headers[$ident]++;
442                }
443            }
444        }
445
446        return $headers;
447    }
448
449    /**
450     * Wrapper around DateTime->format() to implement our own placeholders
451     *
452     * @param \DateTime $date
453     * @param string $formatname
454     * @return string
455     */
456    protected function intervalFormat(\DateTime $date, $formatname)
457    {
458        $format = $this->interval[$formatname];
459        $label = $date->format($format);
460        return str_replace(
461            [
462                '\Q', // quarter of the year
463                '\q', // first letter of the day
464            ],
465            [
466                'Q' . ceil($date->format('n') / 3),
467                substr($date->format('l'), 0, 1),
468            ],
469            $label
470        );
471    }
472}
473