PluginProbe
E2Pdf – Export Pdf Tool for WordPress / 1.32.48
E2Pdf – Export Pdf Tool for WordPress v1.32.48
1.32.49 1.32.48 1.32.43 1.32.40 1.32.34 1.32.32 1.32.31 1.32.26 1.32.22 1.32.23 1.32.18 1.32.17 1.32.15 trunk 1.00.00 1.00.13 1.01.01 1.02.02 1.03.07 1.04.07 1.05.03 1.06.02 1.07.11 1.08.00 1.08.06 All 72 releases
e2pdf / vendors / svggraph / GanttChart.php

GanttChart.php in E2Pdf – Export Pdf Tool for WordPress 1.32.48, at vendors/svggraph/GanttChart.php

1,099 lines 33.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Copyright (C) 2022 Graham Breach
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU Lesser General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU Lesser General Public License for more details.
14 *
15 * You should have received a copy of the GNU Lesser General Public License
16 * along with this program. If not, see <http://www.gnu.org/licenses/>.
17 */
18 /**
19 * For more information, please contact <graham@goat1000.com>
20 */
21
22 namespace Goat1000\SVGGraph;
23
24 class GanttChart extends HorizontalBarGraph {
25
26 protected $start_date = null;
27 protected $end_date = null;
28 protected $auto_format = true;
29 protected $bar_list = [];
30 protected $enabled_datasets = [];
31
32 public function __construct($w, $h, array $settings, array $fixed_settings = [])
33 {
34 // if the format for the date/time axis is not set, figure one out
35 $this->auto_format = !isset($settings['datetime_text_format']);
36
37 $fs = ['require_structured' => ['end'], ];
38 $fs = array_merge($fs, $fixed_settings);
39 parent::__construct($w, $h, $settings, $fs);
40 }
41
42 /**
43 * Converts dates early
44 */
45 public function values($values)
46 {
47 $res = parent::values($values);
48 if(empty($values) || $this->values->error)
49 return $res;
50
51 // find list of enabled datasets
52 $d_count = count($this->values);
53 $d_enabled = $this->getOption("dataset");
54 if($d_enabled === null) {
55 $d_enabled = range(0, $d_count - 1);
56 } else {
57 $enabled = [];
58 if(!is_array($d_enabled))
59 $d_enabled = [$d_enabled];
60 $d_enabled = array_unique($d_enabled);
61 foreach($d_enabled as $d) {
62 if($d > 0 && $d < $d_count)
63 $enabled[] = $d;
64 }
65 $d_enabled = $enabled;
66 }
67 $this->enabled_datasets = $d_enabled;
68
69 // set up class for adjusting times
70 $units = $this->getOption('gantt_units');
71 $ts = new TimeSpanner($units);
72
73 // convert times to seconds, find start and end
74 $start_date = $end_date = null;
75 $update_times = function($item) use (&$start_date, &$end_date, $ts) {
76 if(!isset($item->value))
77 return;
78 $s = Graph::dateConvert($item->value);
79 if($s !== null) {
80 $s = $ts->start($s);
81 if($start_date === null || $start_date > $s)
82 $start_date = $s;
83 $item->value = $s;
84
85 $e = isset($item->end) ? Graph::dateConvert($item->end) : null;
86 if($e === null) {
87 $e = $s;
88 } else {
89 $e = $ts->end($e);
90 }
91
92 if($end_date === null || $end_date < $e)
93 $end_date = $e;
94 $item->end = $e;
95 }
96 return $item;
97 };
98 foreach($d_enabled as $dataset)
99 $this->values->transform($update_times, $dataset);
100
101 // find groups
102 $groups = [];
103 $item_groups = [];
104 $key = null;
105 $entries = 0;
106 $levels = [];
107
108 foreach($d_enabled as $dataset) {
109 foreach($this->values[$dataset] as $item) {
110
111 if($item->group !== null) {
112
113 // things get strange if groups are in later datasets
114 if($dataset > 0 && !isset($groups[$item->key]))
115 throw new \Exception('Groups must be in dataset 0');
116
117 // numeric group has max number of entries
118 $entries = is_numeric($item->group) ? (int)$item->group : 1e6;
119 $key = $item->key;
120 if(!isset($groups[$key])) {
121 $groups[$key] = [
122 'start' => 0,
123 'end' => 0,
124 'total_time' => 0,
125 'total_complete' => 0,
126 'level' => 0,
127 ];
128 }
129
130 // named groups for multiple levels
131 $group_name = is_string($item->group) ? $item->group : 'unnamed_group';
132 if(isset($levels[$group_name])) {
133 $new_levels = [];
134 foreach($levels as $k => $v) {
135 if($k == $group_name)
136 break;
137 $new_levels[$k] = $v;
138 }
139 $levels = $new_levels;
140 }
141
142 $levels[$group_name] = $key;
143 $groups[$key]['level'] = count($levels);
144 continue;
145 }
146
147 // not a group
148 if($key !== null && $entries) {
149 if($item->value === null)
150 continue;
151
152 // groups and tasks/milestones don't work together on a row
153 if(isset($groups[$item->key]))
154 throw new \Exception('Groups must not be mixed with tasks/milestones');
155
156 $item_groups[$item->key] = $key;
157 $item_time = $item->end - $item->value;
158 $item_percent = $item_time > 0 && is_numeric($item->complete) ?
159 $item_time * $item->complete / 100 : 0;
160
161 // update group hierarchy
162 foreach($levels as $level => $key) {
163 $g = &$groups[$key];
164 if($g['start'] == 0 || $item->value < $g['start'])
165 $g['start'] = $item->value;
166 if($g['end'] == 0 || $item->value > $g['end'])
167 $g['end'] = $item->value;
168 if($g['end'] == 0 || $item->end > $g['end'])
169 $g['end'] = $item->end;
170 if($item_time > 0) {
171 $g['total_time'] += $item_time;
172 $g['total_complete'] += $item_percent;
173 }
174 }
175 --$entries;
176 }
177 }
178 }
179
180 // update groups with real dates, percentages, text classes
181 $this->values->addField('axis_text_class');
182
183 $fix_groups = function($item) use ($groups, $item_groups) {
184 if(!isset($groups[$item->key])) {
185 if(isset($item->axis_text_class))
186 return null;
187
188 $level = 0;
189 if(isset($item_groups[$item->key]))
190 $level = $groups[$item_groups[$item->key]]['level'];
191 $item->axis_text_class = isset($item->milestone) ?
192 'gantt_milestone:' . $level :
193 'gantt_item:' . $level;
194 return $item;
195 }
196
197 $g =& $groups[$item->key];
198 $item->value = $g['start'];
199 $item->end = $g['end'];
200 if($g['total_time'] && $g['total_complete']) {
201 $item->complete = 100 * $g['total_complete'] / $g['total_time'];
202 }
203 $item->axis_text_class = 'gantt_group:' . $g['level'];
204 return $item;
205 };
206 $this->values->transform($fix_groups);
207
208 // copy found dates to class
209 $this->start_date = $start_date;
210 $this->end_date = $end_date;
211 }
212
213 /**
214 * Sets up the colours used for the graph, and other things
215 */
216 protected function setup()
217 {
218 $dataset = $this->getOption(['dataset', 0], 0);
219 $icount = $this->values->itemsCount($dataset);
220
221 // axis min/max alter number of items
222 $max = $this->getOption(['axis_max_v', 0], 1e7) + 1;
223 if($max < $icount)
224 $icount = $max;
225 $min = $this->getOption(['axis_min_v', 0], 0);
226 if($min > 0)
227 $icount -= $min;
228 if($icount < 1)
229 throw new \Exception('No items to display');
230
231 // use two datasets for main/completed colour
232 $this->colourSetup($icount, 2);
233 if(!is_numeric($this->height))
234 $this->autoHeight($icount);
235 }
236
237 /**
238 * Setup code here for before drawing starts but after axes set
239 */
240 protected function barSetup()
241 {
242 parent::barSetup();
243
244 $shapes = $this->getDayShading();
245 if($this->getOption('gantt_today')) {
246 $ds = $this->getToday();
247 if(!empty($ds))
248 $shapes = array_merge($shapes, $ds);
249 }
250
251 if(!empty($shapes)) {
252 $o_shapes = $this->getOption('shape');
253 if(is_array($o_shapes)) {
254 if(is_array($o_shapes[0]))
255 $shapes = array_merge($shapes, $o_shapes);
256 else
257 $shapes[] = $o_shapes;
258 }
259 $this->setOption('shape', $shapes);
260 }
261 }
262
263 /**
264 * Override BarGraphTrait::drawBars to draw multiple datasets
265 */
266 protected function drawBars()
267 {
268 $this->barSetup();
269 $bars = '';
270
271 // use a MultiGraph to traverse more easily
272 $multi_graph = new MultiGraph($this->values, false, false, false);
273 foreach($multi_graph as $bnum => $itemlist) {
274 foreach($this->enabled_datasets as $dataset) {
275 $item = $itemlist[$dataset];
276 $this->setBarLegendEntry($dataset, $bnum, $item);
277 $bars .= $this->drawBar($item, $bnum, 0, null, $dataset);
278 }
279 }
280
281 return $bars;
282 }
283
284 /**
285 * Calculates a height for the graph
286 */
287 private function autoHeight($items)
288 {
289 $axis = null;
290 $v_d_a = new DisplayAxis($this, $axis, 0, 'v', 'x', true, false);
291 $h_d_a = new DisplayAxis($this, $axis, 0, 'h', 'x', true, false);
292 $v_style = $v_d_a->getStyling();
293 $h_style = $h_d_a->getStyling();
294
295 // fixed space allows for two lines of labels, plus two of headings, plus padding
296 $fixed = $this->pad_bottom + $this->pad_top;
297 $fixed += $this->getOption('axis_pad_top') + $this->getOption('axis_pad_bottom');
298
299 if(isset($h_style['t_font_size'])) {
300 $space = isset($h_style['t_space']) ? $h_style['t_space'] : 1;
301 if($h_style['d_style'] == 'box')
302 $space *= 2;
303 $text_size = ($space + $h_style['t_font_size']) * 2;
304 if($this->getOption('axis_double_x'))
305 $text_size *= 2;
306 $fixed += $text_size;
307 }
308 if(isset($h_style['l_font_size'])) {
309 $space = isset($h_style['l_space']) ? $h_style['l_space'] : 1;
310 $fixed += ($space + $h_style['l_font_size']) * 2;
311 }
312
313 // add in space for any titles
314 $titles = $this->getTitle();
315 if($titles['font_size'] && ($titles['pos'] == 'top' || $titles['pos'] == 'bottom')) {
316 $fixed += $titles['height'] + $titles['space'];
317 if($titles['sfont_size'])
318 $fixed += $titles['sheight'] + $titles['sspace'];
319 }
320
321 $min_height = 10;
322 $ch = $this->getOption('gantt_group_corner_width') ?
323 $this->getOption('gantt_group_corner_height') : 0;
324 $bw = max($this->getOption('bar_width'), 2);
325 $bs = max($this->getOption('bar_space'), 2);
326 $bar = max($ch, $bs) + $bw;
327 $marker = max($this->getOption('gantt_milestone_size'), 2) * 2;
328 $font_size = $min_height;
329 if(isset($v_style['t_font_size']))
330 $font_size = $v_style['t_font_size'];
331
332 // get largest font size from text classes
333 $classes = ['gantt_group:', 'gantt_item:', 'gantt_milestone:'];
334 for($i = 0; $i < 20; ++$i) {
335 foreach($classes as $cls) {
336 $tc = new TextClass($cls . $i);
337 $sz = $tc->font_size;
338 if($sz > $font_size)
339 $font_size = $sz;
340 }
341 }
342 $text = $font_size * 1.5;
343
344 // each row is the biggest of bar, milestone, text label, or fallback value
345 $row_height = max($min_height, $bar, $marker, $text);
346 $this->height = $fixed + $items * $row_height;
347 }
348
349 /**
350 * Choose a format for the axis depending on the length in time and pixels
351 */
352 private function autoFormatAxis($ends)
353 {
354 // (roughly) measure the horizontal space taken by Y-axis
355 $min_space = 1;
356 $grid_division = 1;
357 $length = $this->height - $this->pad_top - $this->pad_bottom;
358 $l_c = $this->getOption('label_centre');
359 $factory = $this->getYAxisFactory();
360 $axis = $this->createYAxis($factory, $length, $ends, 0, $min_space, $grid_division);
361 $display_axis = new DisplayAxis($this, $axis, 0, 'v', 'x', true, $l_c);
362 $bbox = $display_axis->measure();
363 $left_text = $bbox->x2 - $bbox->x1;
364 if($this->getOption('axis_double_y'))
365 $left_text *= 2;
366
367 // approximate length of X-axis
368 $length = $this->width - $this->pad_left - $this->pad_right - $left_text;
369 $min_space = $this->getOption(['minimum_grid_spacing_h', 0], 'minimum_grid_spacing');
370 $want_space = 5; // amount of space wanted between labels
371 $good_fit = false;
372 $divisions = [
373 ['100 year', 'Y'], ['50 year', 'Y'], ['20 year', 'Y'], ['10 year', 'Y\'\s'],
374
375 ['1 year', 'Y'],
376 ['6 month', ['M', 'Y']],
377 ['3 month', ['M', 'Y']],
378 ['2 month', ['M', 'Y']],
379 ['1 month', ['M', 'Y']],
380 ['14 day', ['d M', 'Y']],
381 ['7 day', ['d M', 'Y']],
382 ['1 day', ['d', 'M Y']],
383 ['1 day', ['D d', 'M Y']],
384 ];
385
386 // if using units smaller than days might need smaller divisions
387 $units = $this->getOption('gantt_units');
388 if($units == 'hour' || $units == 'minute') {
389 $more_divisions = [
390 ['12 hour', ['H:i', 'D d M Y']],
391 ['6 hour', ['H:i', 'D d M Y']],
392 ['3 hour', ['H:i', 'D d M Y']],
393 ['2 hour', ['H:i', 'D d M Y']],
394 ['1 hour', ['H:i', 'D d M Y']],
395 ];
396 $divisions = array_merge($divisions, $more_divisions);
397 }
398 $subdivisions = [
399 '1 hour' => '30 minute',
400 '2 hour' => '1 hour',
401 '3 hour' => '1 hour',
402 '6 hour' => '1 hour',
403 '12 hour' => '2 hour',
404 '1 day' => '6 hour',
405 ];
406 $div_id = count($divisions) - 1;
407
408 $factory = $this->getXAxisFactory();
409 $fmt = $div = null;
410 while(!$good_fit) {
411
412 // find out how well the division fits
413 list($div, $fmt) = $divisions[$div_id];
414 $levels = is_array($fmt) ? count($fmt) : 1;
415 $this->setOption('datetime_text_format', $fmt);
416 $this->setOption('axis_levels_h', $levels);
417
418 $axis = $this->createXAxis($factory, $length, $ends, 0, $min_space, $div);
419 if($levels > 1)
420 $display_axis = new DisplayAxisLevels($this, $axis, 0, 'h', 'x', true, $l_c);
421 else
422 $display_axis = new DisplayAxis($this, $axis, 0, 'h', 'x', true, $l_c);
423
424 $overlap = $display_axis->getTextOverlap();
425 if($overlap < -$want_space)
426 $good_fit = true;
427
428 // give up?
429 if($overlap === null || --$div_id < 0) {
430 $this->setOption('datetime_text_format', null);
431 $this->setOption('axis_levels_h', 1);
432 return;
433 }
434 }
435
436 if($fmt !== null) {
437 $this->setOption('datetime_text_format', $fmt);
438 $this->setOption('axis_levels_h', $levels);
439 }
440 if($div !== null) {
441 $this->setOption('grid_division_h', $div);
442 if(isset($subdivisions[$div]))
443 $this->setOption('subdivision_h', $subdivisions[$div]);
444 }
445 $this->auto_format = false;
446 }
447
448 /**
449 * Sets up the shading for weekends (or whatever)
450 */
451 private function getDayShading()
452 {
453 $shade_days = $this->getOption('gantt_shade_days');
454 if(!is_array($shade_days) || empty($shade_days))
455 return [];
456
457 // get the values at the axis ends from the axis
458 $axis = $this->getAxis('x', null);
459 $a_len = $axis->getLength();
460 $date = $axis->value(0);
461 $end_time = $axis->value($a_len);
462
463 // check how long a day is on this axis
464 $timescale = $end_time - $date;
465 $days = $timescale / 86400;
466 $day_pixels = $a_len / $days;
467 if($day_pixels < 1)
468 return [];
469
470 // set up a rect or NULL for each day of the week
471 $per_day = [];
472 for($i = 0; $i < 7; ++$i) {
473 if(in_array($i, $shade_days)) {
474 $per_day[$i] = [
475 'rect', 'x' => 'gl', 'y' => 'gt',
476 'width' => 'u1 days', 'height' => 'gh',
477 'fill' => $this->getOption(['gantt_shade_days_colour', $i]),
478 'opacity' => $this->getOption(['gantt_shade_days_opacity', $i]),
479 'stroke' => 'none',
480 ];
481 } else {
482 $per_day[$i] = null;
483 }
484 }
485
486 // make an array of rects for shading days
487 $shapes = [];
488 $dd = new \DateTime('@' . new Number($date));
489 $dw = $dd->format('w');
490 while($date < $end_time) {
491 if($per_day[$dw] !== null) {
492 $rect = $per_day[$dw];
493 $dd = new \DateTime('@' . new Number($date));
494 $rect['x'] = 'g' . $dd->format('Y-m-d');
495 $shapes[] = $rect;
496 }
497 $dw = ($dw + 1) % 7;
498 $date += 86400;
499 }
500 return $shapes;
501 }
502
503 /**
504 * Returns the shape that marks the current day
505 */
506 protected function getToday()
507 {
508 $today = $t_i = null;
509 $when = $this->getOption('gantt_today_date');
510 if($when) {
511 $t_i = Graph::dateConvert($when);
512 if($t_i !== null)
513 $today = new \DateTime('@' . $t_i);
514 }
515 if($today === null) {
516 $today = new \DateTime();
517 $t_i = $today->format('U');
518 }
519
520 // check that today is on the chart
521 $axis = $this->getAxis('x', null);
522 $a_len = $axis->getLength();
523 $start_time = $axis->value(0);
524 $end_time = $axis->value($a_len);
525
526 if($t_i < $start_time || $t_i > $end_time)
527 return null;
528
529 $stroke_width = min(10, max(0.1, $this->getOption('gantt_today_width')));
530 $dash = $this->getOption('gantt_today_dash');
531 $opacity = min(1, max(0, $this->getOption('gantt_today_opacity')));
532 if($opacity == 0)
533 return null;
534
535 $midday = 'g' . $today->format('Y-m-d') . 'T12:00:00';
536 $shape = [
537 'line',
538 'x1' => $midday, 'x2' => $midday,
539 'y1' => 'gt', 'y2' => 'gb',
540 ];
541 $shape['stroke'] = $this->getOption('gantt_today_colour');
542 if($stroke_width != 1)
543 $shape['stroke-width'] = $stroke_width;
544 if(!empty($dash))
545 $shape['stroke-dasharray'] = $dash;
546 if($opacity < 1)
547 $shape['opacity'] = $opacity;
548 return [$shape];
549 }
550
551 /**
552 * Returns fixed min and max option for an axis
553 */
554 protected function getFixedAxisOptions($axis, $index)
555 {
556 $a = $axis == 'y' ? 'h' : 'v';
557 $min = $this->getOption(['axis_min_' . $a, $index]);
558 $max = $this->getOption(['axis_max_' . $a, $index]);
559 if($axis == 'y') {
560 if($min !== null) {
561 $min = Graph::dateConvert($min);
562 } else {
563
564 // need to set a minimum, or it will end up as 1970
565 $min = $this->start_date;
566 }
567 if($max !== null)
568 $max = Graph::dateConvert($max);
569 }
570 return [$min, $max];
571 }
572
573 /**
574 * Min value is the stored start date
575 */
576 public function getMinValue()
577 {
578 return $this->start_date;
579 }
580
581 /**
582 * Max value is the stored end date
583 */
584 public function getMaxValue()
585 {
586 return $this->end_date;
587 }
588
589 /**
590 * Both axes are X-type for Gantt chart
591 */
592 protected function getDisplayAxis($axis, $axis_no, $orientation, $type)
593 {
594 $var = 'main_' . $type . '_axis';
595 $main = ($axis_no == $this->{$var});
596 $levels = $this->getOption(['axis_levels_' . $orientation, $axis_no]);
597 $class = 'Goat1000\SVGGraph\DisplayAxis';
598 if(is_numeric($levels) && $levels > 1)
599 $class = 'Goat1000\SVGGraph\DisplayAxisLevels';
600
601 return new $class($this, $axis, $axis_no, $orientation, 'x', $main,
602 $this->getOption('label_centre'));
603 }
604
605 /**
606 * Override to pre-calculate axis settings
607 */
608 protected function getAxisEnds()
609 {
610 // now is the time to figure out the best format
611 if($this->auto_format) {
612 $ends = parent::getAxisEnds();
613 $this->autoFormatAxis($ends);
614 }
615
616 return parent::getAxisEnds();
617 }
618
619 /**
620 * Override to always return datetime axis
621 */
622 protected function getXAxisFactory()
623 {
624 return new AxisFactory(true, $this->settings, false, false, false);
625 }
626
627 /**
628 * Override to always want bar-style Y axis
629 */
630 protected function getYAxisFactory()
631 {
632 // don't reverse the vertical axis for Gantt charts
633 return new AxisFactory($this->getOption('datetime_keys'), $this->settings,
634 true, true, false);
635 }
636
637 /**
638 * Returns an array with x, y, width and height set
639 */
640 protected function barDimensions($item, $index, $start, $axis, $dataset)
641 {
642 $bar = [];
643 $bar_x = $this->barX($item, $index, $bar, $axis, $dataset);
644 if($bar_x === null)
645 return [];
646
647 $start = $item->value;
648 $value = $item->milestone ? 0 : $item->end - $start;
649
650 // if this is not a milestone, ignore backwards bars
651 if($value < 0)
652 return [];
653
654 $y_pos = $this->barY($value, $bar, $start, $axis);
655 if($y_pos === null)
656 return [];
657 return $bar;
658 }
659
660 /**
661 * Returns the SVG code for a bar or milestone
662 */
663 protected function drawBar(DataItem $item, $index, $start = 0, $axis = null,
664 $dataset = 0, $options = [])
665 {
666 if($item->value === null)
667 return '';
668
669 $bar = $this->barDimensions($item, $index, $start, $axis, $dataset);
670 if(empty($bar))
671 return '';
672
673 // check if this item is off the sides
674 $element = $this->getPointer($item, $index, $dataset, $bar);
675 if($element) {
676 $m = new MarkerShape($element, 'above');
677 return $m->draw($this);
678 }
679
680 if($item->milestone) {
681 if($this->gridX($item->value) === null)
682 return null;
683 $element = $this->getMilestone($item, $index, $dataset, $bar);
684 $label = $item->axis_text ? $item->axis_text : $item->key;
685 $label_shown = $this->addDataLabel($dataset, $index, $element, $item,
686 $bar['x'], $bar['y'], $element['size'], $bar['height'], $label);
687 } else {
688 $element = $this->getBar($item, $index, $dataset, $bar);
689 $bar_type = $element['element'];
690 $bar_content = $element['content'];
691 unset($element['element'], $element['content']);
692
693 // data label is % completion
694 $complete = new Number($item->complete ? $item->complete : 0);
695 $label = "[{$complete}%]";
696 $label_shown = $this->addDataLabel($dataset, $index, $element, $item,
697 $bar['x'], $bar['y'], $bar['width'], $bar['height'], $label);
698 }
699
700 $depends = $this->drawDependencies($item, $index, $dataset, $bar);
701
702 if($this->getOption('semantic_classes'))
703 $element['class'] = 'series' . $dataset;
704
705 if($this->getOption('show_tooltips'))
706 $this->setTooltip($element, $item, $dataset, $item->key, $item->value,
707 $label_shown);
708 if($this->getOption('show_context_menu'))
709 $this->setContextMenu($element, $dataset, $item, $label_shown);
710
711 $task_entry = '';
712 if($item->milestone) {
713 $m = new MarkerShape($element, 'above');
714 $task_entry .= $m->draw($this);
715 } else {
716 $bar_part = $this->element($bar_type, $element, null, $bar_content);
717 $task_entry .= $this->getLink($item, $item->key, $bar_part);
718 }
719 return $task_entry . $depends;
720 }
721
722 /**
723 * Returns the incomplete and complete colours for a bar/group
724 */
725 protected function getBarColours(DataItem $item, $index, $dataset)
726 {
727 // use datasets 0 and 1 for incomplete and complete colours
728 $colour_incomplete = $this->getColour($item, $index, 0);
729 $colour_complete = $this->getColour($item, $index, 1);
730
731 if($item->group) {
732 // group bars are coloured differently
733 $ci = $this->getItemOption('gantt_group_colour', $dataset, $item, 'colour');
734 $cc = $this->getItemOption('gantt_group_colour_complete', $dataset, $item, 'colour_complete');
735 if(!empty($ci)) {
736 $cg = new ColourGroup($this, $item, $index, 0, 'gantt_group_colour', null, 'colour');
737 $colour_incomplete = $cg->stroke();
738 }
739 if(!empty($cc)) {
740 $cg = new ColourGroup($this, $item, $index, 1, 'gantt_group_colour_complete', null, 'colour_complete');
741 $colour_complete = $cg->stroke();
742 }
743 } else {
744 // support fill/fillColour for individual bar complete colours
745 $cc = $this->getItemOption('colour_complete', 0, $item);
746 if(!empty($cc)) {
747 $cg = new ColourGroup($this, $item, $index, 1, 'colour_complete', null, 'colour_complete');
748 $colour_complete = $cg->stroke();
749 }
750 }
751 return [$colour_incomplete, $colour_complete];
752 }
753
754 /**
755 * Returns the attributes of a bar
756 */
757 protected function getBar(DataItem $item, $index, $dataset, &$bar)
758 {
759 list($colour_incomplete, $colour_complete) = $this->getBarColours($item, $index, $dataset);
760 $round = max($this->getItemOption('bar_round', $dataset, $item), 0);
761 $corner_width = $corner_height = 0;
762 if($round > 0) {
763 // don't allow the round corner to be more than 1/2 bar width or height
764 $bar['rx'] = $bar['ry'] = min($round, $bar['width'] / 2, $bar['height'] / 2);
765 }
766
767 if($item->group) {
768 $corner_width = $this->getItemOption('gantt_group_corner_width', $dataset, $item, 'corner_width');
769 $corner_height = $this->getItemOption('gantt_group_corner_height', $dataset, $item, 'corner_height');
770 }
771
772 $element = $bar;
773
774 // group bar has downward pointing corners
775 if($corner_height && $corner_width) {
776
777 // make sure the corners are not bigger than the whole bar
778 $corner_width = max(0.5, min($corner_width, ($bar['width'] - 2) / 2));
779
780 $path = ['element' => 'path', 'content' => null];
781 $inner = $bar['width'] - $corner_width * 2;
782 $p = new PathData('M', $bar['x'], $bar['y'] - $corner_height / 2);
783 $p->add('h', $bar['width']);
784 $p->add('v', $bar['height'] + $corner_height);
785 $p->add('l', -$corner_width, -$corner_height);
786 $p->add('h', -$inner);
787 $p->add('l', -$corner_width, $corner_height);
788 $p->add('z');
789 $path['d'] = $p;
790 $element = $path;
791
792 // update $bar
793 $bar['y'] -= $corner_height / 2;
794 $bar['height'] += $corner_height;
795 } else {
796
797 $element['element'] = 'rect';
798 $element['content'] = null;
799 }
800 if($item->complete >= 100) {
801 $element['fill'] = $colour_complete;
802 $this->setStroke($element, $item, $index, 0);
803 return $element;
804 }
805
806 if($item->complete <= 0) {
807 $element['fill'] = $colour_incomplete;
808 $this->setStroke($element, $item, $index, 0);
809 return $element;
810 }
811
812 $type = $element['element'];
813 unset($element['element'], $element['content']);
814
815 // % complete
816 $c = $this->getClippers($bar['x'], $bar['y'], $bar['width'],
817 $bar['height'], $item->complete);
818 $bar_parts = '';
819 $b1 = $element;
820 $b1['fill'] = $colour_complete;
821 $b1['clip-path'] = "url(#{$c[0]})";
822 $bar_parts .= $this->element($type, $b1);
823
824 // % remaining
825 $b2 = $element;
826 $b2['fill'] = $colour_incomplete;
827 $b2['clip-path'] = "url(#{$c[1]})";
828 $bar_parts .= $this->element($type, $b2);
829
830 // outline over top
831 $element['fill'] = 'none';
832 $this->setStroke($element, $item, $index, 0);
833 $bar_parts .= $this->element($type, $element);
834
835 return ['element' => 'g', 'content' => $bar_parts];
836 }
837
838 /**
839 * Returns a pair of clip path IDs for clipping bar
840 */
841 protected function getClippers($x, $y, $w, $h, $percent)
842 {
843 $extra = 5;
844 $m1 = $w * $percent / 100;
845 $m2 = $w - $m1;
846
847 $r = [
848 'x' => $x - $extra,
849 'y' => $y - $extra,
850 'width' => $m1 + $extra,
851 'height' => $h + $extra
852 ];
853 $c = ['id' => $this->newID()];
854 $this->defs->add($this->element('clipPath', $c, null,
855 $this->element('rect', $r)));
856 $clippers = [$c['id']];
857
858 $r['x'] = $x + $m1;
859 $r['width'] = $m2 + $extra;
860 $c = ['id' => $this->newID()];
861 $this->defs->add($this->element('clipPath', $c, null,
862 $this->element('rect', $r)));
863 $clippers[] = $c['id'];
864
865 return $clippers;
866 }
867
868 /**
869 * Returns the colour for the milestone
870 */
871 protected function getMilestoneColour(DataItem $item, $index, $dataset)
872 {
873 $gpat = !($this->getOption('marker_solid', true));
874 $mcolour = $this->getItemOption('gantt_milestone_colour', $dataset, $item, 'colour');
875
876 // don't use per-dataset global colours, only used for complete bars
877 $dataset = 0;
878 if(empty($mcolour))
879 return $this->getColour(null, $index, $dataset, $gpat, $gpat);
880
881 // support fill and fillColour
882 $cg = new ColourGroup($this, $item, $index, $dataset, 'gantt_milestone_colour', null, 'colour');
883 $fill = $cg->stroke();
884
885 // impose marker_solid option
886 if(!$gpat)
887 $fill = new Colour($this, $fill, false, false);
888 return $fill;
889 }
890
891 /**
892 * Returns the attributes of a milestone
893 */
894 protected function getMilestone(DataItem $item, $index, $dataset, $bar)
895 {
896 $fill = $this->getMilestoneColour($item, $index, $dataset);
897 $size = max(2, $this->getItemOption('gantt_milestone_size', $dataset, $item, 'size'));
898 $type = $this->getItemOption('gantt_milestone_type', $dataset, $item, 'type');
899
900 $marker = [
901 'type' => $type,
902 'x' => $bar['x'],
903 'y' => $bar['y'] + $bar['height'] / 2,
904 'fill' => $fill,
905 'size' => $size,
906 ];
907 $this->setStroke($marker, $item, $index, $dataset);
908 return $marker;
909 }
910
911 /**
912 * Returns an arrow pointing to where the data is off the display, or null if it is not
913 */
914 protected function getPointer(DataItem $item, $index, $dataset, $bar)
915 {
916 $angle = 0;
917 $pos_start = $this->gridX($item->value);
918 $pos_end = $item->milestone ? $pos_start : $this->gridX($item->end);
919
920 $size = $bar['height'] / 2;
921 $offset = 3;
922 if($pos_start > $this->width - $this->pad_right) {
923 $x = $this->width - $this->pad_right - $size - $offset;
924 $angle = 90;
925 }
926 if($pos_end < $this->pad_left) {
927 $x = $this->pad_left + $size + $offset;
928 $angle = 270;
929 }
930
931 if($angle === 0)
932 return null;
933
934 if($item->milestone) {
935 $fill = $this->getMilestoneColour($item, $index, $dataset);
936 } else {
937 $colours = $this->getBarColours($item, $index, $dataset);
938 $fill = $item->complete >= 100 ? $colours[1] : $colours[0];
939 }
940
941 $marker = [
942 'type' => 'triangle',
943 'x' => $x,
944 'y' => $bar['y'] + $bar['height'] / 2,
945 'fill' => $fill,
946 'size' => $size,
947 'angle' => $angle,
948 ];
949 $this->setStroke($marker, $item, $index, $dataset);
950 return $marker;
951 }
952
953 /**
954 * Draws dependency arrows
955 */
956 protected function drawDependencies(&$item, $index, $dataset, $bar)
957 {
958 // add this bar to the list so others can draw arrows to it
959 if($dataset == 0)
960 $this->bar_list[$item->key] = $bar;
961 $this->bar_list[$item->key . ":" . new Number($dataset)] = $bar;
962 if(!isset($item->depends))
963 return '';
964
965 $arrows = '';
966 $depends = is_array($item->depends) ? $item->depends : [$item->depends];
967 $dtype = is_array($item->depends_type) ? $item->depends_type : [$item->depends_type];
968
969 $head_size = $this->getItemOption('gantt_depends_head_size', $dataset,
970 $item, 'depends_head_size');
971 $stroke_width = min(10, max(0.1,
972 $this->getItemOption('gantt_depends_stroke_width', $dataset, $item, 'depends_stroke_width')));
973 $cg = new ColourGroup($this, $item, $index, $dataset, 'gantt_depends_colour', null, 'depends_colour');
974 $colour = $cg->stroke();
975 $dash = $this->getItemOption('gantt_depends_dash', $dataset, $item, 'depends_dash');
976 $opacity = min(1, max(0,
977 $this->getItemOption('gantt_depends_opacity', $dataset, $item, 'depends_opacity')));
978
979 $group_style = [ 'stroke' => $colour, ];
980 if($stroke_width != 1)
981 $group_style['stroke-width'] = $stroke_width;
982 if(!empty($dash))
983 $group_style['stroke-dasharray'] = $dash;
984 if($opacity < 1)
985 $group_style['opacity'] = $opacity;
986
987 foreach($depends as $k => $d) {
988 if(!isset($this->bar_list[$d]))
989 break;
990
991 $dbar = $this->bar_list[$d];
992 $arrow = new GanttArrow(new Point($dbar['x'], $dbar['y']),
993 new Point($bar['x'], $bar['y']),
994 $dbar['width'], $dbar['height'],
995 $bar['width'], $bar['height'],
996 isset($dtype[$k]) ? $dtype[$k] : 'FS',
997 $this->calculated_bar_space);
998
999 $arrow->setHeadSize($head_size);
1000 $arrow->setHeadColour($colour);
1001 $arrows .= $arrow->draw($this);
1002 }
1003 $arrows = $this->element('g', $group_style, null, $arrows);
1004 return $arrows;
1005 }
1006
1007 /**
1008 * Tooltips are a little more complicated on Gantt chart
1009 */
1010 protected function formatTooltip(&$item, $dataset, $key, $value)
1011 {
1012 $axis = $this->x_axes[$this->main_x_axis];
1013 $format = $this->getOption('tooltip_datetime_format');
1014
1015 $dt = new \DateTime('@' . $item->value);
1016 $text_start = $axis->format($dt, $format);
1017 if($item->milestone) {
1018 $ttext = $item->axis_text ? $item->axis_text : $key;
1019 $ttext .= "\n" . $text_start;
1020 return $ttext;
1021 }
1022
1023 $pluralize = function($n, $units) {
1024 $str = new Number($n) . ' ' . $units;
1025 if($n != 1)
1026 $str .= 's';
1027 return $str;
1028 };
1029
1030 $dte = new \DateTime('@' . $item->end);
1031 $text_end = $axis->format($dte, $format);
1032 $ttext = "{$text_start} - {$text_end}";
1033 if($this->getOption('gantt_tooltip_duration')) {
1034 $days = ceil(($item->end - $item->value) / 86400);
1035 $hours = ceil(($item->end - $item->value) / 3600);
1036 $mins = ceil(($item->end - $item->value) / 60);
1037 if($days > 364) {
1038 $years = $days / 365;
1039 $ttext .= "\n" . $pluralize($years, "year");
1040 } elseif($days > 20) {
1041 $weeks = floor($days / 7);
1042 $days = $days % 7;
1043 $ttext .= "\n" . $pluralize($weeks, "week");
1044 if($days) {
1045 $ttext .= ", " . $pluralize($days, "day");
1046 }
1047 } else {
1048 $units = $this->getOption('gantt_units');
1049 if($units === 'minute' && $hours <= 24) {
1050 $ttext .= "\n";
1051 $hours = floor($mins / 60);
1052 if($hours > 0) {
1053 $ttext .= $pluralize($hours, "hour");
1054 $mins = $mins % 60;
1055 }
1056 if($mins > 0) {
1057 if($hours > 0)
1058 $ttext .= ', ';
1059 $ttext .= $pluralize($mins, "minute");
1060 }
1061 } elseif($units === 'hour') {
1062 $ttext .= "\n";
1063 $days = floor($hours / 24);
1064 if($days > 0) {
1065 $ttext .= $pluralize($days, "day");
1066 $hours = $hours % 24;
1067 }
1068 if($hours > 0) {
1069 if($days > 0)
1070 $ttext .= ', ';
1071 $ttext .= $pluralize($hours, "hour");
1072 }
1073 } else {
1074 $ttext .= "\n" . $pluralize($days, "day");
1075 }
1076 }
1077 }
1078
1079 if($item->complete && $this->getOption('gantt_tooltip_complete')) {
1080 $n = new Number(min(100, $item->complete));
1081 $ttext .= "\n[{$n}% complete]";
1082 }
1083
1084 return $ttext;
1085 }
1086
1087 /**
1088 * Returns TRUE if the item is visible on the graph
1089 */
1090 public function isVisible($item, $dataset = 0)
1091 {
1092 if($item->value === null)
1093 return false;
1094 if($item->milestone)
1095 return true;
1096 return ($item->end - $item->value != 0);
1097 }
1098 }
1099