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