<?php
/**
 * Copyright (C) 2015-2023 Graham Breach
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
/**
 * For more information, please contact <graham@goat1000.com>
 */

namespace Goat1000\SVGGraph;

class DataLabels {

  protected $graph;
  protected $have_filters = false;
  private $labels = [];
  private $max_values = [];
  private $min_values = [];
  private $start_indices = [];
  private $end_indices = [];
  private $peak_indices = [];
  private $trough_indices = [];
  private $directions = [];
  private $last = [];
  private $max_labels = 1000;
  private $coords = null;

  private $semantic_classes;
  private $units_before;
  private $units;
  private $filter;
  private $same_size;
  private $callback;

  /**
   * Details of each label type
   */
  private $types_info = [
    'box' => ['shape' => 'boxLabel', 'tail' => false, 'pad' => true],
    'bubble' => ['shape' => 'bubbleLabel', 'tail' => true, 'pad' => true],
    'circle' => ['shape' => 'circleLabel', 'tail' => false, 'pad' => true],
    'line' => ['shape' => 'lineLabel', 'tail' => true, 'pad' => false],
    'line2' => ['shape' => 'lineLabel2', 'tail' => true, 'pad' => true],
    'linebox' => ['shape' => 'boxLineLabel', 'tail' => true, 'pad' => true],
    'linecircle' => ['shape' => 'circleLineLabel', 'tail' => true, 'pad' => true],
    'linesquare' => ['shape' => 'squareLineLabel', 'tail' => true, 'pad' => true],
    'plain' => ['shape' => null, 'tail' => false, 'pad' => false],
    'square' => ['shape' => 'squareLabel', 'tail' => false, 'pad' => true],
  ];

  /**
   * Mapping between label style array members and options
   */
  protected $style_map = [
    'type' => 'data_label_type',
    'font' => 'data_label_font',
    'font_size' => 'data_label_font_size',
    'font_adjust' => 'data_label_font_adjust',
    'font_weight' => 'data_label_font_weight',
    'colour' => 'data_label_colour',
    'altcolour' => 'data_label_colour_outside',
    'back_colour' => 'data_label_back_colour',
    'back_altcolour' => 'data_label_back_colour_outside',
    'space' => 'data_label_space',
    'angle' => 'data_label_angle',
    'pad_x' => 'data_label_padding_x',
    'pad_y' => 'data_label_padding_y',
    'round' => 'data_label_round',
    'stroke' => 'data_label_outline_colour',
    'stroke_width' => 'data_label_outline_thickness',
    'fill' => 'data_label_fill',
    'tail_width' => 'data_label_tail_width',
    'tail_length' => 'data_label_tail_length',
    'tail_end' => 'data_label_tail_end',
    'tail_end_angle' => 'data_label_tail_end_angle',
    'tail_end_width' => 'data_label_tail_end_width',
    'shadow_opacity' => 'data_label_shadow_opacity',
    'opacity' => 'data_label_opacity',
    'line_spacing' => 'data_label_line_spacing',
    'align' => 'data_label_align',
  ];

  /**
   * Options that are colours, so need translation
   */
  protected $colour_options = [
    'colour', 'altcolour', 'back_colour', 'back_altcolour', 'stroke', 'fill',
  ];

  function __construct(&$graph)
  {
    $this->graph =& $graph;
    $this->filter = $graph->getOption('data_label_filter');
    $this->have_filters = (!empty($this->filter) && $this->filter !== 'all');

    $max_labels = $graph->getOption('data_label_max_count');
    if($max_labels >= 1)
      $this->max_labels = (int)$max_labels;
    $this->semantic_classes = $graph->getOption('semantic_classes');
    $this->units_before = $graph->getOption('units_before_label');
    $this->units = $graph->getOption('units_label');
    $this->same_size = $graph->getOption('data_label_same_size');
    $this->callback = $graph->getOption('data_label_callback');
  }

  /**
   * Adds a label to the list
   */
  public function addLabel($dataset, $index, &$item, $x, $y, $w, $h,
    $id = null, $content = null, $fade_in = null, $click = null)
  {
    if(!isset($this->labels[$dataset]))
      $this->labels[$dataset] = [];
    $this->labels[$dataset][$index] = [
      'item' => $item, 'id' => $id, 'content' => $content,
      'x' => $x, 'y' => $y, 'width' => $w, 'height' => $h,
      'fade' => $fade_in, 'click' => $click,
    ];

    if($this->have_filters)
      $this->setupFilters($dataset, $index, $item->value);
  }

  /**
   * Adds a content (non-data) label
   */
  public function addContentLabel($dataset, $index, $x, $y, $w, $h, $content)
  {
    if(!isset($this->labels[$dataset]))
      $this->labels[$dataset] = [];
    $this->labels[$dataset][$index] = [
      'item' => null, 'id' => null, 'content' => $content,
      'x' => $x, 'y' => $y, 'width' => $w, 'height' => $h,
      'fade' => null, 'click' => null,
    ];
  }

  /**
   * Adds a user-defined label from a label option
   */
  public function addUserLabel($label_array)
  {
    if(!isset($this->labels['_user']))
      $this->labels['_user'] = [];

    if(!isset($label_array[0]) || !isset($label_array[1]) || !isset($label_array[2]))
      throw new \Exception('Malformed label option - required fields missing');

    $x = $label_array[0];
    $y = $label_array[1];
    $content = $label_array[2];
    $w = 0;
    $h = 0;
    // merge the options with required fields
    $this->labels['_user'][] = array_merge($label_array, [
      'item' => null, 'id' => null, 'content' => $content,
      'x' => $x, 'y' => $y, 'width' => $w, 'height' => $h,
      'fade' => null, 'click' => null,
    ]);
  }

  /**
   * Returns label details
   */
  public function getLabel($dataset, $index)
  {
    if(isset($this->labels[$dataset][$index]))
      return $this->labels[$dataset][$index];
    return null;
  }

  /**
   * Updates filter information from label
   */
  protected function setupFilters($dataset, $index, $value)
  {
    // set up filtering info
    if(!isset($this->max_values[$dataset]) ||
      $this->max_values[$dataset] < $value)
      $this->max_values[$dataset] = $value;
    if(!isset($this->min_values[$dataset]) ||
      $this->min_values[$dataset] > $value)
      $this->min_values[$dataset] = $value;
    if(!isset($this->start_indices[$dataset]) ||
      $this->start_indices[$dataset] > $index)
      $this->start_indices[$dataset] = $index;
    if(!isset($this->end_indices[$dataset]) ||
      $this->end_indices[$dataset] < $index)
      $this->end_indices[$dataset] = $index;

    // peaks and troughs are a bit more complicated
    if(!isset($this->last[$dataset])) {
      $this->last[$dataset] = [$index, $value];
      $this->directions[$dataset] = null;
      $this->peak_indices[$dataset] = [];
      $this->trough_indices[$dataset] = [];
      return;
    }

    if($this->last[$dataset][1] != $value) {
      $last = $this->last[$dataset];
      $diff = $value - $last[1];
      $direction = ($diff > 0);
      if($this->directions[$dataset] !== null &&
        $direction !== $this->directions[$dataset]) {
        if($diff > 0)
          $this->trough_indices[$dataset][] = $last[0];
        else
          $this->peak_indices[$dataset][] = $last[0];
      }
      $this->last[$dataset] = [$index, $value];
      $this->directions[$dataset] = $direction;
    }
  }


