<?php
/**
 * Copyright (C) 2015 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>
 */

class DataLabels {

  protected $graph;
  protected $have_filters = false;
  private $labels = array();
  private $max_values = array();
  private $min_values = array();
  private $start_indices = array();
  private $end_indices = array();
  private $peak_indices = array();
  private $trough_indices = array();
  private $directions = array();
  private $last = array();
  private $max_labels = 1000;
  private $coords = NULL;

  function __construct(&$graph)
  {
    $this->graph =& $graph;
    $this->have_filters = !empty($graph->data_label_filter) &&
      $graph->data_label_filter !== 'all';
    if($graph->data_label_max_count >= 1)
      $this->max_labels = (int)$graph->data_label_max_count;
  }

  /**
   * Retrieves properties from the graph if they are not
   * already available as properties
   */
  public function __get($name)
  {
    return $this->graph->{$name};
  }

  /**
   * Make empty($this->option) more robust
   */
  public function __isset($name)
  {
    return isset($this->graph->{$name});
  }

  /**
   * 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] = array();
    $this->labels[$dataset][$index] = array(
      '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] = array();
    $this->labels[$dataset][$index] = array(
      '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'] = array();

    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, array(
      'item' => null, 'id' => null, 'content' => $content,
      'x' => $x, 'y' => $y, 'width' => $w, 'height' => $h,
      'fade' => null, 'click' => 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] = array($index, $value);
      $this->directions[$dataset] = null;
      $this->peak_indices[$dataset] = array();
      $this->trough_indices[$dataset] = array();
    } elseif($this->last[$dataset][1] != $value) {
      $last = $this->last[$dataset];
      $diff = $value - $last[1];
      $direction = ($diff > 0);
      if(!is_null($this->directions[$dataset]) &&
        $direction !== $this->directions[$dataset]) {
        if($diff > 0)
          $this->trough_indices[$dataset][] = $last[0];
        else
          $this->peak_indices[$dataset][] = $last[0];
      }
      $this->last[$dataset] = array($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');

    $count = 0;
    if(!is_array($settings['label'][0])) {
      $this->AddUserLabel($settings['label']);
      ++$count;
    } else {
      foreach($settings['label'] as $label) {
        $this->AddUserLabel($label);
        ++$count;
      }
    }
    if($count) {
      require_once "SVGGraphCoords.php";
      $this->coords = new SVGGraphCoords($this->graph);
    }
  }

  /**
   * Returns all the labels as a string
   */
  public function GetLabels()
  {
    $labels = '';
    $filters = $this->data_label_filter;
    foreach($this->labels as $dataset => $label_set) {

      if(is_numeric($dataset)) {
        $set_filter = is_array($filters) ? $filters[$dataset % count($filters)] :
          $filters;
      } else {
        $set_filter = 'all';
      }
      $count = 0;
      foreach($label_set as $i => $label) {
        if($this->Filter($set_filter, $dataset, $label, $i)) {
          $labels .= $this->DrawLabel($dataset, $i, $label);
          if(++$count >= $this->max_labels)
            break;
        }
      }
    }
    $group = array();
    if($this->semantic_classes)
      $group['class'] = 'data-labels';
    $labels = $this->graph->Element('g', $group, NULL, $labels);
    return $labels;
  }

