/* SortableTable.js - a function for allowing tables to be sorted
 *
 * The author of this program, Safalra (Stephen Morley), irrevocably releases
 * all rights to this program, with the intention of it becoming part of the
 * public domain. Because this program is released into the public domain, it
 * comes with no warranty either expressed or implied, to the extent permitted
 * by law.
 *
 * For more public domain JavaScript code by the same author, visit:
 * http://www.safalra.com/programming/javascript/
 */




/* Common comparison functions for use with the sort function of SortableTable.
 */
SortableTable.SORT = new function(){


  /* A simple alphabetical comparison. This comparison applies the less than
   * operator to the two strings. The parameters are:
   *
   * cellText1 - the text content of the first cell
   * cellText2 - the text content of the second cell
   */
  this.ALPHABETICAL = function(cellText1, cellText2){
    return (cellText1 == cellText2)
           ? 0
           : (cellText1 < cellText2) ? -1 : 1;
  }


  /* A simple numerical comparison. This comparison applies the subtraction
   * operator to the two strings to convert them into numbers and determine
   * which is larger. The parameters are:
   *
   * cellText1 - the text content of the first cell
   * cellText2 - the text content of the second cell
   */
  this.NUMERICAL = function(cellText1, cellText2){
    return cellText1 - cellText2;
  }


  /* An alphanumerical sort. Conceptually, this treats consecutive digits inside
   * a string as a single character, sorted before all normal characters but
   * compared numberically with other such strings of digits. For example, the
   * following strings are in order based on this comparison:
   *
   * 2
   * 11
   * a
   * a2
   * a11
   * a11b
   * a11b2
   * a11b11
   *
   * The parameters are:
   *
   * cellText1 - the text content of the first cell
   * cellText2 - the text content of the second cell
   */
  this.ALPHANUMERICAL = function(cellText1, cellText2){

    // start by assuming the cells are equal
    var result = 0;

    // split the strings into components
    var components1 = getComponents(cellText1);
    var components2 = getComponents(cellText2);

    // store whether the first component of the first string is text
    var compareText = isNaN(parseInt(components1[0], 10));

    // check whether the first component of the second string is the same type
    if (compareText != isNaN(parseInt(components1[0], 10))){

      // the types do not match, so set which cell comes first
      result = compareText ? 1 : -1;

    }else{

      // the types do match, so compare the strings component by component
      var position = 0;
      while (result == 0
          && components1.length > position
          && components2.length > position){

        // perform the appropriate comparison based on the type
        if (compareText){
          result =
              SortableTable.SORT.ALPHABETICAL(
                  components1[position], components2[position]);
        }else{
          result = components1[position] - components2[position];
        }

        // update the position and the type
        position++;
        compareText = !compareText;

      }

      // check whether one set of components is a subset of the other
      if (result == 0 && components1.length != components2.length){

        // set the cell with fewer components as coming first
        result = (components1.length == position) ? -1 : 1;

      }

    }
    return result;
  }


  /* A regular express used to find consecutive digit or non-digit characters.
   */
  var componentExpression = /^(\d+|\D+)/;


  /* Returns an array containing the numeric and non-numeric components of a
   * string. Conceptually, this splits a string on the boundaries between digits
   * and non-digits. The paramter is:
   *
   * text - the text to split into components
   */
  function getComponents(text){

    // initialise the result
    var result = new Array();

    // loop while there are components remaining, appending them to the result
    while (text.length > 0){
      var component = componentExpression.exec(text)[0];
      result.push(component);
      text = text.substr(component.length);
    }

    //return the result
    return result;

  }


}




/* Creates a SortableTable. A SortableTable provides functions for easily
 * sorting the columns of a table using a range of sort orders, while respecting
 * table headers and footers. The parameter is:
 *
 * tableNode - the DOM node of the table to make sortable
 */