  /**
   * Load user-defined labels
   */
  public function load(&$settings)
  {
    if(!is_array($settings['label']) || !isset($settings['label'][0]))
      throw new \Exception('Malformed label option');

    if(!is_array($settings['label'][0])) {
      $this->addUserLabel($settings['label']);
      $this->coords = new Coords($this->graph);
      return;
    }
    $count = 0;
    foreach($settings['label'] as $label) {
      $this->addUserLabel($label);
      ++$count;
    }
    if($count)
      $this->coords = new Coords($this->graph);
  }

  /**
   * Returns all the labels as a string
   */
  public function getLabels()
  {
    $filter_count = is_array($this->filter) ? count($this->filter) : 1;
    $label_list = [];
    foreach($this->labels as $dataset => $label_set) {

      $set_filter = 'all';
      if(is_numeric($dataset)) {
        $set_filter = is_array($this->filter) ?
          $this->filter[$dataset % $filter_count] : $this->filter;
      }
      $count = 0;
      foreach($label_set as $i => $label) {
        if($this->filter($set_filter, $dataset, $label, $i)) {
          $content = $this->getLabelText($dataset, $label);
          if($content !== null && $content != '') {

            list($w, $h) = $this->measureLabel($content, $dataset, $i, $label);

            $label_list[] = compact('content', 'w', 'h', 'dataset', 'i', 'label');
            if(++$count >= $this->max_labels)
              break;
          }
        }
      }
    }

    $this->setLabelSizes($label_list);
    $labels = '';
    foreach($label_list as $l) {
      $labels .= $this->drawLabel($l['content'], $l['w'], $l['h'],
        $l['dataset'], $l['i'], $l['label']);
    }
    if($labels != '') {
      $group = [];
      if($this->semantic_classes)
        $group['class'] = 'data-labels';
      $labels = $this->graph->element('g', $group, null, $labels);
    }
    return $labels;
  }

  /**
   * Adjusts the label sizes
   */
  protected function setLabelSizes(&$labels)
  {
    if(!$this->same_size)
      return;

    // globally equal sizes
    if(!is_array($this->same_size)) {
      $max_w = 0;
      $max_h = 0;
      foreach($labels as $l) {
        if(is_numeric($l['dataset'])) {
          if($l['w'] > $max_w)
            $max_w = $l['w'];
          if($l['h'] > $max_h)
            $max_h = $l['h'];
        }
      }

      foreach($labels as $k => $l) {
        if(is_numeric($l['dataset'])) {
          $labels[$k]['w'] = $max_w;
          $labels[$k]['h'] = $max_h;
        }
      }
      return;
    }

    // per-dataset maxima (20 datasets should be enough for anybody)
    $max_w = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
    $max_h = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

    foreach($labels as $l) {
      $d = $l['dataset'];
      if(is_numeric($d)) {
        if($l['w'] > $max_w[$d])
          $max_w[$d] = $l['w'];
        if($l['h'] > $max_h[$d])
          $max_h[$d] = $l['h'];
      }
    }

    foreach($labels as $k => $l) {
      $d = $l['dataset'];
      if(is_numeric($d) &&
        $this->graph->getOption(['data_label_same_size', $d])) {
        $labels[$k]['w'] = $max_w[$d];
        $labels[$k]['h'] = $max_h[$d];
      }
    }
  }

  /**
   * Returns the text for a label
   */
  protected function getLabelText($dataset, &$gobject)
  {
    if($gobject['item'] === null && empty($gobject['content']))
      return '';

    if($gobject['item'] === null)
      return (string)$gobject['content'];

    if(is_callable($this->callback)) {
      $content = call_user_func($this->callback, $dataset,
        $gobject['item']->key, $gobject['item']->value);
      return $content === null ? '' : $content;
    }

    $content = $gobject['item']->label;
    if($content !== null)
      return $content;

    if($gobject['content'] !== null)
      return $gobject['content'];

    $n = new Number($gobject['item']->value, $this->units, $this->units_before);
    return $n->format();
  }

  /**
   * Returns the style details for a label
   */
  protected function getStyle($dataset, $index, &$gobject)
  {
    // global styles filled in by graph class
    $style = $this->graph->dataLabelStyle($dataset, $index, $gobject['item']);

    // structured styles and user defined label styles
    if($gobject['item'] !== null)
      $this->itemStyles($style, $gobject['item'], $index, $dataset);
    elseif($dataset === '_user')
      $this->userStyles($style, $gobject);

    // deal with fill/fillColour
    foreach($this->colour_options as $s) {
      if(isset($style[$s]) && is_string($style[$s]) &&
        strpos($style[$s], 'fill') !== false) {
        $cg = new ColourGroup($this->graph, $gobject['item'], $index, $dataset,
          $style[$s], null, null, true);
        $style[$s] = $cg->stroke();
      }
    }
    return $style;
  }

  /**
   * Returns the font size and line spacing for a style as an array
   */
  protected function getFontSize($style)
  {
    $font_size = $line_spacing = max(4, Number::units($style['font_size']));
    if($style['line_spacing'] !== null)
      $line_spacing = max(1, Number::units($style['line_spacing']));
    return [$font_size, $line_spacing];
  }

  /**
   * Returns size of a label as array (w, h)
   */
  protected function measureLabel($content, $dataset, $index, &$gobject)
  {
    $style = $this->getStyle($dataset, $index, $gobject);

    // get size of text
    list($font_size, $line_spacing) = $this->getFontSize($style);
    $svg_text = new Text($this->graph, $style['font'], $style['font_adjust']);
    list($w, $h) = $svg_text->measure($content, $font_size, $style['angle'],
      $line_spacing);

    // if this label type uses padding, add it in
    if($this->getTypeInfo($style['type'], 'pad')) {
      $w += $style['pad_x'] * 2;
      $h += $style['pad_y'] * 2;
    }

    return [$w, $h];
  }

  /**
   * Returns some or all info about a type
   */
  protected function getTypeInfo($type, $field = null)
  {
    if(!isset($this->types_info[$type]))
      $type = 'plain';
    $type_info = $this->types_info[$type];
    return $field === null ? $type_info : $type_info[$field];
  }

