Merge branch 'master' into relationships
Tara L Andrews [Sat, 14 Jan 2012 21:53:01 +0000 (22:53 +0100)]
12 files changed:
TreeOfTexts/lib/TreeOfTexts.pm
TreeOfTexts/lib/TreeOfTexts/Controller/Root.pm
TreeOfTexts/lib/TreeOfTexts/Controller/Stemmagraph.pm
TreeOfTexts/lib/TreeOfTexts/View/JSON.pm [new file with mode: 0644]
TreeOfTexts/root/js/relationship.js [new file with mode: 0644]
TreeOfTexts/root/js/svginteraction.js [new file with mode: 0644]
TreeOfTexts/root/src/relate.tt [new file with mode: 0644]
TreeOfTexts/t/view_JSON.t [new file with mode: 0644]
lib/Text/Tradition/Collation.pm
lib/Text/Tradition/Stemma.pm
lib/Text/Tradition/StemmaUtil.pm [new file with mode: 0644]
t/stemma.t

index 20b70ae..e29f3a8 100644 (file)
@@ -41,6 +41,9 @@ __PACKAGE__->config(
     # Disable deprecated behavior needed by old applications
     disable_component_resolution_regex_fallback => 1,
     default_view => 'TT',
+       'View::JSON' => {
+               expose_stash => 'result',
+       },
 );
 
 # Start the application
index 3141c0b..1f6792d 100644 (file)
@@ -61,8 +61,11 @@ sub relationships :Local {
        my( $self, $c ) = @_;
        my $m = $c->model('Directory');
        my $tradition = $m->tradition( $c->request->params->{'textid'} );
-       $c->stash->{alignment} = $tradition->collation->make_alignment_table( 'refs' );
-       $c->stash->{template} = 'relationships.tt';     
+       my $table = $tradition->collation->make_alignment_table();
+       my $witlist = map { $_->{'witness'} } @{$table->{'alignment'}};
+       $c->stash->{witnesses} = $witlist;
+       $c->stash->{alignment} = $table;
+       $c->stash->{template} = 'relate.tt';    
 }
 
 =head2 stexaminer
@@ -88,6 +91,21 @@ sub stexaminer :Local {
        $c->stash->{conflict} = $t->{'conflict_count'};
 }
 
+=head2 alignment_table 
+
+Return a JSON alignment table of a given text.
+
+=cut
+
+sub alignment_table :Local {
+       my( $self, $c ) = @_;
+       my $m = $c->model( 'Directory' );
+       my $tradition = $m->tradition( $c->request->params->{'textid'} );
+       my $table = $tradition->collation->make_alignment_table();
+       $c->stash->{'result'} = $table;
+       $c->forward-( 'View::JSON' );
+}
+
 =head1 OPENSOCIAL URLs
 
 =head2 view_table
index 1d6a8e0..3079312 100644 (file)
@@ -2,8 +2,9 @@ package TreeOfTexts::Controller::Stemmagraph;
 use Moose;
 use namespace::autoclean;
 use File::Temp;
+use JSON;
 use Text::Tradition::Collation;
-use Text::Tradition::Stemma;
+use Text::Tradition::StemmaUtil qw/ character_input phylip_pars newick_to_svg /;
 
 BEGIN { extends 'Catalyst::Controller' }
 
@@ -52,13 +53,73 @@ sub get_graph :Local {
     $c->forward( "View::SVG" );
 }
 
-=head2 end
+=head2 character_matrix
 
-Attempt to render a view, if needed.
+Given an alignment table in JSON form, in the parameter 'alignment', returns a
+character matrix suitable for input to Phylip PARS. 
 
 =cut
 
-sub end : ActionClass('RenderView') {}
+sub character_matrix :Local {
+       my( $self, $c ) = @_;
+       my $json = $c->request->params->{'alignment'};
+       $c->log->debug( $json );
+       my $table = from_json( $json );
+       my $matrix = character_input( $table );
+       $c->stash->{'result'} = { 'matrix' => $matrix };
+       $c->forward( 'View::JSON' );
+}
+
+=head2 run_pars 
+
+Takes either an alignment table in JSON format (passed as the parameter 'alignment')
+or a character matrix Phylip accepts (passed as the parameter 'matrix').  Returns
+either the Newick-format answer or an SVG representation of the graph.
+
+=cut
+
+sub run_pars :Local {
+       my( $self, $c ) = @_;
+       my $error;
+       my $view = 'View::JSON';
+       my $matrix;
+       if( $c->request->param('matrix') ) {
+               $matrix = $c->request->param('matrix');
+       } elsif( $c->request->param('alignment') ) {
+               # Make the matrix from the alignment
+               my $table = from_json( $c->request->param('alignment') );
+               $matrix = character_input( $table );
+       } else {
+               $error = "Must pass either an alignment or a matrix";
+       }
+       
+       # Got the matrix, so try to run pars.
+       my( $result, $output );
+       unless( $error ) {
+               ( $result, $output ) = phylip_pars( $matrix );
+               $error = $output unless( $result );
+       }
+       
+       # Did we want newick or a graph?
+       unless( $error ) {
+               my $format = 'newick';
+               $format = $c->request->param('format') if $c->request->param('format');
+               if( $format eq 'svg' ) {
+                       # Do something
+                       $c->stash->{'result'} = newick_to_svg( $output );
+                       $view = 'View::SVG';
+               } elsif( $format ne 'newick' ) {
+                       $error = "Requested output format $format unknown";
+               } else {
+                       $c->stash->{'result'} = { 'tree' => $output };
+               }
+       }
+
+       if( $error ) {
+               $c->stash->{'error'} = $error;
+       } # else the stash is populated.
+       $c->forward( $view );
+}
 
 =head1 AUTHOR
 