  /**
   * Draws a label
   */
  protected function DrawLabel($dataset, $index, &$gobject)
  {
    if(is_null($gobject['item']) && empty($gobject['content']))
      return '';

    if(is_null($gobject['item'])) {
      // convert to string - numbers will confuse TextSize()
      $content = (string)$gobject['content'];
    } else {
      if(is_callable($this->data_label_callback)) {
        $content = call_user_func($this->data_label_callback, $dataset,
          $gobject['item']->key, $gobject['item']->value);
        if(is_null($content))
          $content = '';
      } else {
        $content = $gobject['item']->Data('label');
      }
      if(is_null($content)) {
        $content = !is_null($gobject['content']) ? $gobject['content'] :
          $this->units_before_label . Graph::NumString($gobject['item']->value) .
          $this->units_label;
      }
    }
    if($content == '')
      return '';

    $style = $this->graph->DataLabelStyle($dataset, $index, $gobject['item']);
    if(!is_null($gobject['item']))
      $this->ItemStyles($style, $gobject['item']);
    elseif($dataset === '_user')
      $this->UserStyles($style, $gobject);

    $type = $style['type'];
    $font_size = max(4, (float)$style['font_size']);
    $space = (float)$style['space'];
    if($type == 'box' || $type == 'bubble') {
      $label_pad_x = $style['pad_x'];
      $label_pad_y = $style['pad_y'];
    } else {
      $label_pad_x = $label_pad_y = 0;
    }

    // reasonable approximation of the baseline position
    $text_baseline = $font_size * 0.85;

    // get size of label
    list($tw, $th) = Graph::TextSize($content, $font_size, $style['font_adjust'],
      $this->encoding, $style['angle'], $font_size);

    $label_w = $tw + $label_pad_x * 2;
    $label_h = $th + $label_pad_y * 2;
    $label_wp = $label_w + $space * 2;
    $label_hp = $label_h + $space * 2;

    $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];
    } else {
      // try to get position from item
      if(!is_null($gobject['item']))
        $pos = $gobject['item']->Data('data_label_position');

      // find out from graph class where this label should go
      if(is_null($pos))
        $pos = $this->graph->DataLabelPosition($dataset, $index, $gobject['item'],
          $gobject['x'], $gobject['y'], $gobject['width'], $gobject['height'],
          $label_wp, $label_hp);
    }

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

    // if the position is outside, use the alternative colours
    $colour = $style['colour'];
    $back_colour = $style['back_colour'];
    if(strpos($hpos . $vpos, 'o') !== FALSE) {
      if(!empty($style['altcolour']))
        $colour = $style['altcolour'];
      if(!empty($style['back_altcolour']))
        $back_colour = $style['back_altcolour'];
    }

    $text = array(
      'font-family' => $style['font'],
      'font-size' => $font_size,
      'fill' => $colour,
    );

    $label_markup = '';

    // rotation
    if($style['angle'] != 0) {

      // need text size pre-rotation
      list($tbw, $tbh) = Graph::TextSize($content, $font_size,
        $style['font_adjust'], $this->encoding, 0, $font_size);

      if($anchor == 'middle') {
        $text['x'] = $x;
      } elseif($anchor == 'start') {
        $text['x'] = $x + $label_pad_x + ($tw - $tbw) / 2;
      } else {
        $text['x'] = $x - $label_pad_x - ($tw - $tbw) / 2; 
      }
      $text['y'] = $y + $label_h / 2 - $tbh / 2 + $text_baseline;

    } else {

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

    // 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;
      $text['transform'] = "rotate({$style['angle']},$rx,$ry)";

      /** DEBUG: text position and rotation point 
      $label_markup .= $this->graph->Element('circle',
        array('cx' => $text['x'], 'cy' => $text['y'], 'r' => 2, 'fill' => '#f0f'));
      $label_markup .= $this->graph->Element('circle',
        array('cx' => $rx, 'cy' => $ry, 'r' => 2));
      **/
    }

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

    $surround = array();
    $element = null;

    if($type == 'box') {
      $element = $this->BoxLabel($x, $y, $label_w, $label_h, $style, $surround);
    } elseif($type == 'bubble') {

      $style['tail_direction'] = $this->graph->DataLabelTailDirection($dataset,
        $index, $hpos, $vpos);
      $element = $this->BubbleLabel($x, $y, $label_w, $label_h, $style, $surround);

    } elseif($type == 'line') {

      $style['tail_direction'] = $this->graph->DataLabelTailDirection($dataset,
        $index, $hpos, $vpos);
      $element = $this->LineLabel($x, $y, $label_w, $label_h, $style, $surround);
    }

    // if there is a box or bubble, draw it
    if($element) {
      $surround['stroke'] = $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);
        $shadow['transform'] = "translate({$offset},{$offset})";
        $shadow['fill'] = $shadow['stroke'] = '#000';
        $shadow['opacity'] = $style['shadow_opacity'];
        $label_markup .= $this->graph->Element($element, $shadow);
      }
      $label_markup .= $this->graph->Element($element, $surround);
    }

    if(!empty($back_colour)) {
      $outline = array(
        'stroke-width' => '3px',
        'stroke' => $back_colour,
        'stroke-linejoin' => 'round',
      );
      $t1 = array_merge($outline, $text);
      $label_markup .= $this->graph->Text($content, $font_size, $t1);
    }
    $label_markup .= $this->graph->Text($content, $font_size, $text);

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

    // start off hidden?
    if($gobject['click'] == 'show')
      $group['opacity'] = 1; // set opacity explicitly for calculations
    elseif($gobject['click'] == 'hide' || $gobject['fade'])
      $group['opacity'] = 0;

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

  /**
   * Individual label styles from the structured data item
   */
  protected function ItemStyles(&$style, &$item)
  {
    $options = array(
      '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',
      'shadow_opacity' => 'data_label_shadow_opacity',
    );

    // overwrite any style options that the item has set
    $v = $item->Data('data_label_padding');
    if(!is_null($v))
      $style['pad_x'] = $style['pad_y'] = $v;
    foreach($options as $s => $k) {
      $v = $item->Data($k);
      if(!is_null($v))
        $style[$s] = $v;
    }
  }

  /**
   * Styles from the label option
   */
  protected function UserStyles(&$style, &$label_array)
  {
    $options = array(
      'type' => 'type',
      'font' => 'font',
      'font_size' => 'font_size',
      'font_adjust' => 'font_adjust',
      'font_weight' => 'font_weight',
      'colour' => 'colour',
      // 'altcolour' => 'colour_outside',
      'back_colour' => 'back_colour',
      // 'back_altcolour' => 'back_colour_outside',
      'space' => 'space',
      'angle' => 'angle',
      'pad_x' => 'padding_x',
      'pad_y' => 'padding_y',
      'round' => 'round',
      'stroke' => 'outline_colour',
      'stroke_width' => 'outline_thickness',
      'fill' => 'fill',
      'tail_width' => 'tail_width',
      'tail_length' => 'tail_length',
      'shadow_opacity' => 'shadow_opacity',
    );

    if(isset($label_array['padding']))
      $style['pad_x'] = $style['pad_y'] = $label_array['padding'];
    foreach($options as $s => $k) {
      if(isset($label_array[$k]))
        $style[$s] = $label_array[$k];
    }

  }

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

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

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

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

    // default is to show nothing
    $show = false;
    foreach($filters as $f) {

      switch($f) {
      case 'start' :
        if($index == $this->start_indices[$dataset])
          $show = true;
        break;
      case 'end' :
        if($index == $this->end_indices[$dataset])
          $show = true;
        break;
      case 'max' :
        if($item->value == $this->max_values[$dataset])
          $show = true;
        break;
      case 'min' :
        if($item->value == $this->min_values[$dataset])
          $show = true;
        break;
      case 'peaks' :
        if(in_array($index, $this->peak_indices[$dataset], true))
          $show = true;
        break;
      case 'troughs' :
        if(in_array($index, $this->trough_indices[$dataset], true))
          $show = true;
        break;
      default :
        // integer step
        if(is_numeric($f) && $index % (int)$f == 0) {
          $show = 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])
            $show = true;
        }
        break;
      }
    }

    return $show;
  }


  /**
   * 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;

    // make sure line is long enough to not look like part of text
    $llen = max($style['font_size'], $style['tail_length']);

    // 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;
    $x2 = $llen * cos($a);
    $y2 = $llen * sin($a);
    $surround['d'] = "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'] = $this->graph->ParseColour($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, $style['tail_length']);
    $spread = min(max(2, $style['tail_width']), $w - $round * 2);

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

    $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 = "v{$bside}l{$drop} {$s2}l-{$drop} {$s2}v{$bside}";
      break;
    case 1 :
      $r = 'v' . $vcropped;
      $br = 'l' . $ddrop . ' ' . $p1 . 'l' . -$p1 . ' ' . -$ddrop;
      $b = 'h' . -$hcropped;
      break;
    case 2 :
      $bside = $w / 2 - $s2 - $round;
      $b = "h-{$bside}l-{$s2} {$drop}l-{$s2} -{$drop}h-{$bside}";
      break;
    case 3 :
      $l = 'v' . -$vcropped;
      $bl = 'l' . -$p1 . ' ' . $ddrop . 'l' . $ddrop . ' ' . -$p1;
      $b = 'h' . -$hcropped;
      break;
    case 4 :
      $bside = $h / 2 - $s2 - $round;
      $l = "v-{$bside}l-{$drop} -{$s2}l{$drop} -{$s2}v-{$bside}";
      break;
    case 5 :
      $l = 'v' . -$vcropped;
      $tl = 'l' . -$ddrop . ' ' . -$p1 . 'l' . $p1 . ' ' . $ddrop;
      break;
    case 6 :
      $bside = $w / 2 - $s2 - $round;
      $t = "h{$bside}l{$s2} -{$drop}l{$s2} {$drop}z";
      break;
    case 7 :
      $start = 'M' . ($x + $hcropped + $round) . ' ' . $y;
      $r = 'v' . $vcropped;
      $tr = 'l' . $p1 . ' ' . -$ddrop . 'l' . -$ddrop . ' ' . $p1;
      break;
    }
    $surround['d'] = $start . $tr . $r . $br . $b . $bl . $l . $tl . $t;
    $surround['fill'] = $this->graph->ParseColour($style['fill']);
    return 'path';
  }
};