  /**
   * Returns the foreground and background colours, dependent on relative
   * position
   */
  protected function getColours($hpos, $vpos, $style)
  {
    // if the position is outside, use the alternative colours
    $colour = new Colour($this->graph, $style['colour']);
    $back_colour = new Colour($this->graph, $style['back_colour']);
    if(strpos($hpos . $vpos, 'o') !== false) {
      $alt = new Colour($this->graph, $style['altcolour']);
      $back_alt = new Colour($this->graph, $style['back_altcolour']);
      if(!$alt->isNone())
        $colour = $alt;
      if(!$back_alt->isNone())
        $back_colour = $back_alt;
    }
    return [$colour, $back_colour];
  }

  /**
   * Draws a label
   */
  protected function drawLabel($content, $label_w, $label_h, $dataset, $index,
    &$gobject)
  {
    $style = $this->getStyle($dataset, $index, $gobject);
    $style['target'] = [$gobject['x'], $gobject['y']];

    $space = (float)$style['space'];
    $pos = null;
    if($dataset === '_user') {
      // user label, so convert coordinates
      $pos = isset($gobject['position']) ? $gobject['position'] : 'above';
      $xy = $this->coords->transformCoords($gobject['x'], $gobject['y']);
      $gobject['x'] = $xy[0];
      $gobject['y'] = $xy[1];
      $style['target'] = [$gobject['x'], $gobject['y']];
    } else {
      // try to get position from item
      if($gobject['item'] !== null)
        $pos = $gobject['item']->data_label_position;

      // find out from graph class where this label should go
      if($pos === null) {
        $label_wp = $label_w + $space * 2;
        $label_hp = $label_h + $space * 2;

        // get the label position and the target for tail
        list($pos, $target) = $this->graph->dataLabelPosition($dataset,
          $index, $gobject['item'], $gobject['x'], $gobject['y'],
          $gobject['width'], $gobject['height'], $label_wp, $label_hp);
        $style['target'] = $target;
      }
    }

    // convert position string to an actual location
    list($x, $y, $anchor, $hpos, $vpos) = Graph::relativePosition($pos,
      $gobject['y'], $gobject['x'],
      $gobject['y'] + $gobject['height'], $gobject['x'] + $gobject['width'],
      $label_w, $label_h, $space, true);

    list($colour, $back_colour) = $this->getColours($hpos, $vpos, $style);
    list($font_size, $line_spacing) = $this->getFontSize($style);
    $text = [
      'font-family' => $style['font'],
      'font-size' => $font_size,
      'fill' => $colour,
    ];

    $label_markup = '';
    $label_pad_x = $label_pad_y = 0;
    $type_info = $this->getTypeInfo($style['type']);
    if($type_info['pad']) {
      $label_pad_x = $style['pad_x'];
      $label_pad_y = $style['pad_y'];
    }

    // need text size without padding, rotation, etc.
    $svg_text = new Text($this->graph, $style['font'], $style['font_adjust']);
    list($tbw, $tbh) = $svg_text->measure($content, $font_size, 0, $line_spacing);
    $text_baseline = $svg_text->baseline($font_size);

    // allow overriding text alignment
    $align_map = ['left' => 'start', 'centre' => 'middle', 'right' => 'end'];
    if($style['align'] !== null && isset($align_map[$style['align']])) {
      $anchor_new = $align_map[$style['align']];
      if($anchor_new != $anchor) {
        // adjust label position for new anchor
        $pos_remap = [
          'middle' => ['start' => -0.5, 'end' => 0.5],
          'start' => ['middle' => 0.5, 'end' => 1.0],
          'end' => ['start' => -1.0, 'middle' => -0.5],
        ];

        $x += ($label_w * $pos_remap[$anchor][$anchor_new]);
        $anchor = $anchor_new;
      }
    }

    $text['y'] = $y + ($label_h - $tbh) / 2 + $text_baseline;
    if($style['angle'] != 0) {

      if($anchor == 'middle') {
        $text['x'] = $x;
      } elseif($anchor == 'start') {
        $text['x'] = $x + ($label_w - $tbw) / 2;
      } else {
        $text['x'] = $x - ($label_w - $tbw) / 2;
      }

    } else {

      if($anchor == 'start') {
        $text['x'] = $x + $label_pad_x;
      } elseif($anchor == 'end') {
        $text['x'] = $x - $label_pad_x;
      } else {
        $text['x'] = $x;
      }
    }

    // make x right for bounding box
    if($anchor == 'middle') {
      $x -= $label_w / 2;
    } elseif($anchor == 'end') {
      $x -= $label_w;
    }

    if($style['angle'] != 0) {
      // rotate text around centre of box
      $rx = $x + $label_w / 2;
      $ry = $y + $label_h / 2;
      $xform = new Transform;
      $xform->rotate($style['angle'], $rx, $ry);
      $text['transform'] = $xform;
    }

    if($anchor != 'start')
      $text['text-anchor'] = $anchor;
    if(!empty($style['font_weight']) && $style['font_weight'] != 'normal')
      $text['font-weight'] = $style['font_weight'];

    $surround = [];
    $element = null;
    $shape_func = null;
    $need_tail = false;

    if($type_info['shape']) {
      if($type_info['tail']) {
        $style['tail_direction'] = $this->graph->dataLabelTailDirection($dataset,
          $index, $hpos, $vpos);
      }

      // make the shape
      $element = $this->{$type_info['shape']}($x, $y, $label_w, $label_h,
        $style, $surround);
      if($element) {
        $surround['stroke'] = new Colour($this->graph, $style['stroke']);
        if($style['stroke_width'] != 1)
          $surround['stroke-width'] = (float)$style['stroke_width'];

        // add shadow if not completely transparent
        if($style['shadow_opacity'] > 0) {
          $shadow = $surround;
          $offset = 2 + floor($style['stroke_width'] / 2);
          $xform = new Transform;
          $xform->translate($offset, $offset);
          $shadow['transform'] = $xform;
          $shadow['fill'] = $shadow['stroke'] = '#000';
          $shadow['opacity'] = $style['shadow_opacity'];
          $label_markup .= $this->graph->element($element, $shadow);
        }
        $label_markup .= $this->graph->element($element, $surround);
      }
    }

    //$back_colour = new Colour($this, $back_colour);
    if(!$back_colour->isNone()) {
      $outline = [
        'stroke-width' => '3px',
        'stroke' => $back_colour,
        'stroke-linejoin' => 'round',
      ];
      $t1 = array_merge($outline, $text);
      $label_markup .= $svg_text->text($content, $line_spacing, $t1);
    }
    $label_markup .= $svg_text->text($content, $line_spacing, $text);

    $group = [];
    if(isset($gobject['id']) && $gobject['id'] !== null)
      $group['id'] = $gobject['id'];

    // opacity is required when set or using click-show-hide
    $opacity = max(0, min(1, $style['opacity']));
    if($opacity < 1 || $gobject['click'] == 'show')
      $group['opacity'] = $opacity;
    elseif($gobject['click'] == 'hide' || $gobject['fade'])
      $group['opacity'] = 0;

    $label_markup = $this->graph->element('g', $group, null, $label_markup);
    return $label_markup;
  }