diff --git a/TreeOfTexts/lib/TreeOfTexts/View/JSON.pm b/TreeOfTexts/lib/TreeOfTexts/View/JSON.pm
new file mode 100644 (file)
index 0000000..b08faa9
--- /dev/null
@@ -0,0 +1,29 @@
+package TreeOfTexts::View::JSON;
+
+use strict;
+use base 'Catalyst::View::JSON';
+
+=head1 NAME
+
+TreeOfTexts::View::JSON - Catalyst JSON View
+
+=head1 SYNOPSIS
+
+See L<TreeOfTexts>
+
+=head1 DESCRIPTION
+
+Catalyst JSON View.
+
+=head1 AUTHOR
+
+Tara Andrews
+
+=head1 LICENSE
+
+This library is free software, you can redistribute it and/or modify
+it under the same terms as Perl itself.
+
+=cut
+
+1;
diff --git a/TreeOfTexts/root/js/relationship.js b/TreeOfTexts/root/js/relationship.js
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/TreeOfTexts/root/js/svginteraction.js b/TreeOfTexts/root/js/svginteraction.js
new file mode 100644 (file)
index 0000000..8fa72e2
--- /dev/null
@@ -0,0 +1,310 @@
+function getRelativePath( action ) {
+    path_elements = window.location.pathname.split('/'); 
+    if( path_elements[1].length > 0 ) {
+        return window.location.pathname.split('/')[1] + '/' + action;
+    } else {
+        return action;
+    }
+}
+
+function svgLoaded() {
+  $('ellipse').attr( {stroke:'black', fill:'#fff'} );
+  ncpath = getRelativePath( 'node_click' );
+  var jqjson = $.getJSON( ncpath, 'node_id=null', function(data) {
+    $.each( data, function(item, node_id_and_state) {
+      if( node_id_and_state[1] == 1 ) {
+        node_ellipse = $('.node').children('title').filter( function(index) {
+          return $(this).text() == node_id_and_state[0];
+        }).siblings('ellipse');
+        node_ellipse.attr( {stroke:'green', fill:'#b3f36d'} );
+        $('#constructedtext').append( node_ellipse.siblings('text').text() + '&#32;' );
+      } else {
+        if( node_id_and_state[1] == null ) {
+          $('#constructedtext').append( ' &hellip; ' );
+        }
+      }
+    });
+    add_node_objs();
+  });
+}
+
+function add_node_objs() {
+  $('ellipse[fill="#fff"]').each( function() {
+      $(this).data( 'node_obj', new node_obj( $(this) ) );
+    }
+  );
+}
+
+function get_node_obj( node_id ) {
+  return $('.node').children('title').filter( function(index) {
+    return $(this).text() == node_id;
+  }).siblings('ellipse').data( 'node_obj' );
+}
+
+function get_edge( edge_id ) {
+  return $('.edge').filter( function(index) {
+    return $(this).children( 'title' ).text() == $('<div/>').html(edge_id).text() ;
+  });
+}
+
+function node_obj(ellipse) {
+  this.ellipse = ellipse;
+  var self = this;
+  
+  this.x = 0;
+  this.y = 0;
+  this.dx = 0;
+  this.dy = 0;
+  this.node_elements = node_elements_for(self.ellipse);
+  this.sub_nodes = [];
+  this.super_node = null;
+
+  this.dblclick_listener = function(evt) {
+    node_id = self.ellipse.siblings('title').text();
+    ncpath = getRelativePath( 'node_click' );
+    var jqjson = $.getJSON( ncpath, 'node_id=' + node_id, function(data) {
+      $('#constructedtext').empty();
+      $.each( data, function(item, node_id_and_state) {
+        node = get_node_obj( node_id_and_state[0] );
+        // 1 -> turn the associated SVG node on, put in the associate word in the text box.
+        // 0 -> turn SVG node off.
+        // null -> turn node off, put in ellipsis in text box at the corresponding place.
+        if( node_id_and_state[1] == 1 ) {
+//TODO: create test suite en refactor this in to more OO! (node and node_ellipse are 'conflated')
+          node_ellipse = $('.node').children('title').filter( function(index) {
+            return $(this).text() == node_id_and_state[0];
+          }).siblings('ellipse');
+          $('#constructedtext').append( node_ellipse.siblings('text').text() + '&#32;' );
+          if( node ) { node.set_draggable( false ) }
+        } else {
+          if( node ) { node.set_draggable( true ) };
+          if( node_id_and_state[1] == null ) {
+            $('#constructedtext').append( ' &hellip; ' );
+          }
+        }
+      });
+    });
+  }
+
+  this.set_draggable = function( draggable ) {
+    if( draggable ) {
+      self.ellipse.attr( {stroke:'black', fill:'#fff'} );
+      self.ellipse.mousedown( this.mousedown_listener );
+      self.ellipse.hover( this.enter_node, this.leave_node );  
+    } else {
+      self.ellipse.unbind('mouseenter').unbind('mouseleave').unbind('mousedown');
+      self.ellipse.attr( {stroke:'green', fill:'#b3f36d'} );
+    }
+  }
+
+  this.mousedown_listener = function(evt) {
+    evt.stopPropagation();
+    self.x = evt.clientX;
+    self.y = evt.clientY;
+    $('body').mousemove( self.mousemove_listener );
+    $('body').mouseup( self.mouseup_listener );
+    self.ellipse.unbind('mouseenter').unbind('mouseleave')
+    self.ellipse.attr( 'fill', '#ff66ff' );
+  }
+
+  this.mousemove_listener = function(evt) {
+    self.dx = evt.clientX - self.x;
+    self.dy = evt.clientY - self.y;
+    self.move_elements();
+  }
+
+  this.mouseup_listener = function(evt) {    
+    if( $('ellipse[fill="#ffccff"]').size() > 0 ) {
+      $('#source_node_id').val( self.ellipse.siblings('title').text() );
+      $('#target_node_id').val( $('ellipse[fill="#ffccff"]').siblings("title").text() );
+      $( '#dialog-form' ).dialog( 'open' );
+    };
+    $('body').unbind('mousemove');
+    $('body').unbind('mouseup');
+    self.ellipse.attr( 'fill', '#fff' );
+    self.ellipse.hover( self.enter_node, self.leave_node );
+    if( self.super_node ) {
+      self.eclipse();
+    } else {
+      self.reset_elements();
+    }
+  }
+
+  this.cpos = function() {
+    return { x: self.ellipse.attr('cx'), y: self.ellipse.attr('cy') };
+  }
+
+  this.get_g = function() {
+    return self.ellipse.parent('g');
+  }
+
+  this.stack_behind = function( collapse_info ) {
+    self.super_node = get_node_obj( collapse_info.target );
+    self.super_node.sub_nodes.push( self );
+    self.eclipse();
+    if( collapse_info.edges ) {
+      $.each( collapse_info.edges, function( source_edge_id, target_info ) {
+        get_edge(source_edge_id).attr( 'display', 'none' );
+        target_edge = get_edge(target_info.target);
+        // Unfortunately, the simple solution doesn't work...
+        // target_edge.children( 'text' ).replaceWith( '<text x="2270" y="-59.400001525878906"><tspan text-anchor="middle">A, B</tspan><tspan fill="red">, C</tspan></text>' );
+        // ..so we take the long and winding road...
+        var svg = $('#svgbasics').children('svg').svg().svg('get');
+        textx = target_edge.children( 'text' )[0].x.baseVal.getItem(0).value
+        texty = target_edge.children( 'text' )[0].y.baseVal.getItem(0).value
+        current_label = target_edge.children( 'text' ).text(); 
+        target_edge.children( 'text' ).remove();
+        texts = svg.createText();
+        texts.span(current_label, {'text-anchor': 'middle'}).span(target_info.label, {fill: 'red'});
+        svg.text(target_edge, textx, texty, texts);
+      }); 
+    }
+  }
+
+  this.eclipse = function() {
+    self.dx = new Number( self.super_node.cpos().x ) - new Number( self.cpos().x ) + ( 10 * (self.super_node.sub_nodes.indexOf(self) + 1) );
+    self.dy = new Number( self.super_node.cpos().y ) - new Number( self.cpos().y ) + ( 5 * (self.super_node.sub_nodes.indexOf(self) + 1) );
+    self.move_elements();
+    eclipse_index = self.super_node.sub_nodes.indexOf(self) - 1;
+    if( eclipse_index > -1 ) {
+      self.get_g().insertBefore( self.super_node.sub_nodes[eclipse_index].get_g() );
+    } else {
+      self.get_g().insertBefore( self.super_node.get_g() );
+    }
+  }
+
+  this.enter_node = function(evt) {
+    self.ellipse.attr( 'fill', '#ffccff' );
+  }
+
+  this.leave_node = function(evt) {
+    self.ellipse.attr( 'fill', '#fff' );
+  }
+
+  this.move_elements = function() {
+    $.each( self.node_elements, function(index, value) {
+      value.move(self.dx,self.dy);
+    });
+  }
+
+  this.reset_elements = function() {
+    $.each( self.node_elements, function(index, value) {
+      value.reset();
+    });
+  }
+
+  this.ellipse.dblclick( this.dblclick_listener );
+  self.set_draggable( true );
+}
+
+function svgshape( shape_element ) {
+  this.shape = shape_element;
+  this.move = function(dx,dy) {
+    this.shape.attr( "transform", "translate(" + dx + " " + dy + ")" );
+  }
+  this.reset = function() {
+    this.shape.attr( "transform", "translate( 0, 0 )" );
+  }
+}
+
+function svgpath( path_element ) {
+  this.path = path_element;
+  this.x = this.path.x;
+  this.y = this.path.y;
+  this.move = function(dx,dy) {
+    this.path.x = this.x + dx;
+    this.path.y = this.y + dy;
+  }
+  this.reset = function() {
+    this.path.x = this.x;
+    this.path.y = this.y;
+  }
+}
+
+function node_elements_for( ellipse ) {
+  node_elements = get_edge_elements_for( ellipse );
+  node_elements.push( new svgshape( ellipse.siblings('text') ) );
+  node_elements.push( new svgshape( ellipse ) );
+  return node_elements;
+}
+
+function get_edge_elements_for( ellipse ) {
+  edge_elements = new Array();
+  node_id = ellipse.siblings('title').text();
+  edge_in_pattern = new RegExp( node_id + '$' );
+  edge_out_pattern = new RegExp( '^' + node_id );
+  $.each( $('.edge').children('title'), function(index) {
+    title = $(this).text();
+    if( edge_in_pattern.test(title) ) {
+      edge_elements.push( new svgshape( $(this).siblings('polygon') ) );
+      path_segments = $(this).siblings('path')[0].pathSegList;
+      edge_elements.push( new svgpath( path_segments.getItem(path_segments.numberOfItems - 1) ) );
+    }
+    if( edge_out_pattern.test(title) ) {
+      path_segments = $(this).siblings('path')[0].pathSegList;
+      edge_elements.push( new svgpath( path_segments.getItem(0) ) );
+    }
+  });
+  return edge_elements;
+} 
+
+$(document).ready(function () {
+  $('#graph').ajaxError(function() {
+    console.log( 'Oops.. something went wrong with trying to save this change. Please try again...' );
+  });
+  $('#graph').mousedown(function (event) {
+    $(this)
+      .data('down', true)
+      .data('x', event.clientX)
+      .data('scrollLeft', this.scrollLeft);
+      return false;
+  }).mouseup(function (event) {
+    $(this).data('down', false);
+  }).mousemove(function (event) {
+    if ($(this).data('down') == true ) {
+      this.scrollLeft = $(this).data('scrollLeft') + $(this).data('x') - event.clientX;
+    }
+  }).mousewheel(function (event, delta) {
+      this.scrollLeft -= (delta * 30);
+  }).css({
+    'overflow' : 'hidden',
+    'cursor' : '-moz-grab'
+  });
+  $( "#dialog-form" ).dialog({
+    autoOpen: false,
+    height: 150,
+    width: 250,
+    modal: true,
+    buttons: {
+      "Ok": function() {
+        form_values = $('#collapse_node_form').serialize()
+       ncpath = getRelativePath( 'node_collapse' );
+        var jqjson = $.getJSON( ncpath, form_values, function(data) {
+          $.each( data, function(item, collapse_info) { 
+            get_node_obj( item ).stack_behind( collapse_info );
+          });
+        });
+        $( this ).dialog( "close" );
+      },
+      Cancel: function() {
+        $( this ).dialog( "close" );
+      }
+    },
+    close: function() {
+      $('#reason').val( "" ).removeClass( "ui-state-error" );
+    }
+  });
+});
+
+
+$(window).mouseout(function (event) {
+  if ($('#graph').data('down')) {
+    try {
+      if (event.originalTarget.nodeName == 'BODY' || event.originalTarget.nodeName == 'HTML') {
+        $('#graph').data('down', false);
+      }                
+    } catch (e) {}
+  }
+});
+
+
diff --git a/TreeOfTexts/root/src/relate.tt b/TreeOfTexts/root/src/relate.tt
new file mode 100644 (file)
index 0000000..4ef43c3
--- /dev/null
@@ -0,0 +1,22 @@
+[% BLOCK js %]
+    <script type="text/javascript" src="js/jquery-1.4.4.min.js"></script>
+    <script type="text/javascript" src="js/jquery-ui-1.8.10.custom.min.js"></script>
+    <script type="text/javascript" src="js/interaction.js"></script>
+[% END %]
+
+<div id="row_directory">
+       <table>
+               <tr>
+[% FOREACH wit IN witnesses -%]
+                       <th>[% wit %]</th>
+[% END -%]
+               </tr>
+[% FOREACH row IN alignment -%]
+               <tr>
+[% FOREACH item in row -%]
+                       <td>[% item %]</td>
+[% END -%]
+               </tr>
+[% END -%]
+       </table>
+</div>
\ No newline at end of file
diff --git a/TreeOfTexts/t/view_JSON.t b/TreeOfTexts/t/view_JSON.t
new file mode 100644 (file)
index 0000000..aa5a7ce
--- /dev/null
@@ -0,0 +1,8 @@
+use strict;
+use warnings;
+use Test::More;
+
+
+BEGIN { use_ok 'TreeOfTexts::View::JSON' }
+
+done_testing();
index 94a2046..cf3211b 100644 (file)
@@ -729,7 +729,7 @@ sub make_alignment_table {
     }
     my $table = { 'alignment' => [], 'length' => $self->end->rank - 1 };
     my @all_pos = ( 1 .. $self->end->rank - 1 );
