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