  /**
   * Returns the mapping between style members and option names
   */
  public function getStyleMap()
  {
    return $this->style_map;
  }

  /**
   * Individual label styles from the structured data item
   */
  protected function itemStyles(&$style, &$item, $index, $dataset)
  {
    // overwrite any style options that the item has set
    $v = $item->data_label_padding;
    if($v !== null)
      $style['pad_x'] = $style['pad_y'] = $v;
    foreach($this->style_map as $s => $k) {
      $v = $item->data($k);
      if($v !== null)
        $style[$s] = $v;
    }
  }

  /**
   * Styles from the label option
   */
  protected function userStyles(&$style, &$label_array)
  {
    // pad_x and pad_y will override single padding option
    if(isset($label_array['padding']))
      $style['pad_x'] = $style['pad_y'] = $label_array['padding'];
    foreach($this->style_map as $s => $k) {
      // remove the 'data_label_' part
      $o = substr($k, 11);
      if(isset($label_array[$o]))
        $style[$s] = $label_array[$o];
    }
  }

  /**
   * Returns TRUE if the label should be shown
   */
  protected function filter($filter, $dataset, &$label, $index)
  {
    // non-numeric datasets are for additional labels,
    // empty filter does nothing
    if(!is_numeric($dataset) || empty($filter))
      return true;

    $item =& $label['item'];

    // if the item has a show_label member, use it
    $struct_show = $item->show_label;
    if($struct_show !== null)
      return $struct_show;

    // if 'all' is in the list, others don't matter
    $filters = explode(' ', $filter);
    if(in_array('all', $filters, true))
      return true;

    // an array of closures for filter tests
    $tests = [
      'start' => function() use ($index, $dataset) {
        return $index == $this->start_indices[$dataset]; },
      'end' => function() use ($index, $dataset) {
        return $index == $this->end_indices[$dataset]; },
      'max' => function() use (&$item, $dataset) {
        return $item->value == $this->max_values[$dataset]; },
      'min' => function() use (&$item, $dataset) {
        return $item->value == $this->min_values[$dataset]; },
      'peaks' => function() use ($index, $dataset) {
        return in_array($index, $this->peak_indices[$dataset], true); },
      'troughs' => function() use ($index, $dataset) {
        return in_array($index, $this->trough_indices[$dataset], true); },
      'nonzero' => function() use (&$item) { return $item->value != 0; },
      'none' => function() use (&$item) {
        return $item->label !== null && $item->label !== ''; },
    ];

    foreach($filters as $f) {

      if(isset($tests[$f]) && $tests[$f]())
        return true;

      // integer step
      if(is_numeric($f) && $index % (int)$f == 0) {
        return true;
      } else {
        // step with offset
        $parts = explode('+', $f);
        if(count($parts) == 2 &&
          is_numeric($parts[0]) && is_numeric($parts[1]) &&
          $parts[0] > 1 && $parts[1] < $parts[0] &&
          $index % (int)$parts[0] == $parts[1])
          return true;
      }
    }

    // default is to show nothing
    return false;
  }


  /**
   * Straight line label style
   */
  protected function lineLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $w2 = $w / 2;
    $h2 = $h / 2;
    $a = $style['tail_direction'] * M_PI / 180;

    if($style['round']) {
      $bbradius = sqrt($w2 * $w2 + $h2 * $h2);
      $w2a = $bbradius * cos($a);
      $h2a = $bbradius * sin($a);
    } else {
      // start at edge of text bounding box
      $w2a = $w2;
      $h2a = $w2 * tan($a);
      if(abs($h2a) > $h2) {
        $h2a = $h2;
        $w2a = $h2 / tan($a);
      }
    }
    if(($a < M_PI && $h2a < 0) || ($a > M_PI && $h2a > 0)) {
      $h2a = -$h2a;
      $w2a = -$w2a;
    }

