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    /** @var string the Type of renderer used */
16    protected $mode;
17
18    /** @var \Doku_Renderer the DokuWiki renderer used to create the output */
19    protected $renderer;
20
21    /** @var SearchConfig the configured search - gives access to columns etc. */
22    protected $searchConfig;
23
24    /** @var Column[] the list of columns to be displayed */
25    protected $columns;
26
27    /** @var  Value[][] the search result */
28    protected $result;
29
30    /** @var int number of all results */
31    protected $resultCount;
32
33    /** @var string[] the result PIDs for each row */
34    protected $resultPIDs;
35
36    /** @var int column number containing the start date */
37    protected $colrefStart = -1;
38
39    /** @var int column number containing the end date */
40    protected $colrefEnd = -1;
41
42    /** @var int column number containing the color */
43    protected $colrefColor = -1;
44
45    /** @var int column number containing the label */
46    protected $labelRef = -1;
47
48    /** @var int column number containing the title */
49    protected $titleRef = -1;
50
51    /** @var  string first date */
52    protected $minDate;
53
54    /** @var  string last date */
55    protected $maxDate;
56
57    /** @var  \DateTime[] all the days */
58    protected $days;
59
60    /** @var  int number of days */
61    protected $daynum;
62
63    /** @var int the scaling to use */
64    protected $scale = 1;
65
66    /** @var  bool do not show saturday and sunday */
67    protected $skipWeekends;
68
69    /**
70     * Initialize the Aggregation renderer and executes the search
71     *
72     * You need to call @see render() on the resulting object.
73     *
74     * @param string $id
75     * @param string $mode
76     * @param \Doku_Renderer $renderer
77     * @param SearchConfig $searchConfig
78     */
79    public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig) {
80        $this->mode = $mode;
81        $this->renderer = $renderer;
82        $this->searchConfig = $searchConfig;
83        $this->columns = $searchConfig->getColumns();
84        $this->result = $this->searchConfig->execute();
85        $this->resultCount = $this->searchConfig->getCount();
86
87        $conf = $searchConfig->getConf();
88        $this->skipWeekends = $conf['skipweekends'];
89
90        $this->initColumnRefs();
91        $this->initMinMax();
92    }
93
94    /**
95     * Figure out which columns will be used for dates and color
96     *
97     * The first date column is the start, the second is the end
98     *
99     * @todo suport Lookups pointing to dates and colors
100     * @todo handle multi columns
101     */
102    protected function initColumnRefs() {
103        $ref = 0;
104        foreach($this->columns as $column) {
105            if(
106                is_a($column->getType(), Date::class) ||
107                is_a($column->getType(), DateTime::class)
108            ) {
109                if($this->colrefStart == -1) {
110                    $this->colrefStart = $ref;
111                } else {
112                    $this->colrefEnd = $ref;
113                }
114            } elseif(is_a($column->getType(), Color::class)) {
115                $this->colrefColor = $ref;
116            } else if($this->labelRef == -1) {
117                $this->labelRef = $ref;
118            } else if($this->titleRef == -1) {
119                $this->titleRef = $ref;
120            }
121            $ref++;
122        }
123
124        if($this->colrefStart === -1 || $this->colrefEnd === -1) {
125            throw new StructException('Not enough Date columns selected');
126        }
127
128        if($this->labelRef === -1) {
129            throw new StructException('No label column found');
130        }
131
132        if($this->titleRef === -1) {
133            $this->titleRef = $this->labelRef;
134        }
135    }
136
137    /**
138     * Figure out the minimum and maximum dates and number of days inbetween
139     *
140     * @throws StructException when the range is not at least two days
141     */
142    protected function initMinMax() {
143        $min = PHP_INT_MAX;
144        $max = 0;
145
146        /** @var Value[] $row */
147        foreach($this->result as $row) {
148            $start = $row[$this->colrefStart]->getCompareValue();
149            $start = explode(' ', $start); // cut off time
150            $start = array_shift($start);
151            if($start && $start < $min) $min = $start;
152            if($start && $start > $max) $max = $start;
153
154            $end = $row[$this->colrefEnd]->getCompareValue();
155            $end = explode(' ', $end); // cut off time
156            $end = array_shift($end);
157            if($end && $end < $min) $min = $end;
158            if($end && $end > $max) $max = $end;
159        }
160
161        $days = $this->listDays($min, $max, 0, 1);
162        $daynum = count($days);
163        if($days <= 1) {
164            throw new StructException('Not enough variation in dates to create a range');
165        }
166
167        $this->minDate = $min;
168        $this->maxDate = $max;
169        $this->days = $days;
170        $this->daynum = $daynum;
171        $this->scale = $daynum / 85; // each day should have at least 1% space, 15% for the header
172        if($this->scale < 1) $this->scale = 1;
173    }
174
175    /**
176     * Output the chart
177     */
178    public function render() {
179        if($this->mode !== 'xhtml') {
180            $this->renderer->cdata('no other renderer than xhtml supported for struct gantt');
181            return;
182        }
183
184        $width = 100 * $this->scale;
185        $this->renderer->doc .= '<div class="table">';
186        $this->renderer->doc .= '<table class="plugin_structgantt" style="width: ' . $width . '%">';
187        $this->renderColGroup();
188        $this->renderer->doc .= '<thead>';
189        $this->renderHeaders();
190        $this->renderer->doc .= '</thead>';
191        $this->renderer->doc .= '<tbody>';
192        foreach($this->result as $row) {
193            $this->renderRow($row);
194        }
195        $this->renderer->doc .= '</tbody>';
196        $this->renderer->doc .= '<tfoot>';
197        $this->renderDayRow();
198        $this->renderer->doc .= '</tfoot>';
199        $this->renderer->doc .= '</table>';
200        $this->renderer->doc .= '</div>';
201    }
202
203    /**
204     * Get the color to use in this row
205     *
206     * @param Value[] $row
207     * @return string
208     */
209    protected function getColorStyle($row) {
210        if($this->colrefColor === -1) return '';
211        $color = $row[$this->colrefColor]->getValue();
212        $conf = $row[$this->colrefColor]->getColumn()->getType()->getConfig();
213        if($color == $conf['default']) return '';
214        return 'style="background-color:' . $color . '"';
215    }
216
217    /**
218     * Render the headers
219     *
220     * Automatically decides on the scale
221     */
222    protected function renderHeaders() {
223        // define the resolution
224        if($this->daynum < 14) {
225            $format = 'j'; // days
226        } elseif($this->daynum < 60) {
227            $format = '\wW'; // week numbers
228        } else {
229            $format = 'F'; // months
230        }
231        $headers = $this->makeHeaders($this->minDate, $this->maxDate, $format);
232
233        $this->renderer->doc .= '<tr>';
234        $this->renderer->doc .= '<th></th>';
235        foreach($headers as $name => $days) {
236            $this->renderer->doc .= '<th colspan="' . $days . '">' . $name . '</th>';
237        }
238        $this->renderer->doc .= '</tr>';
239        $this->renderDayRow();
240    }
241
242    /**
243     * Calculates how wide a day should be and creates an appropriate colgroup
244     */
245    protected function renderColGroup() {
246
247        $headwidth = 15;
248        $daywidth = (100 * $this->scale - $headwidth) / $this->daynum;
249
250        $this->renderer->doc .= '<colgroup>';
251        $this->renderer->doc .= '<col style="width:' . $headwidth . '%" />';
252        foreach($this->days as $day) {
253            $this->renderer->doc .= '<col style="width:' . $daywidth . '%" />';
254        }
255        $this->renderer->doc .= '</colgroup>';
256
257    }
258
259    /**
260     * Render a row for the days and the today pointer
261     */
262    protected function renderDayRow() {
263        $today = date('Y-m-d');
264        $this->renderer->doc .= '<tr class="days">';
265        $this->renderer->doc .= '<th></th>';
266        foreach($this->days as $day) {
267            if($day->format('Y-m-d') == $today) {
268                $class = 'today';
269            } else {
270                $class = '';
271            }
272            $text = substr($day->format('l'), 0, 1);
273            $this->renderer->doc .= '<td title="' . $day->format('Y-m-d') . '" class="' . $class . '">' . $text . '</td>';
274        }
275        $this->renderer->doc .= '</tr>';
276    }
277
278    /**
279     * Render one row in the  diagram
280     *
281     * @param Value[] $row
282     */
283    protected function renderRow($row) {
284        $start = $row[$this->colrefStart]->getCompareValue();
285        $end = $row[$this->colrefEnd]->getCompareValue();
286
287        if($start && $end) {
288            $r1 = $this->listDays($this->minDate, $start);
289            $r2 = $this->listDays($start, $end, 0, 1);
290            $r3 = $this->listDays($end, $this->maxDate, 1, 1);
291        } else {
292            $r1 = $this->days;
293            $r2 = 0;
294            $r3 = 0;
295        }
296
297        // header
298        $this->renderer->doc .= '<tr>';
299        $this->renderer->doc .= '<th>';
300        $row[$this->labelRef]->render($this->renderer, $this->mode);
301        $this->renderer->doc .= '</th>';
302
303        // period before the task
304        foreach($r1 as $day) {
305            $this->renderer->doc .= '<td title="' . $day->format('Y-m-d') . '"></td>';
306        }
307
308        // the task itself
309        if($r2) {
310            $style = $this->getColorStyle($row);
311            $this->renderer->doc .= '<td colspan="' . count($r2) . '" class="task" ' . $style . '>';
312            $row[$this->titleRef]->render($this->renderer, $this->mode);
313
314            $this->renderer->doc .= '<dl class="flyout">';
315            foreach($row as $value) {
316                $this->renderer->doc .= '<dd>';
317                $value->render($this->renderer, $this->mode);
318                $this->renderer->doc .= '<dd>';
319
320            }
321            $this->renderer->doc .= '</dl>';
322
323            $this->renderer->doc .= '</td>';
324        }
325
326        // period after the task
327        foreach($r3 as $day) {
328            $this->renderer->doc .= '<td title="' . $day->format('Y-m-d') . '"></td>';
329        }
330
331        $this->renderer->doc .= '</tr>';
332    }
333
334    /**
335     * Returns the days in the given period
336     *
337     * @link based on http://stackoverflow.com/a/31046319/172068
338     * @param string $start as YYYY-MM-DD
339     * @param string $end as YYYY-MM-DD
340     * @param int $modstart days to add to start
341     * @param int $modend days to add to end
342     * @return \DateTime[]
343     */
344    protected function listDays($start, $end, $modstart=0, $modend=0) {
345        if($start > $end) list($start, $end) = array($end, $start);
346        $days = array();
347
348        $period = new \DatePeriod(
349            (new \DateTime($start))->modify($modstart.' day'),
350            new \DateInterval('P1D'),
351            (new \DateTime($end))->modify($modend.' day')
352        );
353
354        /** @var \DateTime $date */
355        foreach($period as $date) {
356            if($this->skipWeekends && (int) $date->format('N') >= 6) {
357                continue;
358            } else {
359                $days[] = $date;
360            }
361        }
362
363        return $days;
364    }
365
366    /**
367     * Returns the headers
368     *
369     * @param string $start as YYYY-MM-DD
370     * @param string $end as YYYY-MM-DD
371     * @param string $format a format string as understood by date(), used for grouping
372     * @return array
373     */
374    protected function makeHeaders($start, $end, $format) {
375        if($start > $end) list($start, $end) = array($end, $start);
376        $headers = array();
377
378        $period = new \DatePeriod(
379            new \DateTime($start),
380            new \DateInterval('P1D'),
381            (new \DateTime($end))->modify('+1 day')
382        );
383
384        /** @var \DateTime $date */
385        foreach($period as $date) {
386            if($this->skipWeekends && (int) $date->format('N') >= 6) {
387                continue;
388            } else {
389                $ident = $date->format($format);
390                if(!isset($headers[$ident])) {
391                    $headers[$ident] = 1;
392                } else {
393                    $headers[$ident]++;
394                }
395            }
396        }
397
398        return $headers;
399    }
400}
401