From 505a2de081ee8bfe6a0b4ff5809b819157d75286 Mon Sep 17 00:00:00 2001 From: Andreas Gohr Date: Wed, 25 Jan 2023 19:33:56 +0100 Subject: [PATCH] Automatically adjust resolution This fixes a problem with periods crossing years #7, but more importantly it changes the interval used to display tasks based on the overall time frame (switching from days to weeks and finally months) it also introduces year quarters for the headers. This means for large time frames, the displayed accuracy is reduced in favor of a birdseye view. This is largely untested and I expect a bunch of off-by-one errors. The size scaling is probably broken too. This needs some unit tests. --- meta/Gantt.php | 247 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 164 insertions(+), 83 deletions(-) diff --git a/meta/Gantt.php b/meta/Gantt.php index cfa06ed..90ab9c9 100644 --- a/meta/Gantt.php +++ b/meta/Gantt.php @@ -10,7 +10,8 @@ use dokuwiki\plugin\struct\types\Date; use dokuwiki\plugin\struct\types\DateTime; -class Gantt { +class Gantt +{ /** @var string the Type of renderer used */ protected $mode; @@ -66,17 +67,26 @@ class Gantt { /** @var bool do not show saturday and sunday */ protected $skipWeekends; + /** @var string[] interval formats used */ + protected $interval = [ + 'header' => 'j', + 'period' => 'P1D', + 'short' => 'q', + 'long' => 'Y-m-d', + ]; + /** * Initialize the Aggregation renderer and executes the search * - * You need to call @see render() on the resulting object. - * - * @param string $id + * You need to call @param string $id * @param string $mode * @param \Doku_Renderer $renderer * @param SearchConfig $searchConfig + * @see render() on the resulting object. + * */ - public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig) { + public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig) + { $this->mode = $mode; $this->renderer = $renderer; $this->searchConfig = $searchConfig; @@ -99,37 +109,42 @@ public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $ * @todo suport Lookups pointing to dates and colors * @todo handle multi columns */ - protected function initColumnRefs() { + protected function initColumnRefs() + { $ref = 0; - foreach($this->columns as $column) { - if( + foreach ($this->columns as $column) { + if ( is_a($column->getType(), Date::class) || is_a($column->getType(), DateTime::class) ) { - if($this->colrefStart == -1) { + if ($this->colrefStart == -1) { $this->colrefStart = $ref; } else { $this->colrefEnd = $ref; } - } elseif(is_a($column->getType(), Color::class)) { + } elseif (is_a($column->getType(), Color::class)) { $this->colrefColor = $ref; - } else if($this->labelRef == -1) { - $this->labelRef = $ref; - } else if($this->titleRef == -1) { - $this->titleRef = $ref; + } else { + if ($this->labelRef == -1) { + $this->labelRef = $ref; + } else { + if ($this->titleRef == -1) { + $this->titleRef = $ref; + } + } } $ref++; } - if($this->colrefStart === -1 || $this->colrefEnd === -1) { + if ($this->colrefStart === -1 || $this->colrefEnd === -1) { throw new StructException('Not enough Date columns selected'); } - if($this->labelRef === -1) { + if ($this->labelRef === -1) { throw new StructException('No label column found'); } - if($this->titleRef === -1) { + if ($this->titleRef === -1) { $this->titleRef = $this->labelRef; } } @@ -139,49 +154,90 @@ protected function initColumnRefs() { * * @throws StructException when the range is not at least two days */ - protected function initMinMax() { + protected function initMinMax() + { $min = PHP_INT_MAX; $max = 0; /** @var Value[] $row */ - foreach($this->result as $row) { + foreach ($this->result as $row) { $start = $row[$this->colrefStart]->getCompareValue(); $start = explode(' ', $start); // cut off time $start = array_shift($start); - if($start && $start < $min) $min = $start; - if($start && $start > $max) $max = $start; + if ($start && $start < $min) $min = $start; + if ($start && $start > $max) $max = $start; $end = $row[$this->colrefEnd]->getCompareValue(); $end = explode(' ', $end); // cut off time $end = array_shift($end); - if($end && $end < $min) $min = $end; - if($end && $end > $max) $max = $end; + if ($end && $end < $min) $min = $end; + if ($end && $end > $max) $max = $end; } - $days = $this->listDays($min, $max, 0, 1); - $daynum = count($days); - if($days <= 1) { + $daynum = (new \DateTime($min))->diff(new \DateTime($max))->days; + if ($daynum <= 1) { throw new StructException('Not enough variation in dates to create a range'); } + // define the resolution + if ($daynum < 14) { + $this->interval = [ + 'header' => 'j', // days + 'period' => 'P1D', + 'short' => 'q', + 'long' => 'Y-m-d', + ]; + } elseif ($daynum < 52) { + $this->interval = [ + 'header' => '\wW', // week numbers + 'period' => 'P1D', + 'short' => 'q', + 'long' => 'Y-m-d', + ]; + } elseif ($daynum < 360) { + $this->interval = [ + 'header' => 'F', // months + 'period' => 'P1D', + 'short' => 'q', + 'long' => 'Y-m-d', + ]; + } elseif ($daynum < 600) { + $this->interval = [ + 'header' => 'M \'y', // months and year + 'period' => 'P1W', // weeks + 'short' => '\wW', + 'long' => '\wW Y', + ]; + $this->skipWeekends = false; + } else { + $this->interval = [ + 'header' => '\\\\Q \'y', // quarter and year + 'period' => 'P1M', // months + 'short' => 'M', + 'long' => 'F Y', + ]; + $this->skipWeekends = false; + } + $this->minDate = $min; $this->maxDate = $max; - $this->days = $days; + $this->days = $this->listDays($min, $max); $this->daynum = $daynum; $this->scale = $daynum / 85; // each day should have at least 1% space, 15% for the header - if($this->scale < 1) $this->scale = 1; + if ($this->scale < 1) $this->scale = 1; } /** * Output the chart */ - public function render() { - if($this->mode !== 'xhtml') { + public function render() + { + if ($this->mode !== 'xhtml') { $this->renderer->cdata('no other renderer than xhtml supported for struct gantt'); return; } - $width = 100 * $this->scale; + $width = 100; //* $this->scale; $this->renderer->doc .= '
'; $this->renderer->doc .= ''; $this->renderColGroup(); @@ -189,7 +245,7 @@ public function render() { $this->renderHeaders(); $this->renderer->doc .= ''; $this->renderer->doc .= ''; - foreach($this->result as $row) { + foreach ($this->result as $row) { $this->renderRow($row); } $this->renderer->doc .= ''; @@ -206,11 +262,12 @@ public function render() { * @param Value[] $row * @return string */ - protected function getColorStyle($row) { - if($this->colrefColor === -1) return ''; + protected function getColorStyle($row) + { + if ($this->colrefColor === -1) return ''; $color = $row[$this->colrefColor]->getValue(); $conf = $row[$this->colrefColor]->getColumn()->getType()->getConfig(); - if($color == $conf['default']) return ''; + if ($color == $conf['default']) return ''; return 'style="background-color:' . $color . '"'; } @@ -219,20 +276,13 @@ protected function getColorStyle($row) { * * Automatically decides on the scale */ - protected function renderHeaders() { - // define the resolution - if($this->daynum < 14) { - $format = 'j'; // days - } elseif($this->daynum < 60) { - $format = '\wW'; // week numbers - } else { - $format = 'F'; // months - } - $headers = $this->makeHeaders($this->minDate, $this->maxDate, $format); + protected function renderHeaders() + { + $headers = $this->makeHeaders($this->minDate, $this->maxDate); $this->renderer->doc .= ''; $this->renderer->doc .= ''; - foreach($headers as $name => $days) { + foreach ($headers as $name => $days) { $this->renderer->doc .= ''; } $this->renderer->doc .= ''; @@ -242,14 +292,15 @@ protected function renderHeaders() { /** * Calculates how wide a day should be and creates an appropriate colgroup */ - protected function renderColGroup() { + protected function renderColGroup() + { $headwidth = 15; $daywidth = (100 * $this->scale - $headwidth) / $this->daynum; $this->renderer->doc .= ''; $this->renderer->doc .= ''; - foreach($this->days as $day) { + foreach ($this->days as $day) { $this->renderer->doc .= ''; } $this->renderer->doc .= ''; @@ -259,18 +310,20 @@ protected function renderColGroup() { /** * Render a row for the days and the today pointer */ - protected function renderDayRow() { - $today = date('Y-m-d'); + protected function renderDayRow() + { + $today = new \DateTime(); $this->renderer->doc .= ''; $this->renderer->doc .= ''; - foreach($this->days as $day) { - if($day->format('Y-m-d') == $today) { + foreach ($this->days as $day) { + if ($day->format($this->interval['long']) == $today->format($this->interval['long'])) { $class = 'today'; } else { $class = ''; } - $text = substr($day->format('l'), 0, 1); - $this->renderer->doc .= ''; + $text = $this->format($day, $this->interval['short']); + $title = $this->format($day, $this->interval['long']); + $this->renderer->doc .= ''; } $this->renderer->doc .= ''; } @@ -280,14 +333,19 @@ protected function renderDayRow() { * * @param Value[] $row */ - protected function renderRow($row) { + protected function renderRow($row) + { $start = $row[$this->colrefStart]->getCompareValue(); $end = $row[$this->colrefEnd]->getCompareValue(); - if($start && $end) { + if ($start && $end) { $r1 = $this->listDays($this->minDate, $start); - $r2 = $this->listDays($start, $end, 0, 1); - $r3 = $this->listDays($end, $this->maxDate, 1, 1); + $r2 = $this->listDays($start, $end); + $r3 = $this->listDays($end, $this->maxDate); + + if($r1) array_pop($r1); // last period is task + if($r3) array_shift($r3); // first period is task + } else { $r1 = $this->days; $r2 = 0; @@ -301,18 +359,18 @@ protected function renderRow($row) { $this->renderer->doc .= ''; // period before the task - foreach($r1 as $day) { - $this->renderer->doc .= ''; + foreach ($r1 as $day) { + $this->renderer->doc .= ''; } // the task itself - if($r2) { + if ($r2) { $style = $this->getColorStyle($row); $this->renderer->doc .= ''; + foreach ($r3 as $day) { + $this->renderer->doc .= ''; } $this->renderer->doc .= ''; } /** - * Returns the days in the given period + * Returns the interval units in the given period * + * @fixme currently it's still called days, but may actually use weeks or months * @link based on http://stackoverflow.com/a/31046319/172068 * @param string $start as YYYY-MM-DD * @param string $end as YYYY-MM-DD - * @param int $modstart days to add to start - * @param int $modend days to add to end * @return \DateTime[] */ - protected function listDays($start, $end, $modstart=0, $modend=0) { - if($start > $end) list($start, $end) = array($end, $start); + protected function listDays($start, $end) + { + if ($start > $end) list($start, $end) = array($end, $start); $days = array(); $period = new \DatePeriod( - (new \DateTime($start))->modify($modstart.' day'), - new \DateInterval('P1D'), - (new \DateTime($end))->modify($modend.' day') + new \DateTime($start), + new \DateInterval($this->interval['period']), + (new \DateTime($end))->modify('+1 day') // Include End Date ); /** @var \DateTime $date */ - foreach($period as $date) { - if($this->skipWeekends && (int) $date->format('N') >= 6) { + foreach ($period as $date) { + if ($this->skipWeekends && (int)$date->format('N') >= 6) { continue; } else { $days[] = $date; @@ -368,26 +426,26 @@ protected function listDays($start, $end, $modstart=0, $modend=0) { * * @param string $start as YYYY-MM-DD * @param string $end as YYYY-MM-DD - * @param string $format a format string as understood by date(), used for grouping * @return array */ - protected function makeHeaders($start, $end, $format) { - if($start > $end) list($start, $end) = array($end, $start); + protected function makeHeaders($start, $end) + { + if ($start > $end) list($start, $end) = array($end, $start); $headers = array(); $period = new \DatePeriod( new \DateTime($start), - new \DateInterval('P1D'), - (new \DateTime($end))->modify('+1 day') + new \DateInterval($this->interval['period']), + (new \DateTime($end))->modify('+1 day') // Include End Date ); /** @var \DateTime $date */ - foreach($period as $date) { - if($this->skipWeekends && (int) $date->format('N') >= 6) { + foreach ($period as $date) { + if ($this->skipWeekends && (int)$date->format('N') >= 6) { continue; } else { - $ident = $date->format($format); - if(!isset($headers[$ident])) { + $ident = $this->format($date, $this->interval['header']); + if (!isset($headers[$ident])) { $headers[$ident] = 1; } else { $headers[$ident]++; @@ -397,4 +455,27 @@ protected function makeHeaders($start, $end, $format) { return $headers; } + + /** + * Wrapper around DateTime->format() to implement our own placeholders + * + * @param \DateTime $date + * @param string $format + * @return string + */ + protected function format(\DateTime $date, $format) + { + $label = $date->format($format); + return str_replace( + [ + '\Q', // quarter of the year + '\q', // first letter of the day + ], + [ + 'Q' . ceil($date->format('n') / 3), + substr($date->format('l'), 0, 1), + ], + $label + ); + } }
' . $name . '
' . $text . '' . $text . '
'; $row[$this->titleRef]->render($this->renderer, $this->mode); $this->renderer->doc .= '
'; - foreach($row as $value) { + foreach ($row as $value) { $this->renderer->doc .= '
'; $value->render($this->renderer, $this->mode); $this->renderer->doc .= '
'; @@ -324,36 +382,36 @@ protected function renderRow($row) { } // period after the task - foreach($r3 as $day) { - $this->renderer->doc .= '