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