-    foreach my $wit ( $self->tradition->witnesses ) {
+    foreach my $wit ( sort { $a->sigil cmp $b->sigil } $self->tradition->witnesses ) {
        if( $include ) {
                next unless $include->{$wit->sigil};
        }
index 19140be..2c7b310 100644 (file)
@@ -2,12 +2,11 @@ package Text::Tradition::Stemma;
 
 use Bio::Phylo::IO;
 use Encode qw( decode_utf8 );
-use File::chdir;
 use File::Temp;
-use File::Which;
 use Graph;
 use Graph::Reader::Dot;
 use IPC::Run qw/ run binary /;
+use Text::Tradition::StemmaUtil qw/ character_input phylip_pars parse_newick /;
 use Moose;
 
 has collation => (
@@ -198,7 +197,7 @@ before 'distance_trees' => sub {
         my( $ok, $result ) = $self->$dsub();
         if( $ok ) {
             # Save the resulting trees
-            my $trees = _parse_newick( $result );
+            my $trees = parse_newick( $result );
             $self->_save_distance_trees( $trees );
             $self->distance_program( $args{'program'} );
         } else {
@@ -206,191 +205,11 @@ before 'distance_trees' => sub {
         }
     }
 };
-        
-sub make_character_matrix {
-    my $self = shift;
-    unless( $self->collation->linear ) {
-        warn "Need a linear graph in order to make an alignment table";
-        return;
-    }
-    my $table = $self->collation->make_alignment_table;
-    # Push the names of the witnesses to initialize the rows of the matrix.
-    my @matrix = map { [ $self->_normalize_ac( $_->{'witness'} ) ] } 
-                               @{$table->{'alignment'}};
-    foreach my $token_index ( 0 .. $table->{'length'} - 1) {
-        # First implementation: make dumb alignment table, caring about
-        # nothing except which reading is in which position.
-        my @pos_readings = map { $_->{'tokens'}->[$token_index] }
-                                                       @{$table->{'alignment'}};
-        my @pos_text = map { $_ ? $_->{'t'} : $_ } @pos_readings;
-        my @chars = convert_characters( \@pos_text );
-        foreach my $idx ( 0 .. $#matrix ) {
-            push( @{$matrix[$idx]}, $chars[$idx] );
-        }
-    }
-    return \@matrix;
-} 
-
-sub _normalize_ac {
-    my( $self, $witname ) = @_;
-    my $ac = $self->collation->ac_label;
-    if( $witname =~ /(.*)\Q$ac\E$/ ) {
-        $witname = $1 . '_ac';
-    }
-    return sprintf( "%-10s", $witname );
-}
-
-sub convert_characters {
-    my $row = shift;
-    # This is a simple algorithm that treats every reading as different.
-    # Eventually we will want to be able to specify how relationships
-    # affect the character matrix.
-    my %unique = ( '__UNDEF__' => 'X',
-                   '#LACUNA#'  => '?',
-                 );
-    my %count;
-    my $ctr = 0;
-    foreach my $word ( @$row ) {
-        if( $word && !exists $unique{$word} ) {
-            $unique{$word} = chr( 65 + $ctr );
-            $ctr++;
-        }
-        $count{$word}++ if $word;
-    }
-    # Try to keep variants under 8 by lacunizing any singletons.
-    if( scalar( keys %unique ) > 8 ) {
-               foreach my $word ( keys %count ) {
-                       if( $count{$word} == 1 ) {
-                               $unique{$word} = '?';
-                       }
-               }
-    }
-    my %u = reverse %unique;
-    if( scalar( keys %u ) > 8 ) {
-        warn "Have more than 8 variants on this location; phylip will break";
-    }
-    my @chars = map { $_ ? $unique{$_} : $unique{'__UNDEF__' } } @$row;
-    return @chars;
-}
-
-sub phylip_pars_input {
-    my $self = shift;
-    my $character_matrix = $self->make_character_matrix;
-    my $input = '';
-    my $rows = scalar @{$character_matrix};
-    my $columns = scalar @{$character_matrix->[0]} - 1;
-    $input .= "\t$rows\t$columns\n";
-    foreach my $row ( @{$character_matrix} ) {
-        $input .= join( '', @$row ) . "\n";
-    }
-    return $input;
-}
 
 sub run_phylip_pars {
-    my $self = shift;
-
-    # Set up a temporary directory for all the default Phylip files.
-    my $phylip_dir = File::Temp->newdir();
-    # $phylip_dir->unlink_on_destroy(0);
-    # We need an infile, and we need a command input file.
-    open( MATRIX, ">$phylip_dir/infile" ) or die "Could not write $phylip_dir/infile";
-    print MATRIX $self->phylip_pars_input();
-    close MATRIX;
-
-    open( CMD, ">$phylip_dir/cmdfile" ) or die "Could not write $phylip_dir/cmdfile";
-    ## TODO any configuration parameters we want to set here
-#   U                 Search for best tree?  Yes
-#   S                        Search option?  More thorough search
-#   V              Number of trees to save?  100
-#   J     Randomize input order of species?  No. Use input order
-#   O                        Outgroup root?  No, use as outgroup species 1
-#   T              Use Threshold parsimony?  No, use ordinary parsimony
-#   W                       Sites weighted?  No
-#   M           Analyze multiple data sets?  No
-#   I            Input species interleaved?  Yes
-#   0   Terminal type (IBM PC, ANSI, none)?  ANSI
-#   1    Print out the data at start of run  No
-#   2  Print indications of progress of run  Yes
-#   3                        Print out tree  Yes
-#   4          Print out steps in each site  No
-#   5  Print character at all nodes of tree  No
-#   6       Write out trees onto tree file?  Yes
-    print CMD "Y\n";
-    close CMD;
-
-    # And then we run the program.
-    my $program = File::Which::which( 'pars' );
-    unless( -x $program ) {
-               return( undef, "Phylip pars not found in path" );
-    }
-
-    {
-        # We need to run it in our temporary directory where we have created
-        # all the expected files.
-        local $CWD = $phylip_dir;
-        my @cmd = ( $program );
-        run \@cmd, '<', 'cmdfile', '>', '/dev/null';
-    }
-    # Now our output should be in 'outfile' and our tree in 'outtree',
-    # both in the temp directory.
-
-    my @outtree;
-    if( -f "$phylip_dir/outtree" ) {
-        open( TREE, "$phylip_dir/outtree" ) or die "Could not open outtree for read";
-        @outtree = <TREE>;
-        close TREE;
-    }
-    return( 1, join( '', @outtree ) ) if @outtree;
-
-    my @error;
-    if( -f "$phylip_dir/outfile" ) {
-        open( OUTPUT, "$phylip_dir/outfile" ) or die "Could not open output for read";
-        @error = <OUTPUT>;
-        close OUTPUT;
-    } else {
-        push( @error, "Neither outtree nor output file was produced!" );
-    }
-    return( undef, join( '', @error ) );
-}
-
-sub _parse_newick {
-    my $newick = shift;
-    my @trees;
-    # Parse the result into a tree
-    my $forest = Bio::Phylo::IO->parse( 
-        -format => 'newick',
-        -string => $newick,
-        );
-    # Turn the tree into a graph, starting with the root node
-    foreach my $tree ( @{$forest->get_entities} ) {
-        push( @trees, _graph_from_bio( $tree ) );
-    }
-    return \@trees;
-}
-
-sub _graph_from_bio {
-    my $tree = shift;
-    my $graph = Graph->new( 'undirected' => 1 );
-    # Give all the intermediate anonymous nodes a name.
-    my $i = 0;
-    foreach my $n ( @{$tree->get_entities} ) {
-        next if $n->get_name;
-        $n->set_name( $i++ );
-    }
-    my $root = $tree->get_root->get_name;
-    $graph->add_vertex( $root );
-    _add_tree_children( $graph, $root, $tree->get_root->get_children() );
-    return $graph;
-}
-
-sub _add_tree_children {
-    my( $graph, $parent, $tree_children ) = @_;
-    foreach my $c ( @$tree_children ) {
-        my $child = $c->get_name;
-        $graph->add_vertex( $child );
-        $graph->add_path( $parent, $child );
-        _add_tree_children( $graph, $child, $c->get_children() );
-    }
+       my $self = shift;
+       my $cdata = character_input( $self->collation->make_alignment_table() );
+       return phylip_pars( $cdata );
 }
 
 no Moose;
diff --git a/lib/Text/Tradition/StemmaUtil.pm b/lib/Text/Tradition/StemmaUtil.pm
new file mode 100644 (file)
index 0000000..6e6a11d
--- /dev/null
@@ -0,0 +1,213 @@
+package Text::Tradition::StemmaUtil;
+
+use strict;
+use warnings;
+use Exporter 'import';
+use vars qw/ @EXPORT_OK /;
+use Bio::Phylo::IO;
+use Encode qw( decode_utf8 );
+use File::chdir;
+use File::Temp;
+use File::Which;
+use Graph;
+use Graph::Reader::Dot;
+use IPC::Run qw/ run binary /;
+@EXPORT_OK = qw/ make_character_matrix character_input phylip_pars 
+                                parse_newick newick_to_svg /;
+
+sub make_character_matrix {
+    my( $table ) = @_;
+    # Push the names of the witnesses to initialize the rows of the matrix.
+    my @matrix = map { [ _normalize_witname( $_->{'witness'} ) ] } 
+                               @{$table->{'alignment'}};
+    foreach my $token_index ( 0 .. $table->{'length'} - 1) {
+        # First implementation: make dumb alignment table, caring about
+        # nothing except which reading is in which position.
+        my @pos_readings = map { $_->{'tokens'}->[$token_index] }
+                                                       @{$table->{'alignment'}};
+        my @pos_text = map { $_ ? $_->{'t'} : $_ } @pos_readings;
+        my @chars = convert_characters( \@pos_text );
+        foreach my $idx ( 0 .. $#matrix ) {
+            push( @{$matrix[$idx]}, $chars[$idx] );
+        }
+    }
+    return \@matrix;
+} 
+
+# Helper function to make the witness name something legal for pars
+
+sub _normalize_witname {
+    my( $witname ) = @_;
+    $witname =~ s/\s+/ /g;
+    $witname =~ s/[\[\]\(\)\:;,]//g;
+    $witname = substr( $witname, 0, 10 );
+    return sprintf( "%-10s", $witname );
+}
+
+sub convert_characters {
+    my $row = shift;
+    # This is a simple algorithm that treats every reading as different.
+    # Eventually we will want to be able to specify how relationships
+    # affect the character matrix.
+    my %unique = ( '__UNDEF__' => 'X',
+                   '#LACUNA#'  => '?',
+                 );
+    my %count;
+    my $ctr = 0;
+    foreach my $word ( @$row ) {
+        if( $word && !exists $unique{$word} ) {
+            $unique{$word} = chr( 65 + $ctr );
+            $ctr++;
+        }
+        $count{$word}++ if $word;
+    }
+    # Try to keep variants under 8 by lacunizing any singletons.
+    if( scalar( keys %unique ) > 8 ) {
+               foreach my $word ( keys %count ) {
+                       if( $count{$word} == 1 ) {
+                               $unique{$word} = '?';
+                       }
+               }
+    }
+    my %u = reverse %unique;
+    if( scalar( keys %u ) > 8 ) {
+        warn "Have more than 8 variants on this location; phylip will break";
+    }
+    my @chars = map { $_ ? $unique{$_} : $unique{'__UNDEF__' } } @$row;
+    return @chars;
+}
+
+sub character_input {
+    my $table = shift;
+    my $character_matrix = make_character_matrix( $table );
+    my $input = '';
+    my $rows = scalar @{$character_matrix};
+    my $columns = scalar @{$character_matrix->[0]} - 1;
+    $input .= "\t$rows\t$columns\n";
+    foreach my $row ( @{$character_matrix} ) {
+        $input .= join( '', @$row ) . "\n";
+    }
+    return $input;
+}
+
+sub phylip_pars {
+       my( $charmatrix ) = @_;
+    # Set up a temporary directory for all the default Phylip files.
+    my $phylip_dir = File::Temp->newdir();
+    # $phylip_dir->unlink_on_destroy(0);
+    # We need an infile, and we need a command input file.
+    open( MATRIX, ">$phylip_dir/infile" ) or die "Could not write $phylip_dir/infile";
+    print MATRIX $charmatrix;
+    close MATRIX;
+
+    open( CMD, ">$phylip_dir/cmdfile" ) or die "Could not write $phylip_dir/cmdfile";
+    ## TODO any configuration parameters we want to set here
+#   U                 Search for best tree?  Yes
+#   S                        Search option?  More thorough search
+#   V              Number of trees to save?  100
+#   J     Randomize input order of species?  No. Use input order
+#   O                        Outgroup root?  No, use as outgroup species 1
+#   T              Use Threshold parsimony?  No, use ordinary parsimony
+#   W                       Sites weighted?  No
+#   M           Analyze multiple data sets?  No
+#   I            Input species interleaved?  Yes
+#   0   Terminal type (IBM PC, ANSI, none)?  ANSI
+#   1    Print out the data at start of run  No
+#   2  Print indications of progress of run  Yes
+#   3                        Print out tree  Yes
+#   4          Print out steps in each site  No
+#   5  Print character at all nodes of tree  No
+#   6       Write out trees onto tree file?  Yes
+    print CMD "Y\n";
+    close CMD;
+
+    # And then we run the program.
+    my $program = File::Which::which( 'pars' );
+    unless( -x $program ) {
+               return( undef, "Phylip pars not found in path" );
+    }
+
+    {
+        # We need to run it in our temporary directory where we have created
+        # all the expected files.
+        local $CWD = $phylip_dir;
+        my @cmd = ( $program );
+        run \@cmd, '<', 'cmdfile', '>', '/dev/null';
+    }
+    # Now our output should be in 'outfile' and our tree in 'outtree',
+    # both in the temp directory.
+
+    my @outtree;
+    if( -f "$phylip_dir/outtree" ) {
+        open( TREE, "$phylip_dir/outtree" ) or die "Could not open outtree for read";
+        @outtree = <TREE>;
+        close TREE;
+    }
+    return( 1, join( '', @outtree ) ) if @outtree;
+
+    my @error;
+    if( -f "$phylip_dir/outfile" ) {
+        open( OUTPUT, "$phylip_dir/outfile" ) or die "Could not open output for read";
+        @error = <OUTPUT>;
+        close OUTPUT;
+    } else {
+        push( @error, "Neither outtree nor output file was produced!" );
+    }
+    return( undef, join( '', @error ) );
+}
+
+sub parse_newick {
+    my $newick = shift;
+    my @trees;
+    # Parse the result into a tree
+    my $forest = Bio::Phylo::IO->parse( 
+        -format => 'newick',
+        -string => $newick,
+        );
+    # Turn the tree into a graph, starting with the root node
+    foreach my $tree ( @{$forest->get_entities} ) {
+        push( @trees, _graph_from_bio( $tree ) );
+    }
+    return \@trees;
+}
+
+sub newick_to_svg {
+       my $newick = shift;
+    my $program = File::Which::which( 'figtree' );
+    unless( -x $program ) {
+               warn "FigTree commandline utility not found in path";
+               return;
+    }
+    my $svg;
+    my $nfile = File::Temp->new();
+    print $nfile $newick;
+    close $nfile;
+       my @cmd = ( $program, '-graphic', 'SVG', $nfile );
+    run( \@cmd, ">", binary(), \$svg );
+    return decode_utf8( $svg );
+}
+
+sub _graph_from_bio {
+    my $tree = shift;
+    my $graph = Graph->new( 'undirected' => 1 );
+    # Give all the intermediate anonymous nodes a name.
+    my $i = 0;
+    foreach my $n ( @{$tree->get_entities} ) {
+        next if $n->get_name;
+        $n->set_name( $i++ );
+    }
+    my $root = $tree->get_root->get_name;
+    $graph->add_vertex( $root );
+    _add_tree_children( $graph, $root, $tree->get_root->get_children() );
+    return $graph;
+}
+
+sub _add_tree_children {
+    my( $graph, $parent, $tree_children ) = @_;
+    foreach my $c ( @$tree_children ) {
+        my $child = $c->get_name;
+        $graph->add_vertex( $child );
+        $graph->add_path( $parent, $child );
+        _add_tree_children( $graph, $child, $c->get_children() );
+    }
+}
index 67ded66..8d2fd6e 100644 (file)
@@ -5,6 +5,7 @@ use File::Which;
 use Test::More;
 use lib 'lib';
 use Text::Tradition;
+use Text::Tradition::StemmaUtil qw/ make_character_matrix /;
 use XML::LibXML;
 use XML::LibXML::XPathContext;
 
@@ -29,7 +30,7 @@ ok( $stemma->isa( 'Text::Tradition::Stemma' ), 'Got the right sort of object' );
 is( $stemma->graph, '1-2,1-A,2-B,2-C', "Got the correct graph" );
 
 # Test for character matrix creation
-my $m = $stemma->make_character_matrix();
+my $m = make_character_matrix( $c->make_alignment_table() );
  ## check number of rows
 is( scalar @$m, 3, "Found three witnesses in char matrix" );
  ## check number of columns