    $x1 = $x + $w2 + $w2a;
    $y1 = $y + $h2 + $h2a;
    if($style['tail_length'] == 'auto') {
      list($x2, $y2) = $style['target'];
      // check line is outside bbox
      if($style['round']) {
        $llen = sqrt(pow($x2 - $x - $w2, 2) + pow($y2 - $y - $h2, 2));
        if($llen < $bbradius)
          return '';
      } else {
        if($x2 > $x && $x2 < $x + $w && $y2 > $y && $y2 < $y + $h)
          return '';
      }
    } else {
      // make sure line is long enough to not look like part of text
      list($font_size) = $this->getFontSize($style);
      $llen = max($font_size, $style['tail_length']);
      $x2 = $x1 + ($llen * cos($a));
      $y2 = $y1 + ($llen * sin($a));
    }
    $surround['d'] = new PathData('M', $x1, $y1, 'L', $x2, $y2);
    return 'path';
  }

  /**
   * Simple box label style
   */
  protected function boxLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $surround['x'] = $x;
    $surround['y'] = $y;
    $surround['width'] = $w;
    $surround['height'] = $h;
    if($style['round'])
      $surround['rx'] = $surround['ry'] = min((float)$style['round'],
        $h / 2, $w / 2);
    $surround['fill'] = new Colour($this->graph, $style['fill']);
    return 'rect';
  }

  /**
   * Speech bubble label style
   */
  protected function bubbleLabel($x, $y, $w, $h, &$style, &$surround)
  {
    // can't be more round than this!
    $round = min((float)$style['round'], $h / 3, $w / 3);
    $drop = max(2, (float)$style['tail_length']);
    $spread = min(max(2, (float)$style['tail_width']), $w - $round * 2);

    $vert = $h - $round * 2;
    $horz = $w - $round * 2;
    $start = new PathData('M', ($x + $w - $round), $y);
    $t = new PathData('z');
    $r = new PathData('v', $vert);
    $b = new PathData('h', -$horz);
    $l = new PathData('v', -$vert);
    $tr = new PathData;
    $br = new PathData;
    $bl = new PathData;
    $tl = new PathData;
    if($round) {
      $tr->add('a', $round, $round, 90, 0, 1, $round, $round);
      $br->add('a', $round, $round, 90, 0, 1, -$round, $round);
      $bl->add('a', $round, $round, 90, 0, 1, -$round, -$round);
      $tl->add('a', $round, $round, 90, 0, 1, $round, -$round);
    }

    $direction = floor(($style['tail_direction'] + 22.5) * 8 / 360) % 8;
    $ddrop = 0.707 * $drop; // cos 45
    $p1 = $ddrop + $spread * 0.707;
    $s2 = $spread / 2;
    $vcropped = $vert - $spread * 0.707 + $round;
    $hcropped = $horz - $spread * 0.707 + $round;
    switch($direction) {
    case 0 :
      $bside = $h / 2 - $s2 - $round;
      $r = new PathData('v', $bside, 'l', $drop, $s2, 'l', -$drop, $s2, 'v', $bside);
      break;
    case 1 :
      $r = new PathData('v', $vcropped);
      $br = new PathData('l', $ddrop, $p1, 'l', -$p1, -$ddrop);
      $b = new PathData('h', -$hcropped);
      break;
    case 2 :
      $bside = $w / 2 - $s2 - $round;
      $b = new PathData('h', -$bside, 'l', -$s2, $drop, 'l', -$s2,  -$drop, 'h', -$bside);
      break;
    case 3 :
      $l = new PathData('v', -$vcropped);
      $bl = new PathData('l', -$p1, $ddrop, 'l', $ddrop, -$p1);
      $b = new PathData('h', -$hcropped);
      break;
    case 4 :
      $bside = $h / 2 - $s2 - $round;
      $l = new PathData('v', -$bside, 'l', -$drop, -$s2, 'l', $drop, -$s2, 'v', -$bside);
      break;
    case 5 :
      $l = new PathData('v', -$vcropped);
      $tl = new PathData('l', -$ddrop, -$p1, 'l', $p1, $ddrop);
      break;
    case 6 :
      $bside = $w / 2 - $s2 - $round;
      $t = new PathData('h', $bside, 'l', $s2, -$drop, 'l', $s2, $drop, 'z');
      break;
    case 7 :
      $start = new PathData('M', ($x + $hcropped + $round), $y);
      $r = new PathData('v', $vcropped);
      $tr = new PathData('l', $p1, -$ddrop, 'l', -$ddrop, $p1);
      break;
    }
    $start->add($tr);
    $start->add($r);
    $start->add($br);
    $start->add($b);
    $start->add($bl);
    $start->add($l);
    $start->add($tl);
    $start->add($t);
    $surround['d'] = $start;
    $surround['fill'] = new Colour($this->graph, $style['fill']);
    return 'path';
  }

  /**
   * Returns the cx, cy and radius for a round label
   */
  protected function calcRoundLabel($x, $y, $w, $h)
  {
    $w2 = $w / 2;
    $h2 = $h / 2;
    $r = sqrt($w2 * $w2 + $h2 * $h2);
    return [$x + $w2, $y + $h2, $r];
  }

  /**
   * Circular label style
   */
  protected function circleLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $params = $this->calcRoundLabel($x, $y, $w, $h);
    $surround['cx'] = $params[0];
    $surround['cy'] = $params[1];
    $surround['r'] = $params[2];
    $surround['fill'] = new Colour($this->graph, $style['fill']);
    return 'circle';
  }

  /**
   * Returns the tail target coordinates, angle and length for a box label,
   * or NULL if the tail would end inside the label.
   * Return value is array(array($x, $y), $angle, $length)
   */
  protected function getBoxTailTarget(&$style, $x1, $y1, $x2, $y2)
  {
    $target = null;
    $length = $style['tail_length'];
    $cx = ($x1 + $x2) / 2;
    $cy = ($y1 + $y2) / 2;
    $w2 = $cx - $x1;
    $h2 = $cy - $y1;
    if($length == 'auto') {
      // just use the defined target
      $target = $style['target'];

      // check that target is outside the label
      $tx = $target[0]; $ty = $target[1];
      if($tx >= $x1 && $tx <= $x2 && $ty >= $y1 && $ty <= $y2)
        return null;

      if($tx > $x2) {
        $dx = $tx - $x2;
      } elseif($tx < $x1) {
        $dx = $x1 - $tx;
      } else {
        $dx = abs($tx - $cx);
      }
      if($ty > $y2) {
        $dy = $ty - $y2;
      } elseif($ty < $y1) {
        $dy = $y1 - $ty;
      } else {
        $dy = abs($ty - $cy);
      }
      $length = sqrt($dx * $dx + $dy * $dy);
      $angle = atan2($ty - $cy, $tx - $cx);
    } else {
      // target is radius + tail length away
      if($length <= 0)
        return null;
      $angle = $style['tail_direction'] * M_PI / 180;

      $lx = $length * cos($angle);
      $ly = $length * sin($angle);
      // compare tangent with box ratio
      if(abs(tan($angle)) > $h2 / $w2) {
        // out top or bottom
        $target = [
          $cx + $lx,
          $ly > 0 ? $y2 + $ly : $y1 + $ly
        ];
      } else {
        // out left or right
        $target = [
          $lx > 0 ? $x2 + $lx : $x1 + $lx,
          $cy + $ly
        ];
      }
    }
    return [$target, $angle, $length];
  }

  /**
   * Returns the tail target coordinates, angle and length for a round label,
   * or NULL if the tail would end inside the label.
   * Return value is array(array($x, $y), $angle, $length)
   */
  protected function getRoundTailTarget(&$style, $cx, $cy, $radius)
  {
    $target = null;
    $length = $style['tail_length'];
    if($length == 'auto') {
      // just use the defined target
      $target = $style['target'];

      // check that target is outside the label radius
      $tx = $target[0] - $cx;
      $ty = $target[1] - $cy;
      $rt = sqrt($tx * $tx + $ty * $ty);
      if($rt <= $radius)
        return null;
      $angle = atan2($ty, $tx);
      $length = $rt - $radius;
    } else {
      // target is radius + tail length away
      if($length <= 0)
        return null;
      $len = $radius + $length;
      $angle = $style['tail_direction'] * M_PI / 180;
      $target = [$cx + ($len * cos($angle)), $cy + ($len * sin($angle))];
    }
    return [$target, $angle, $length];
  }

  /**
   * Rotate and translate $x and $y, returning Point
   */
  private static function xForm($x, $y, $a, $tx, $ty)
  {
    if($x == 0 && $y == 0)
      return new Point($tx, $ty);
    $sa = sin($a);
    $ca = cos($a);
    $x1 = $x * $ca - $y * $sa;
    $y1 = $x * $sa + $y * $ca;
    return new Point($tx + $x1, $ty + $y1);
  }

  /**
   * Returns the tail ending path fragment
   */
  protected function getTailEnding($x, $y, $langle, $lwidth, $dist, &$style)
  {
    $a = max(5, min(80, $style['tail_end_angle']));
    $ewidth = max($lwidth, $style['tail_end_width']);
    $eangle = M_PI * $a / 180;

    // first fallback is a tapering line
    $fallback = new PathData('L', $x, $y);
    $lw = $lwidth * 0.5;
    $ew = $ewidth * 0.5;
    $ll = $lw / tan($eangle);
    $el = $ew / tan($eangle);

    // ends are defined pointing upwards
    $langle -= M_PI * 0.5;
    $type = $style['tail_end'];

    // 'point' style by default
    $points = [ [-$lw, -$ll], [0, 0], [$lw, -$ll] ];

    switch($type)
    {
    default:
    case 'flat' :
      $points = [ [-$lw, 0], [$lw, 0] ];
      break;
    case 'taper' :
      return $fallback;
    case 'point' :
      if($dist <= $ll)
        return $fallback;
      break;

    case 'filled' :
      if($dist <= $el)
        return $fallback;
      if($ew > $lw) {
        $points = [
          [-$lw, -$el], [-$ew, -$el], [0, 0], [$ew, -$el], [$lw, -$el]
        ];
      }
      break;
    case 'arrow' :
      $tip_w = $lwidth * sin($eangle);
      $w1 = $ew - $tip_w;
      $l2 = $el + $lwidth * cos($eangle);
      $l1 = $l2 - ($ew - $tip_w - $lw) / tan($eangle);
      if($dist < $l2)
        return $fallback;
      if($w1 > $lw) {
        $points = [
          [-$lw, -$l1], [-$w1, -$l2], [-$ew, -$el],
          [0, 0],
          [$ew, -$el], [$w1, -$l2], [$lw, -$l1]
        ];
        break;
      }
      // fall through to diamond shape if not wide enough

    case 'diamond' :
      $blen = 2 * $el - $ll;
      if($dist <= $blen)
        return $fallback;
      if($ew > $lw) {
        $points = [
          [-$lw, -$blen], [-$ew, -$el], [0,0], [$ew, -$el], [$lw, -$blen]
        ];
      }
      break;
    case 'tee' :
      if($dist <= $lwidth)
        return $fallback;
      $points = [
        [-$lw, -$lwidth], [-$ew, -$lwidth], [-$ew, 0],
        [$ew, 0], [$ew, -$lwidth], [$lw, -$lwidth]
      ];
      break;
    case 'round' :
      if($dist < $lw)
        return $fallback;
      $cradius = min($ew, max($lw, $dist / 2));
      $rlen = sqrt(($cradius * $cradius) - ($lw * $lw));
      $rdist = $cradius + $rlen;
      $p1 = $this->xForm(-$lw, -$rdist, $langle, $x, $y);
      $p2 = $this->xForm($lw, -$rdist, $langle, $x, $y);
      return new PathData('L', $p1, 'A', $cradius, $cradius, 0, 1, 0, $p2);
    }

    $path = new PathData;
    foreach($points as $pair) {
      $pt = $this->xForm($pair[0], $pair[1], $langle, $x, $y);
      $path->add('L', $pt);
    }
    return $path;
  }

  /**
   * Returns the point where a line crosses an arc
   */
  private function lineCrossArc($x, $y, $angle, $cx, $cy, $radius, $corner)
  {
    $h = $cx;
    $k = $cy;
    $r = $radius;

    // 90-degree angles are simpler
    $cos = abs(cos($angle));
    $sin = abs(sin($angle));
    if($cos == 1 || $sin == 1) {
      if($cos == 1) {
        $rt = sqrt(-($y * $y) + (2 * $y * $k) - ($k * $k) + ($r * $r));
        $y1 = $y2 = $y;
        $x1 = $h - $rt;
        $x2 = $h + $rt;
      } else {
        $rt = sqrt(-($x * $x) + (2 * $x * $h) - ($h * $h) + ($r * $r));
        $x1 = $x2 = $x;
        $y1 = $k - $rt;
        $y2 = $k + $rt;
      }
    } else {
      // y = mx + c
      $m = tan($angle);
      $c = $y - ($x * $m);

      // using quadratic formula
      $disc = -($c * $c) - (2 * $c * $h * $m) + (2 * $c * $k)
        - ($h * $h * $m * $m) + (2 * $h * $k * $m) - ($k * $k)
        + ($m * $m * $r * $r) + ($r * $r);
      $rt = sqrt($disc);
      $b = (-$c * $m) + $h + ($k * $m);
      $d = ($m * $m) + 1;

      $x1 = (-$rt + $b) / $d;
      $x2 = ($rt + $b) / $d;

      // y = mx + c again, using original x and y
      $y1 = $m * $x1 + $c;
      $y2 = $m * $x2 + $c;
    }

    $use_first = false;
    switch($corner) {
    case 'tr' :
      if($x1 > $cx && $y1 < $cy)
        $use_first = true;
      break;
    case 'tl' :
      if($x1 < $cx && $y1 < $cy)
        $use_first = true;
      break;
    case 'br' :
      if($x1 > $cx && $y1 > $cy)
        $use_first = true;
      break;
    case 'bl' :
      if($x1 < $cx && $y1 > $cy)
        $use_first = true;
    }
    if($use_first)
      return new Point($x1, $y1);
    return new Point($x2, $y2);
  }

  /**
   * Filled line label
   */
  protected function lineLabel2($x, $y, $w, $h, &$style, &$surround)
  {
    if($style['round']) {
      list($cx, $cy, $bbradius) = $this->calcRoundLabel($x, $y, $w, $h);
      list($target, $angle, $len) = $this->getRoundTailTarget($style, $cx, $cy,
        $bbradius);
    } else {
      list($target, $angle, $len) = $this->getBoxTailTarget($style, $x, $y, $x + $w,
        $y + $h);
    }
    if($target === null)
      return null;

    if($style['round']) {
      $t_width = max(1, min($style['tail_width'], $bbradius));
      $l_angle = asin($t_width * 0.5 / $bbradius);
      $p1 = new Point($cx + $bbradius * cos($angle - $l_angle),
        $cy + $bbradius * sin($angle - $l_angle));
      $p2 = new Point($cx + $bbradius * cos($angle + $l_angle),
        $cy + $bbradius * sin($angle + $l_angle));
    } else {

      $h2 = $h * 0.5; $w2 = $w * 0.5;
      $cx = $x + $w2; $cy = $y + $h2;
      $t_width = max(1, min((float)$style['tail_width'], $w - 1, $h - 1));
      $sin = sin($angle);
      $cos = cos($angle);
      $xo = $yo = 0;
      if(abs($sin) == 1) {
        $py = $cy + $h2 * $sin;
        $px = $cx;
        $xo = $t_width * 0.5;
      } elseif(abs($cos) == 1) {
        $px = $cx + $w2 * $cos;
        $py = $cy;
        $yo = $t_width * 0.5;
      } else {
        $h1 = abs($w2 * tan($angle));
        if($h1 >= $h2) {
          $h1 = ($sin < 0 ? -$h2 : $h2);
          $w1 = $h1 * tan(M_PI * 0.5 - $angle);
        } else {
          $w1 = ($cos < 0 ? -$w2 : $w2);
          $h1 = $w1 / tan(M_PI * 0.5 - $angle);
        }
        $px = $cx + $w1;
        $py = $cy + $h1;
        $xo = $t_width * 0.5 * $sin;
        $yo = $t_width * -0.5 * $cos;
      }

      $p1 = new Point($px + $xo, $py + $yo);
      $p2 = new Point($px - $xo, $py - $yo);
      $l1 = $px - $target[0];
      $l2 = $py - $target[1];
      $len = sqrt($l1 * $l1 + $l2 * $l2);
    }

    $surround['fill'] = new Colour($this->graph, $style['fill']);
    $path = new PathData('M', $p1, 'L', $p2);
    $path->add($this->getTailEnding($target[0], $target[1], $angle, $t_width,
      $len, $style));
    $path->add('z');
    $surround['d'] = $path;
    return 'path';
  }

  /**
   * Line and box label style
   */
  protected function boxLineLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $x1 = $x; $y1 = $y;
    $x2 = $x + $w; $y2 = $y + $h;
    list($target, $angle, $len) = $this->getBoxTailTarget($style, $x1, $y1,
      $x2, $y2);
    if($target === null)
      return $this->boxLabel($x, $y, $w, $h, $style, $surround);

    $round = min((float)$style['round'], $h / 3, $w / 3);
    $t_width = max(1, min((float)$style['tail_width'], $w - 1, $h - 1));
    $cx = ($x1 + $x2) / 2;
    $cy = ($y1 + $y2) / 2;

    while($angle < 0)
      $angle += M_PI * 2.0;

    // centre points of corner arcs
    $x1c = $x1 + $round;
    $x2c = $x2 - $round;
    $y1c = $y1 + $round;
    $y2c = $y2 - $round;

    // box corners
    if($round) {
      $arc = new PathData('a', $round, $round, 0, 0, 0);
      $c_tl = new PathData('L', $x1c, $y1);
      $c_tl->add($arc);
      $c_tl->add(-$round, $round);
      $c_tr = new PathData('L', $x2, $y1c);
      $c_tr->add($arc);
      $c_tr->add(-$round, -$round);
      $c_bl = new PathData('L', $x1, $y2c);
      $c_bl->add($arc);
      $c_bl->add($round, $round);
      $c_br = new PathData('L', $x2c, $y2);
      $c_br->add($arc);
      $c_br->add($round, -$round);
      // this gets repeated a lot
      $arc = new PathData('A', $round, $round, 0, 0, 0);
    } else {
      $c_tl = new PathData('L', $x1, $y1);
      $c_tr = new PathData('L', $x2, $y1);
      $c_bl = new PathData('L', $x1, $y2);
      $c_br = new PathData('L', $x2, $y2);
    }
    $points = [];
    $rangle = M_PI * 0.5 - $angle;
    if(abs(tan($angle)) > $h / $w) {
      // top or bottom
      // $hoff = horizontal offset from centre of edge
      // $wa = width at angle
      $hoff = $h * 0.5 * tan($rangle);
      $wa = $t_width * 0.5 / cos($rangle);
      if($angle > M_PI) {
        // top
        $p1 = new Point($cx - $hoff + $wa, $y1);
        if($p1->x < $x1) {
          $p1->y = $y1 + ($x1 - $p1->x) * tan($angle);
          $p1->x = $x1;
        }
        $p2 = new Point($cx - $hoff - $wa, $y1);
        if($p2->x > $x2) {
          $p2->y = $y1 - ($p2->x - $x2) * tan($angle);
          $p2->x = $x2;
        }
        $start = new PathData('M', $p1);
        $end = new PathData('L', $p2);
        // if the line meets the side past the corner radius, there is no corner
        if($p1->y > $y1c)
          $c_tl->clear();
        if($p2->y > $y1c)
          $c_tr->clear();
        if($round) {
          if(!$c_tl->isEmpty() && $p1->x < $x1c) {
            $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x1c, $y1c, $round, 'tl');
            $start = new PathData('M');
            $start->add($cross);
            $c_tl = new PathData($arc);
            $c_tl->add($x1, $y1c);
            if($p2->x < $x1c) {
              $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x1c, $y1c, $round, 'tl');
              $end = new PathData('L', $x1c, $y1);
              $end->add($arc);
              $end->add($cross);
            }
          }
          if(!$c_tr->isEmpty() && $x2c < $p2->x) {
            $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x2c, $y1c, $round, 'tr');
            $end = new PathData($arc);
            $end->add($cross);
            $c_tr = new PathData('L', $x2, $y1c);
            if($x2c < $p1->x) {
              $cross = $this->lineCrossArc($p1->x, $p2->y, $angle, $x2c, $y1c, $round, 'tr');
              $start = new PathData('M');
              $start->add($cross);
              $start->add($arc);
              $start->add($x2c, $y1);
            }
          }
        }
        $points = [$start, $c_tl, $c_bl, $c_br, $c_tr, $end];
        $distance = $y1 - $target[1];
      } else {
        // bottom
        $p1 = new Point($cx + $hoff + $wa, $y2);
        if($p1->x > $x2) {
          $p1->y = $y2 - ($p1->x - $x2) * tan($angle);
          $p1->x = $x2;
        }
        $p2 = new Point($cx + $hoff - $wa, $y2);
        if($p2->x < $x1) {
          $p2->y = $y2 + ($x1 - $p2->x) * tan($angle);
          $p2->x = $x1;
        }
        $start = new PathData('M', $p1);
        $end = new PathData('L', $p2);
        if($p1->y < $y2c)
          $c_br->clear();
        if($p2->y < $y2c)
          $c_bl->clear();
        if($round) {
          if(!$c_bl->isEmpty() && $p2->x < $x1c) {
            $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x1c, $y2c, $round, 'bl');
            $end = new PathData($arc);
            $end->add($cross);
            $c_bl = new PathData('L', $x1, $y2c);
            if($p1->x < $x1c) {
              $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x1c, $y2c, $round, 'bl');
              $start = new PathData('M');
              $start->add($cross);
              $start->add($arc);
              $start->add($x1c, $y2);
            }
          }
          if(!$c_br->isEmpty() && $x2c < $p1->x) {
            $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x2c, $y2c, $round, 'br');
            $start = new PathData('M');
            $start->add($cross);
            $c_br = new PathData($arc);
            $c_br->add($x2, $y2c);
            if($x2c < $p2->x) {
              $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x2c, $y2c, $round, 'br');
              $end = new PathData('L', $x2c, $y2);
              $end->add($arc);
              $end->add($cross);
            }
          }
        }
        $points = [$start, $c_br, $c_tr, $c_tl, $c_bl, $end];
        $distance = $target[1] - $y2;
      }
    } else {
      // either side
      // $voff = vertical offset from centre of side
      // $wa = width at angle
      $voff = $w * 0.5 * tan($angle);
      $wa = $t_width * 0.5 / cos($angle);
      if($angle < M_PI * 0.5 || $angle > M_PI * 1.5) {
        // right
        $p1 = new Point($x2, $cy + $voff - $wa);
        if($p1->y < $y1) {
          $p1->x = $x2 + ($y1 - $p1->y) * tan($rangle);
          $p1->y = $y1;
        }
        $p2 = new Point($x2, $cy + $voff + $wa);
        if($p2->y > $y2) {
          $p2->x = $x2 - ($p2->y - $y2) * tan($rangle);
          $p2->y = $y2;
        }
        $start = new PathData('M', $p1);
        $end = new PathData('L', $p2);
        if($p1->x < $x2c)
          $c_tr->clear();
        if($p2->x < $x2c)
          $c_br->clear();
        if($round) {
          if(!$c_br->isEmpty() && $y2c < $p2->y) {
            $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x2c, $y2c, $round, 'br');
            $end = new PathData($arc);
            $end->add($cross);
            $c_br = new PathData('L', $x2c, $y2);
            if($y2c < $p1->y) {
              $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x2c, $y2c, $round, 'br');
              $start = new PathData('M');
              $start->add($cross);
              $start->add($arc);
              $start->add($x2, $y2c);
            }
          }
          if(!$c_tr->isEmpty() && $p1->y < $y1c) {
            $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x2c, $y1c, $round, 'tr');
            $start = new PathData('M');
            $start->add($cross);
            $c_tr = new PathData($arc);
            $c_tr->add($x2c, $y1);
            if($p2->y < $y1c) {
              $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x2c, $y1c, $round, 'tr');
              $end = new PathData('L', $x2, $y1c);
              $end->add($arc);
              $end->add($cross);
            }
          }
        }
        $points = [$start, $c_tr, $c_tl, $c_bl, $c_br, $end];
        $distance = $target[0] - $x2;
      } else {
        // left
        $p1 = new Point($x1, $cy - $voff - $wa);
        if($y2 < $p1->y) {
          $p1->x = $x1 + ($y2 - $p1->y) * tan($rangle);
          $p1->y = $y2;
        }
        $p2 = new Point($x1, $cy - $voff + $wa);
        if($p2->y < $y1) {
          $p2->x = $x1 - ($p2->y - $y1) * tan($rangle);
          $p2->y = $y1;
        }
        $start = new PathData('M', $p1);
        $end = new PathData('L', $p2);
        if($p1->x > $x1c)
          $c_bl->clear();
        if($p2->x > $x1c)
          $c_tl->clear();
        if($round) {
          if(!$c_bl->isEmpty() && $y2c < $p1->y) {
            $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x1c, $y2c, $round, 'bl');
            $start = new PathData('M');
            $start->add($cross);
            $c_bl = new PathData($arc);
            $c_bl->add($x1c, $y2);
            if($y2c < $p2->y) {
              $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x1c, $y2c, $round, 'bl');
              $end = new PathData('L', $x1, $y2c);
              $end->add($arc);
              $end->add($cross);
            }
          }
          if(!$c_tl->isEmpty() && $p2->y < $y1c) {
            $cross = $this->lineCrossArc($p2->x, $p2->y, $angle, $x1c, $y1c, $round, 'tl');
            $end = new PathData($arc);
            $end->add($cross);
            $c_tl = new PathData('L', $x1c, $y1);
            if($p1->y < $y1c) {
              $cross = $this->lineCrossArc($p1->x, $p1->y, $angle, $x1c, $y1c, $round, 'tl');
              $start = new PathData('M');
              $start->add($cross);
              $start->add($arc);
              $start->add($x1, $y1c);
            }
          }
        }
        $points = [$start, $c_bl, $c_br, $c_tr, $c_tl, $end];
        $distance = $x1 - $target[0];
      }
    }
    $box_path = new PathData;
    foreach($points as $pt)
      $box_path->add($pt);
    $box_path->add($this->getTailEnding($target[0], $target[1], $angle,
      $t_width, $distance, $style));
    $box_path->add('z');

    $surround['fill'] = new Colour($this->graph, $style['fill']);
    $surround['d'] = $box_path;
    return 'path';
  }

  /**
   * Line and circle label style
   */
  protected function circleLineLabel($x, $y, $w, $h, &$style, &$surround)
  {
    list($cx, $cy, $bbradius) = $this->calcRoundLabel($x, $y, $w, $h);
    list($target, $angle, $len) = $this->getRoundTailTarget($style, $cx, $cy,
      $bbradius);
    if($target === null)
      return $this->circleLabel($x, $y, $w, $h, $style, $surround);

    $t_width = max(1, min($style['tail_width'], $bbradius));
    $l_angle = asin($t_width * 0.5 / $bbradius);
    $p1x = $cx + $bbradius * cos($angle - $l_angle);
    $p1y = $cy + $bbradius * sin($angle - $l_angle);
    $p2x = $cx + $bbradius * cos($angle + $l_angle);
    $p2y = $cy + $bbradius * sin($angle + $l_angle);

    $surround['fill'] = new Colour($this->graph, $style['fill']);
    $path = new PathData('M', $p1x, $p1y, 'A', $bbradius, $bbradius, 0, 1, 0,
      $p2x, $p2y);
    $path->add($this->getTailEnding($target[0], $target[1], $angle, $t_width, $len, $style));
    $path->add('z');
    $surround['d'] = $path;
    return 'path';
  }

  /**
   * Converts a rectangle to a square with same centre point
   */
  private static function makeSquare(&$x, &$y, &$w, &$h)
  {
    if($w == $h)
      return;
    $w2 = $w / 2;
    $h2 = $h / 2;
    if($w > $h) {
      $y -= ($w2 - $h2);
      $h += ($w - $h);
      return;
    }
    $x -= ($h2 - $w2);
    $w += ($h - $w);
  }

  /**
   * Square label style
   */
  protected function squareLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $this->makeSquare($x, $y, $w, $h);
    return $this->boxLabel($x, $y, $w, $h, $style, $surround);
  }

  /**
   * Line and square label style
   */
  protected function squareLineLabel($x, $y, $w, $h, &$style, &$surround)
  {
    $this->makeSquare($x, $y, $w, $h);
    return $this->boxLineLabel($x, $y, $w, $h, $style, $surround);
  }
}