function SortableTable(tableNode){


  // Allow this object to be accessed from inner functions
  var that = this;


  /* The internal model of the table. This object has the following structure:
   *
   * tbodies[i]                       - ith tbody
   * tbodies[i].node                  -   its DOM node
   * tbodies[i].rows[j]               -   jth row of ith tbody
   * tbodies[i].rows[j].node          -     its DOM node
   * tbodies[i].rows[j].cells[k]      -     kth cell of jth row of ith tbody
   * tbodies[i].rows[j].cells[k].node -       its DOM node
   * tbodies[i].rows[j].cells[k].text -       its text content
   *
   * Note that the same DOM node can occur as multiple cells' DOM nodes if
   * the colspan attribute is used.
   */
  var tbodies;


  /* Updates the internal model of the table. The internal model of the table is
   * used to make sorting faster - the increase in sorting speed is particularly
   * significant when the table cells contain many DOM nodes but sorting is
   * based only on the text content of the cell. If the content of the table is
   * altered dynamically, this function must be called before the next sort is
   * performed.
   */
  this.update = function(){

    // reset the internal model of the table
    tbodies = new Array();

    // find all tbody nodes and iterate over them
    var tbodyNodes = tableNode.getElementsByTagName('tbody');
    for (var i = 0; i < tbodyNodes.length; i++){
      tbodies.push(
          {
            'node': tbodyNodes[i],
            'rows': new Array()
          });

      // find all tr nodes in this tbody node and iterate over them
      var trNodes = tbodyNodes[i].getElementsByTagName('tr');
      for (var j = 0; j < trNodes.length; j++){
        tbodies[i].rows.push(
            {
              'node': trNodes[j],
              'cells': new Array()
            });

        // iterate over all children of this tr node, finding th and td nodes
        for (var k = 0; k < trNodes[j].childNodes.length; k++){
          var node = trNodes[j].childNodes[k];

          // if this node is an element, add it to the internal table model
          if (node.nodeType == 1){

            // obtain the normalised text content of this node
            var text =
                getContent(node).replace(/\s+/g, ' ').replace(/^\s|\s$/g, '');

            // and this node and its content to the internal table model
            var colspan = node.getAttribute('colspan')
            if (!colspan) colspan = 1;
            for (var col = 0; col < colspan; col++){
              tbodies[i].rows[j].cells.push(
                  {
                    'node': node,
                    'text': text
                  });
            }

          }

        }

      }

    }

  }


  /* Returns the text content of a node. This is equal to the concatentation of
   * the values of all text nodes contained within this node. The parameter is:
   *
   * node - the node whose text content will be returned
   */
  function getContent(node){

    // initialise result
    var result = '';

    // loop over child nodes
    for (var i = 0; i < node.childNodes.length; i++){
      var child = node.childNodes[i];

      // check the type of this node
      switch (child.nodeType){

        // if it is an element node, recurse into it and then update the result
        case 1:
          result += getContent(child);
          break;

        // if it is a text node, concatenate its value with the result
        case 3:
          result += child.nodeValue;
          break;

        // otherwise, do nothing
        default:

      }

    }

    // return the result
    return result;

  }


  /* Sorts the table by a specified column using the specified comparison
   * function and order. If there are multiple tbody elements, their order is
   * preserved. The parameters are:
   *
   * column             - the index of the column by which to sort. Indices are
   *                      0-based (that is, the first column is column 0).
   * comparisonFunction - the function to use in comparisons - see below
   * ascending          - true to sort in ascending order, false otherwise
   *
   * While several comparison functions are supplied, developers may wish to
   * use their own functions. Each function should take up to four parameters:
   *
   * cellText1 - the text content of the first cell
   * cellText2 - the text content of the second cell
   * cellNode1 - the DOM node of the first cell
   * cellNode2 - the DOM node of the second cell
   *
   * Note that the text is normalised - leading and trailing whitespace is
   * removed and consecutive whitespace characters are replaced with a single
   * space.
   *
   * The function should return an integer. A value of 0 means that the two
   * cells should be considered equal. A value less than 0 means that the first
   * cell should be sorted before the second cell (when in ascending order). A
   * value greater than 0 means that the first cell should be sorted after the
   * second cell (when in ascending order).
   */
  this.sort = function(column, comparisonFunction, ascending){

    // construct a sort function for rows from the comparison function
    function sortFunction(row1, row2){
      var cell1 = row1.cells[column];
      var cell2 = row2.cells[column];

      return ((ascending ? 1 : -1) *
          comparisonFunction(cell1.text, cell2.text, cell1.node, cell2.node));
    }

    // iterate over tbodies, sorting each in turn
    for (var i = 0; i < tbodies.length; i++){

      // sort the rows
      tbodies[i].rows.sort(sortFunction);

      // rearrange the DOM tree to reflect the new row order
      for (var j = 0; j < tbodies[i].rows.length; j++){
        tbodies[i].node.removeChild(tbodies[i].rows[j].node);
        tbodies[i].node.appendChild(tbodies[i].rows[j].node);
      }

    }

  }


  /* Add 'sort images' to the table. This a convenience function that adds the
   * specified images to each column header (and footer if desired) and attaches
   * event listeners so the columns are sorted when the visitor clicks on the
   * images. The parameters are:
   *
   * includeFooter       - true to include headers in the tfoot element as well
   *                       as the thead element, false otherwise
   * ascendingImageFirst - true to place the ascending image first in the
   *                       document true, false otherwise
   * comparisonFunctions - an array of comparison functions to use when the sort
   *                       function is called. The array should have at least as
   *                       many elements as there are columns - extra elements
   *                       are ignored.
   * ascendingImageURL   - the URL of the ascending image
   * descendingImageURL  - the URL of the descending image
   * imageWidth          - the width of each image, in pixels
   * imageHeight         - the height of each image, in pixels
   *
   * The images are added to the document tree as two img elements with the
   * classes sortableTableAscendingImage and sortableTableDescendingImage,
   * inside a span element with class sortableTableSortImages, giving a range of
   * formatting options.
   */
  this.addSortImages = function(
      includeFooter, ascendingImageFirst, comparisonFunctions,
      ascendingImageURL, descendingImageURL, imageWidth, imageHeight){

    // create the image span template
    var imageSpanTemplate = document.createElement('span');
    imageSpanTemplate.className = 'sortableTableSortImages';

    // create the ascending image
    var ascendingImage = document.createElement('img');
    ascendingImage.setAttribute('width', imageWidth);
    ascendingImage.setAttribute('height', imageHeight);

    // create the descending image
    var descendingImage = ascendingImage.cloneNode(true);

    // set the attributes of the ascending image
    ascendingImage.className = 'sortableTableAscendingImage';
    ascendingImage.setAttribute('src', ascendingImageURL);
    ascendingImage.setAttribute('alt', 'Sort ascending');
    ascendingImage.setAttribute('title', 'Sort ascending');

    // set the attributes of the descending image
    descendingImage.className = 'sortableTableDescendingImage';
    descendingImage.setAttribute('src', descendingImageURL);
    descendingImage.setAttribute('alt', 'Sort descending');
    descendingImage.setAttribute('title', 'Sort descending');

    // add the images to span in the appropriate order
    if (ascendingImageFirst){
      imageSpanTemplate.appendChild(ascendingImage);
      imageSpanTemplate.appendChild(descendingImage);
    }else{
      imageSpanTemplate.appendChild(descendingImage);
      imageSpanTemplate.appendChild(ascendingImage);
    }

    // add images to the headers in the thead element
    addSortImageNodes(
        imageSpanTemplate,
        tableNode.getElementsByTagName('thead')[0].getElementsByTagName('th'),
        comparisonFunctions);

    // if requested, add images to the headers in the tfoot element
    if (includeFooter){
      addSortImageNodes(
          imageSpanTemplate,
          tableNode.getElementsByTagName('tfoot')[0].getElementsByTagName('th'),
          comparisonFunctions);
    }

  }


  /* Modifies by the document tree to add sort images. The parameters are:
   *
   * imageSpanTemplate   - the node to use as a template. This node will be
   *                       cloned and appended to each header's child nodes.
   * headers             - an array of header nodes
   * comparisonFunctions - an array of functions to use in comparisons
   */
  function addSortImageNodes(imageSpanTemplate, headers, comparisonFunctions){

    // iterate over the headers
    for (var i = 0; i < headers.length; i++){

      // create a copy of the image span template
      var imageSpan = imageSpanTemplate.cloneNode(true);
      var ascendingEventListener =
          createEventListener(i, comparisonFunctions[i], true);
      var descendingEventListener =
          createEventListener(i, comparisonFunctions[i], false);

      // add the appropriate event listeners to the images
      if (imageSpan.firstChild.addEventListener){
        imageSpan.firstChild.addEventListener(
            'click', ascendingEventListener, false);
        imageSpan.lastChild.addEventListener(
            'click', descendingEventListener, false);
      }else if (imageSpan.firstChild.attachEvent){
        imageSpan.firstChild.attachEvent('onclick', ascendingEventListener);
        imageSpan.lastChild.attachEvent('onclick', descendingEventListener);
      }

      // add the image span to the document tree
      headers[i].appendChild(imageSpan);

    }

  }


  /* Returns a function to be used as an event listener by the addSortImages
   * function. The parameters are:
   *
   * column             - the index of the column by which to sort
   * comparisonFunction - the function to use in comparisons
   * ascending          - true to sort in ascending order, false otherwise
   */
  function createEventListener(column, comparisonFunction, ascending){
    return function(){
      that.sort(column, comparisonFunction, ascending);
    }
  }


  // initialise the internal model
  this.update();


}
