| 1 |
<?php |
| 2 |
/** |
| 3 |
* Copyright (C) 2016-2023 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 |
/** |
| 25 |
* Draws legend |
| 26 |
*/ |
| 27 |
class Legend { |
| 28 |
|
| 29 |
protected $graph; |
| 30 |
protected $entry_details = []; |
| 31 |
protected $autohide; |
| 32 |
protected $autohide_opacity; |
| 33 |
protected $back_colour; |
| 34 |
protected $colour; |
| 35 |
protected $columns; |
| 36 |
protected $draggable; |
| 37 |
protected $entries; |
| 38 |
protected $entry_height; |
| 39 |
protected $entry_width; |
| 40 |
protected $font; |
| 41 |
protected $font_adjust; |
| 42 |
protected $font_size; |
| 43 |
protected $font_weight; |
| 44 |
protected $position; |
| 45 |
protected $round; |
| 46 |
protected $shadow_opacity; |
| 47 |
protected $show_empty; |
| 48 |
protected $stroke_colour; |
| 49 |
protected $stroke_width; |
| 50 |
protected $text_side; |
| 51 |
protected $line_spacing; |
| 52 |
protected $title; |
| 53 |
protected $title_colour; |
| 54 |
protected $title_font; |
| 55 |
protected $title_font_adjust; |
| 56 |
protected $title_font_size; |
| 57 |
protected $title_font_weight; |
| 58 |
protected $title_line_spacing; |
| 59 |
protected $title_link; |
| 60 |
protected $type; |
| 61 |
protected $unique_fields; |
| 62 |
|
| 63 |
public function __construct(&$graph) |
| 64 |
{ |
| 65 |
$this->graph =& $graph; |
| 66 |
|
| 67 |
// copy options to class |
| 68 |
$opts = ['autohide', 'autohide_opacity', 'back_colour', 'colour', 'columns', |
| 69 |
'draggable', 'entries', 'entry_height', 'entry_width', 'font', |
| 70 |
'font_adjust', 'font_weight', 'position', 'round', |
| 71 |
'shadow_opacity', 'show_empty', 'stroke_colour', 'stroke_width', |
| 72 |
'text_side', 'title', 'title_link', 'title_font_weight', 'type', |
| 73 |
'unique_fields']; |
| 74 |
foreach($opts as $opt) { |
| 75 |
$this->{$opt} = $graph->getOption('legend_' . $opt); |
| 76 |
} |
| 77 |
|
| 78 |
// slightly more complicated options |
| 79 |
$this->font_size = Number::units($graph->getOption('legend_font_size')); |
| 80 |
$this->title_colour = $graph->getOption('legend_title_colour', |
| 81 |
'legend_colour'); |
| 82 |
$this->title_font = $graph->getOption('legend_title_font', 'legend_font'); |
| 83 |
$this->title_font_adjust = $graph->getOption('legend_title_font_adjust', |
| 84 |
'legend_font_adjust'); |
| 85 |
$this->title_font_size = Number::units($graph->getOption('legend_title_font_size', |
| 86 |
'legend_font_size')); |
| 87 |
$this->line_spacing = Number::units($graph->getOption('legend_line_spacing')); |
| 88 |
if($this->line_spacing === null || $this->line_spacing < 1) |
| 89 |
$this->line_spacing = $this->font_size; |
| 90 |
$this->title_line_spacing = Number::units($graph->getOption('legend_title_line_spacing')); |
| 91 |
if($this->title_line_spacing === null || $this->title_line_spacing < 1) |
| 92 |
$this->title_line_spacing = $this->title_font_size; |
| 93 |
} |
| 94 |
|
| 95 |
/** |
| 96 |
* Sets the style information for an entry |
| 97 |
*/ |
| 98 |
public function setEntry($dataset, $index, $item, $style_info) |
| 99 |
{ |
| 100 |
// ignore entry if empty and legend_show_empty not enabled |
| 101 |
if(!$this->show_empty && !$this->graph->isVisible($item, $dataset)) |
| 102 |
return; |
| 103 |
|
| 104 |
// find the text first |
| 105 |
$text = ''; |
| 106 |
$link = null; |
| 107 |
$entry = count($this->entry_details); |
| 108 |
$itext = $item->legend_text; |
| 109 |
if($itext !== null) |
| 110 |
$text = $itext; |
| 111 |
|
| 112 |
if($text == '') { |
| 113 |
// no text from structured data |
| 114 |
if($this->type == 'none') |
| 115 |
return; |
| 116 |
|
| 117 |
if($this->type == 'dataset') { |
| 118 |
// one entry per dataset |
| 119 |
$entry = $dataset; |
| 120 |
if(!isset($this->entry_details[$entry]) && |
| 121 |
isset($this->entries[$dataset])) |
| 122 |
$text = $this->entries[$dataset]; |
| 123 |
} else { // $this->type == 'all' |
| 124 |
// one entry per data item |
| 125 |
if(isset($this->entries[$index])) |
| 126 |
$text = $this->entries[$index]; |
| 127 |
} |
| 128 |
} |
| 129 |
|
| 130 |
// split out link |
| 131 |
if(is_array($text)) |
| 132 |
list($text, $link) = $text; |
| 133 |
|
| 134 |
if($this->unique_fields) { |
| 135 |
// prevent adding multiple entries with the same text |
| 136 |
foreach($this->entry_details as $e) { |
| 137 |
if($e->text == $text) |
| 138 |
return; |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
// if there is no text, don't add the entry |
| 143 |
if($text != '') |
| 144 |
$this->entry_details[$entry] = new LegendEntry($item, $text, $link, |
| 145 |
$style_info); |
| 146 |
} |
| 147 |
|
| 148 |
/** |
| 149 |
* Draws the legend |
| 150 |
*/ |
| 151 |
public function draw() |
| 152 |
{ |
| 153 |
$entries = $this->getEntries(); |
| 154 |
$entry_count = count($entries); |
| 155 |
if($entry_count < 1) |
| 156 |
return ''; |
| 157 |
|
| 158 |
$encoding = $this->graph->encoding; |
| 159 |
|
| 160 |
// find the largest width / height |
| 161 |
$font_size = $this->font_size; |
| 162 |
$max_width = $max_height = 0; |
| 163 |
$svg_text = new Text($this->graph, $this->font, $this->font_adjust); |
| 164 |
$baseline = $svg_text->baseline($font_size); |
| 165 |
foreach($this->entry_details as $entry) { |
| 166 |
list($w, $h) = $svg_text->measure($entry->text, $font_size, 0, $this->line_spacing); |
| 167 |
if($w > $max_width) |
| 168 |
$max_width = $w; |
| 169 |
if($h > $max_height) |
| 170 |
$max_height = $h; |
| 171 |
$entry->width = $w; |
| 172 |
$entry->height = $h; |
| 173 |
} |
| 174 |
|
| 175 |
$title = ''; |
| 176 |
$title_width = $entries_x = 0; |
| 177 |
$padding_y = $this->graph->getOption('legend_padding_y', 'legend_padding', 1); |
| 178 |
$padding_x = $this->graph->getOption('legend_padding_x', 'legend_padding', 1); |
| 179 |
$start_y = $padding_y; |
| 180 |
|
| 181 |
$w = $this->entry_width; |
| 182 |
$x = 0; |
| 183 |
$entry_height = max($max_height, $this->entry_height); |
| 184 |
|
| 185 |
// make room for title |
| 186 |
if($this->title != '') { |
| 187 |
$title_font_size = $this->title_font_size; |
| 188 |
$svg_text_title = new Text($this->graph, $this->title_font, |
| 189 |
$this->title_font_adjust); |
| 190 |
list($tw, $th) = $svg_text_title->measure($this->title, $title_font_size, |
| 191 |
0, $this->title_line_spacing); |
| 192 |
$title_width = $tw + $padding_x * 2; |
| 193 |
$start_y += $th + $this->graph->getOption('legend_title_spacing', |
| 194 |
'legend_padding', 1); |
| 195 |
} |
| 196 |
|
| 197 |
$columns = max(1, min(ceil($this->columns), $entry_count)); |
| 198 |
$per_column = ceil($entry_count / $columns); |
| 199 |
$columns = ceil($entry_count / $per_column); |
| 200 |
$column = 0; |
| 201 |
|
| 202 |
$text = ['x' => 0]; |
| 203 |
|
| 204 |
$column_entry = 0; |
| 205 |
$y = $start_y; |
| 206 |
$text_columns = array_fill(0, $columns, ''); |
| 207 |
$entry_columns = array_fill(0, $columns, ''); |
| 208 |
$valid_entries = 0; |
| 209 |
$spacing = $this->graph->getOption('legend_spacing', 'legend_padding', 1); |
| 210 |
$entry_space = $this->graph->getOption('legend_entry_spacing', |
| 211 |
'legend_padding', 1); |
| 212 |
$col_space = $this->graph->getOption('legend_column_spacing', |
| 213 |
'legend_padding', 1); |
| 214 |
|
| 215 |
foreach($entries as $entry) { |
| 216 |
$y = $start_y + $column_entry * ($entry_height + $spacing); |
| 217 |
|
| 218 |
// position the graph element |
| 219 |
$e_y = $y + ($entry_height - $this->entry_height) / 2; |
| 220 |
$element = $this->graph->drawLegendEntry($x, $e_y, $w, |
| 221 |
$this->entry_height, $entry); |
| 222 |
if(!empty($element)) { |
| 223 |
// position the text element |
| 224 |
$text['y'] = $y + $baseline + ($entry_height - $entry->height) / 2; |
| 225 |
$text_element = $svg_text->text($entry->text, $this->line_spacing, $text); |
| 226 |
if($entry->link !== null) |
| 227 |
$text_element = $this->graph->getLink($entry, 0, $text_element); |
| 228 |
$text_columns[$column] .= $text_element; |
| 229 |
$entry_columns[$column] .= $element; |
| 230 |
|
| 231 |
++$valid_entries; |
| 232 |
if(++$column_entry == $per_column) { |
| 233 |
$column_entry = 0; |
| 234 |
++$column; |
| 235 |
} |
| 236 |
} |
| 237 |
} |
| 238 |
// if there's nothing to go in the legend, stop now |
| 239 |
if(!$valid_entries) |
| 240 |
return ''; |
| 241 |
|
| 242 |
if($this->text_side == 'left') { |
| 243 |
$text_x_offset = $max_width + $padding_x; |
| 244 |
$entries_x_offset = $max_width + $padding_x + $entry_space; |
| 245 |
} else { |
| 246 |
$text_x_offset = $w + $padding_x + $entry_space; |
| 247 |
$entries_x_offset = $padding_x; |
| 248 |
} |
| 249 |
$longest_width = $padding_x * 2 + ($entry_space * $columns) + |
| 250 |
$col_space * ($columns - 1) + |
| 251 |
($this->entry_width + $max_width) * $columns; |
| 252 |
$column_width = $col_space + $this->entry_width + $entry_space + $max_width; |
| 253 |
$width = max($title_width, $longest_width); |
| 254 |
$height = $start_y + $per_column * ($entry_height + $spacing) - $spacing |
| 255 |
+ $padding_y; |
| 256 |
|
| 257 |
// centre the entries if the title makes the box bigger |
| 258 |
if($width > $longest_width) { |
| 259 |
$offset = ($width - $longest_width) / 2; |
| 260 |
$entries_x_offset += $offset; |
| 261 |
$text_x_offset += $offset; |
| 262 |
} |
| 263 |
|
| 264 |
$xform = new Transform; |
| 265 |
$xform->translate($text_x_offset, 0); |
| 266 |
$text_group = ['transform' => $xform]; |
| 267 |
if($this->text_side == 'left') |
| 268 |
$text_group['text-anchor'] = 'end'; |
| 269 |
$xform = new Transform; |
| 270 |
$xform->translate($entries_x_offset, 0); |
| 271 |
$entries_group = ['transform' => $xform]; |
| 272 |
|
| 273 |
$parts = ''; |
| 274 |
foreach($entry_columns as $col) { |
| 275 |
$parts .= $this->graph->element('g', $entries_group, null, $col); |
| 276 |
$entries_x_offset += $column_width; |
| 277 |
$xform = new Transform; |
| 278 |
$xform->translate($entries_x_offset, 0); |
| 279 |
$entries_group['transform'] = $xform; |
| 280 |
} |
| 281 |
foreach($text_columns as $col) { |
| 282 |
$parts .= $this->graph->element('g', $text_group, null, $col); |
| 283 |
$text_x_offset += $column_width; |
| 284 |
$xform = new Transform; |
| 285 |
$xform->translate($text_x_offset, 0); |
| 286 |
$text_group['transform'] = $xform; |
| 287 |
} |
| 288 |
|
| 289 |
// create box and title |
| 290 |
$box = [ |
| 291 |
'fill' => new Colour($this->graph, $this->back_colour), |
| 292 |
'width' => $width, |
| 293 |
'height' => $height, |
| 294 |
]; |
| 295 |
if($this->round > 0) |
| 296 |
$box['rx'] = $box['ry'] = $this->round; |
| 297 |
if($this->stroke_width) { |
| 298 |
$box['stroke-width'] = $this->stroke_width; |
| 299 |
$box['stroke'] = new Colour($this->graph, $this->stroke_colour); |
| 300 |
} |
| 301 |
$rect = $this->graph->element('rect', $box); |
| 302 |
if($this->title != '') { |
| 303 |
$text['x'] = $width / 2; |
| 304 |
$text['y'] = $padding_y + $svg_text_title->baseline($title_font_size); |
| 305 |
$text['text-anchor'] = 'middle'; |
| 306 |
if($this->title_font != $this->font) |
| 307 |
$text['font-family'] = $this->title_font; |
| 308 |
if($title_font_size != $font_size) |
| 309 |
$text['font-size'] = $title_font_size; |
| 310 |
if($this->title_font_weight != $this->font_weight) |
| 311 |
$text['font-weight'] = $this->title_font_weight; |
| 312 |
if($this->title_colour != $this->colour) |
| 313 |
$text['fill'] = new Colour($this->graph, $this->title_colour); |
| 314 |
$title = $svg_text_title->text($this->title, $this->title_line_spacing, $text); |
| 315 |
if(!empty($this->title_link)) { |
| 316 |
$item = new \stdClass; |
| 317 |
$item->link = $this->title_link; |
| 318 |
$title = $this->graph->getLink($item, 0, $title); |
| 319 |
} |
| 320 |
} |
| 321 |
|
| 322 |
// find position for legend |
| 323 |
if(is_array($this->position)) { |
| 324 |
list($px, $py) = $this->position; |
| 325 |
$vx = Coords::parseValue($px); |
| 326 |
$vy = Coords::parseValue($py); |
| 327 |
$c = new Coords($this->graph); |
| 328 |
list($tx, $ty) = $c->transformCoords($px, $py); |
| 329 |
|
| 330 |
// handle relative positions |
| 331 |
switch($vx['value']) { |
| 332 |
case 'c' : $left = $tx - ($width / 2); break; |
| 333 |
case 'r' : $left = $tx - $width; break; |
| 334 |
default : $left = $tx; |
| 335 |
} |
| 336 |
switch($vy['value']) { |
| 337 |
case 'c' : $top = $ty - ($height / 2); break; |
| 338 |
case 'b' : $top = $ty - $height; break; |
| 339 |
default: $top = $ty; |
| 340 |
} |
| 341 |
} else { |
| 342 |
list($left, $top) = $this->graph->parsePosition($this->position, |
| 343 |
$width, $height); |
| 344 |
} |
| 345 |
|
| 346 |
// create group to contain whole legend |
| 347 |
$xform = new Transform; |
| 348 |
$xform->translate($left, $top); |
| 349 |
$group = [ |
| 350 |
'font-family' => $this->font, |
| 351 |
'font-size' => $font_size, |
| 352 |
'fill' => new Colour($this->graph, $this->colour), |
| 353 |
'transform' => $xform, |
| 354 |
]; |
| 355 |
if($this->font_weight != 'normal') |
| 356 |
$group['font-weight'] = $this->font_weight; |
| 357 |
|
| 358 |
// add shadow if not completely transparent |
| 359 |
if($this->shadow_opacity > 0) { |
| 360 |
$box['x'] = $box['y'] = 2 + ($this->stroke_width / 2); |
| 361 |
$box['fill'] = '#000'; |
| 362 |
$box['opacity'] = $this->shadow_opacity; |
| 363 |
unset($box['stroke'], $box['stroke-width']); |
| 364 |
$rect = $this->graph->element('rect', $box) . $rect; |
| 365 |
} |
| 366 |
|
| 367 |
if($this->autohide) { |
| 368 |
$o0 = $this->autohide_opacity; |
| 369 |
$o1 = 1; |
| 370 |
if($o0 < 0) |
| 371 |
{ |
| 372 |
$o1 = new Number(-$o0); |
| 373 |
$o0 = 1; |
| 374 |
$group['opacity'] = $o1; |
| 375 |
} |
| 376 |
$this->graph->getJavascript()->autoHide($group, $o0, $o1); |
| 377 |
} |
| 378 |
if($this->draggable) |
| 379 |
$this->graph->getJavascript()->setDraggable($group); |
| 380 |
return $this->graph->element('g', $group, null, $rect . $title . $parts); |
| 381 |
} |
| 382 |
|
| 383 |
/** |
| 384 |
* Filters the entry list |
| 385 |
*/ |
| 386 |
protected function filterEntries($entries) |
| 387 |
{ |
| 388 |
$callback = $this->graph->getOption('legend_entries_callback'); |
| 389 |
if(!is_callable($callback)) |
| 390 |
return $entries; |
| 391 |
|
| 392 |
$filtered = call_user_func($callback, $entries); |
| 393 |
$valid = false; |
| 394 |
if(is_array($filtered)) { |
| 395 |
$valid = true; |
| 396 |
foreach($filtered as $e) { |
| 397 |
if(!is_object($e) || get_class($e) !== 'Goat1000\\SVGGraph\\LegendEntry') |
| 398 |
$valid = false; |
| 399 |
} |
| 400 |
} |
| 401 |
|
| 402 |
if(!$valid) |
| 403 |
throw new \Exception('Callback did not return array of LegendEntry values.'); |
| 404 |
|
| 405 |
return $filtered; |
| 406 |
} |
| 407 |
|
| 408 |
/** |
| 409 |
* Returns the list of entries in the correct order |
| 410 |
*/ |
| 411 |
protected function getEntries() |
| 412 |
{ |
| 413 |
$entry_order = $this->graph->getOption('legend_order'); |
| 414 |
if($entry_order === null || $entry_order == 'auto') |
| 415 |
$entry_order = $this->graph->getLegendOrder(); |
| 416 |
|
| 417 |
if(is_array($entry_order)) { |
| 418 |
$entries = []; |
| 419 |
foreach($entry_order as $e) { |
| 420 |
if(isset($this->entry_details[$e])) |
| 421 |
$entries[] = $this->entry_details[$e]; |
| 422 |
} |
| 423 |
return $this->filterEntries($entries); |
| 424 |
} |
| 425 |
|
| 426 |
$entries = $this->entry_details; |
| 427 |
if(!empty($entry_order)) { |
| 428 |
if(strpos($entry_order, 'sort') !== false) { |
| 429 |
usort($entries, function($a, $b) { |
| 430 |
if($a->text == $b->text) |
| 431 |
return 0; |
| 432 |
return $a->text > $b->text ? 1 : -1; |
| 433 |
}); |
| 434 |
} |
| 435 |
|
| 436 |
if(strpos($entry_order, 'reverse') !== false) |
| 437 |
$entries = array_reverse($entries, true); |
| 438 |
} |
| 439 |
|
| 440 |
return $this->filterEntries($entries); |
| 441 |
} |
| 442 |
} |
| 443 |
|
| 444 |
|