Merge branch 'master' of github.com:tla/stemmatology
Tara L Andrews [Fri, 3 Feb 2012 14:16:28 +0000 (15:16 +0100)]
38 files changed:
MANIFEST.SKIP [new file with mode: 0644]
lib/Text/Tradition.pm
lib/Text/Tradition/Analysis.pm
lib/Text/Tradition/Collation.pm
lib/Text/Tradition/Collation/Reading.pm
lib/Text/Tradition/Collation/Relationship.pm
lib/Text/Tradition/Collation/RelationshipStore.pm
lib/Text/Tradition/Directory.pm
lib/Text/Tradition/Error.pm
lib/Text/Tradition/Parser/BaseText.pm
lib/Text/Tradition/Parser/CollateX.pm
lib/Text/Tradition/Parser/JSON.pm
lib/Text/Tradition/Parser/Self.pm
lib/Text/Tradition/Parser/TEI.pm
lib/Text/Tradition/Parser/Tabular.pm
lib/Text/Tradition/Parser/Util.pm
lib/Text/Tradition/Stemma.pm
lib/Text/Tradition/StemmaUtil.pm
script/make_tradition.pl
stemmaweb/lib/stemmaweb/Controller/Relation.pm
stemmaweb/lib/stemmaweb/Controller/Root.pm
stemmaweb/lib/stemmaweb/Controller/Stexaminer.pm
stemmaweb/root/css/relationship.css [new file with mode: 0644]
stemmaweb/root/css/style.css
stemmaweb/root/images/act_arrs.gif [new file with mode: 0644]
stemmaweb/root/images/ajax-loader.gif [new file with mode: 0644]
stemmaweb/root/js/componentload.js
stemmaweb/root/js/jquery.mousewheel.min.js [new file with mode: 0644]
stemmaweb/root/js/relationship.js
stemmaweb/root/src/directory.tt
stemmaweb/root/src/header.tt
stemmaweb/root/src/index.tt
stemmaweb/root/src/relate.tt
t/03podcoverage.t
t/graph.t
t/stemma.t
t/text_tradition.t
t/text_tradition_directory.t

diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP
new file mode 100644 (file)
index 0000000..cb8c062
--- /dev/null
@@ -0,0 +1,5 @@
+lib/Text/Tradition/Parser/BaseText.pm
+lib/Text/Tradition/Parser/CollateText.pm
+lib/Text/Tradition/Parser/CTE.pm
+lib/Text/Tradition/Parser/KUL.pm
+lib/Text/Tradition/Analysis.pm
index 7995d05..8bed34d 100644 (file)
@@ -34,11 +34,17 @@ has 'name' => (
     default => 'Tradition',
     );
     
-has 'stemma' => (
-       is => 'ro',
-       isa => 'Text::Tradition::Stemma',
-       writer => '_add_stemma',
-       predicate => 'has_stemma',
+has 'stemmata' => (
+       traits => ['Array'],
+       isa => 'ArrayRef[Text::Tradition::Stemma]',
+       handles => {
+               all_stemmata => 'elements',
+               _add_stemma => 'push',
+               stemma => 'get',
+               stemma_count => 'count',
+               clear_stemmata => 'clear',
+       },
+       default => sub { [] },
        );
   
 # Create the witness before trying to add it
@@ -301,10 +307,12 @@ my $t = Text::Tradition->new(
     'file'  => 't/data/simple.txt',
     );
 
+is( $t->stemma_count, 0, "No stemmas added yet" );
 my $s;
 ok( $s = $t->add_stemma( dotfile => 't/data/simple.dot' ), "Added a simple stemma" );
 is( ref( $s ), 'Text::Tradition::Stemma', "Got a stemma object returned" );
-is( $t->stemma, $s, "Stemma is the right one" );
+is( $t->stemma_count, 1, "Tradition claims to have a stemma" );
+is( $t->stemma(0), $s, "Tradition hands back the right stemma" );
 
 =end testing
 
index 7bded9e..910f994 100644 (file)
@@ -17,11 +17,11 @@ sub run_analysis {
        my $data = {};
        
        # We need a stemma in order to run this...
-       unless( $tradition->has_stemma ) {
+       unless( $tradition->stemma_count ) {
                warn "Tradition '" . $tradition->name . "' has no stemma to analyze";
                return undef;
        }
-       my $stemma = $tradition->stemma;
+       my $stemma = $tradition->stemma(0); # TODO allow multiple
                
        # We have the collation, so get the alignment table with witnesses in rows.
        # Also return the reading objects in the table, rather than just the words.
index 4370f78..85d09bf 100644 (file)
@@ -9,6 +9,7 @@ use Text::Tradition::Collation::Reading;
 use Text::Tradition::Collation::RelationshipStore;
 use Text::Tradition::Error;
 use XML::LibXML;
+use XML::LibXML::XPathContext;
 use Moose;
 
 has 'sequence' => (
@@ -431,67 +432,51 @@ sub reading_witnesses {
 
 =head1 OUTPUT METHODS
 
-=head2 as_svg
+=head2 as_svg( \%options )
 
 Returns an SVG string that represents the graph, via as_dot and graphviz.
+See as_dot for a list of options.
 
 =cut
 
 sub as_svg {
-    my( $self ) = @_;
+    my( $self, $opts ) = @_;
         
     my @cmd = qw/dot -Tsvg/;
     my( $svg, $err );
     my $dotfile = File::Temp->new();
-    ## TODO REMOVE
+    ## USE FOR DEBUGGING
     # $dotfile->unlink_on_destroy(0);
     binmode $dotfile, ':utf8';
-    print $dotfile $self->as_dot();
+    print $dotfile $self->as_dot( $opts );
     push( @cmd, $dotfile->filename );
     run( \@cmd, ">", binary(), \$svg );
-    $svg = decode_utf8( $svg );
-    return $svg;
+    return decode_utf8( $svg );
 }
 
-=head2 svg_subgraph( $from, $to )
 
-Returns an SVG string that represents the portion of the graph given by the
-specified range.  The $from and $to variables refer to ranks within the graph.
+=head2 as_dot( \%options )
 
-=cut
+Returns a string that is the collation graph expressed in dot
+(i.e. GraphViz) format.  Options include:
 
-sub svg_subgraph {
-    my( $self, $from, $to ) = @_;
-    
-    my $dot = $self->as_dot( $from, $to );
-    unless( $dot ) {
-       throw( "Could not output a graph with range $from - $to" );
-    }
-    
-    my @cmd = qw/dot -Tsvg/;
-    my( $svg, $err );
-    my $dotfile = File::Temp->new();
-    ## TODO REMOVE
-    # $dotfile->unlink_on_destroy(0);
-    binmode $dotfile, ':utf8';
-    print $dotfile $dot;
-    push( @cmd, $dotfile->filename );
-    run( \@cmd, ">", binary(), \$svg );
-    $svg = decode_utf8( $svg );
-    return $svg;
-}
+=over 4
 
+=item * from
 
-=head2 as_dot( $from, $to )
+=item * to
 
-Returns a string that is the collation graph expressed in dot
-(i.e. GraphViz) format.  If $from or $to is passed, as_dot creates
-a subgraph rather than the entire graph.
+=item * color_common
+
+=back
 
 =cut
 
 sub as_dot {
-    my( $self, $startrank, $endrank ) = @_;
+    my( $self, $opts ) = @_;
+    my $startrank = $opts->{'from'} if $opts;
+    my $endrank = $opts->{'to'} if $opts;
+    my $color_common = $opts->{'color_common'} if $opts;
     
     # Check the arguments
     if( $startrank ) {
@@ -513,7 +498,7 @@ sub as_dot {
        'bgcolor' => 'none',
        );
     my %node_attrs = (
-       'fontsize' => 11,
+       'fontsize' => 14,
        'fillcolor' => 'white',
        'style' => 'filled',
        'shape' => 'ellipse'
@@ -535,8 +520,13 @@ sub as_dot {
        if( $endrank ) {
                $dot .= "\t\"#SUBEND#\" [ label=\"...\" ];\n";  
        }
+
        my %used;  # Keep track of the readings that actually appear in the graph
-    foreach my $reading ( $self->readings ) {
+       # Sort the readings by rank if we have ranks; this speeds layout.
+       my @all_readings = $self->end->has_rank 
+               ? sort { $a->rank <=> $b->rank } $self->readings
+               : $self->readings;
+    foreach my $reading ( @all_readings ) {
        # Only output readings within our rank range.
        next if $startrank && $reading->rank < $startrank;
        next if $endrank && $reading->rank > $endrank;
@@ -547,27 +537,37 @@ sub as_dot {
         my $label = $reading->text;
         $label =~ s/\"/\\\"/g;
                $rattrs->{'label'} = $label;
-               # TODO make this an option?
-               # $rattrs->{'fillcolor'} = 'green' if $reading->is_common;
+               $rattrs->{'fillcolor'} = '#b3f36d' if $reading->is_common && $color_common;
         $dot .= sprintf( "\t\"%s\" %s;\n", $reading->id, _dot_attr_string( $rattrs ) );
     }
     
-       # Add the real edges
+       # Add the real edges. Need to weight one edge per rank jump, in a
+       # continuous line.
+       my $weighted = $self->_add_edge_weights;
     my @edges = $self->paths;
        my( %substart, %subend );
     foreach my $edge ( @edges ) {
        # Do we need to output this edge?
        if( $used{$edge->[0]} && $used{$edge->[1]} ) {
-               my $label = $self->path_display_label( $self->path_witnesses( $edge ) );
+               my $label = $self->_path_display_label( $self->path_witnesses( $edge ) );
                        my $variables = { %edge_attrs, 'label' => $label };
+                       
                        # Account for the rank gap if necessary
-                       if( $self->reading( $edge->[1] )->has_rank 
-                               && $self->reading( $edge->[0] )->has_rank
-                               && $self->reading( $edge->[1] )->rank 
-                               - $self->reading( $edge->[0] )->rank > 1 ) {
-                               $variables->{'minlen'} = $self->reading( $edge->[1] )->rank 
-                               - $self->reading( $edge->[0] )->rank;
+                       my $rank0 = $self->reading( $edge->[0] )->rank
+                               if $self->reading( $edge->[0] )->has_rank;
+                       my $rank1 = $self->reading( $edge->[1] )->rank
+                               if $self->reading( $edge->[1] )->has_rank;
+                       if( defined $rank0 && defined $rank1 && $rank1 - $rank0 > 1 ) {
+                               $variables->{'minlen'} = $rank1 - $rank0;
                        }
+                       
+                       # Add the calculated edge weights
+                       if( exists $weighted->{$edge->[0]} 
+                               && $weighted->{$edge->[0]} eq $edge->[1] ) {
+                               # $variables->{'color'} = 'red';
+                               $variables->{'weight'} = 3.0;
+                       }
+
                        # EXPERIMENTAL: make edge width reflect no. of witnesses
                        my $extrawidth = scalar( $self->path_witnesses( $edge ) ) * 0.2;
                        $variables->{'penwidth'} = $extrawidth + 0.8; # gives 1 for a single wit
@@ -583,18 +583,18 @@ sub as_dot {
     }
     # Add substitute start and end edges if necessary
     foreach my $node ( keys %substart ) {
-       my $witstr = $self->path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
+       my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
        my $variables = { %edge_attrs, 'label' => $witstr };
         my $varopts = _dot_attr_string( $variables );
         $dot .= "\t\"#SUBSTART#\" -> \"$node\" $varopts;";
        }
     foreach my $node ( keys %subend ) {
-       my $witstr = $self->path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
+       my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
        my $variables = { %edge_attrs, 'label' => $witstr };
         my $varopts = _dot_attr_string( $variables );
         $dot .= "\t\"$node\" -> \"#SUBEND#\" $varopts;";
        }
-       
+
     $dot .= "}\n";
     return $dot;
 }
@@ -609,6 +609,34 @@ sub _dot_attr_string {
        return( '[ ' . join( ', ', @attrs ) . ' ]' );
 }
 
+sub _add_edge_weights {
+       my $self = shift;
+       # Walk the graph from START to END, choosing the successor node with
+       # the largest number of witness paths each time.
+       my $weighted = {};
+       my $curr = $self->start->id;
+       while( $curr ne $self->end->id ) {
+               my @succ = sort { $self->path_witnesses( $curr, $a )
+                                                       <=> $self->path_witnesses( $curr, $b ) } 
+                       $self->sequence->successors( $curr );
+               my $next = pop @succ;
+               # Try to avoid lacunae in the weighted path.
+               while( $self->reading( $next )->is_lacuna && @succ ) {
+                       $next = pop @succ;
+               }
+               $weighted->{$curr} = $next;
+               $curr = $next;
+       }
+       return $weighted;       
+}
+
+=head2 path_witnesses( $edge )
+
+Returns the list of sigils whose witnesses are associated with the given edge.
+The edge can be passed as either an array or an arrayref of ( $source, $target ).
+
+=cut
+
 sub path_witnesses {
        my( $self, @edge ) = @_;
        # If edge is an arrayref, cope.
@@ -620,7 +648,7 @@ sub path_witnesses {
        return @wits;
 }
 
-sub path_display_label {
+sub _path_display_label {
        my $self = shift;
        my @wits = sort @_;
        my $maj = scalar( $self->tradition->witnesses ) * 0.6;
@@ -811,7 +839,7 @@ sub as_graphml {
        }
        
        # Add the relationship graph to the XML
-       $self->relations->as_graphml( $graphml_ns, $root, \%node_hash, 
+       $self->relations->_as_graphml( $graphml_ns, $root, \%node_hash, 
                $node_data_keys{'id'}, \%edge_data_keys );
 
     # Save and return the thing
@@ -874,9 +902,11 @@ keys have a true hash value will be included.
 
 sub make_alignment_table {
     my( $self, $noderefs, $include ) = @_;
-    unless( $self->linear ) {
-        throw( "Need a linear graph in order to make an alignment table" );
-    }
+    # Make sure we can do this
+       throw( "Need a linear graph in order to make an alignment table" )
+               unless $self->linear;
+       $self->calculate_ranks unless $self->end->has_rank;
+       
     my $table = { 'alignment' => [], 'length' => $self->end->rank - 1 };
     my @all_pos = ( 1 .. $self->end->rank - 1 );
     foreach my $wit ( sort { $a->sigil cmp $b->sigil } $self->tradition->witnesses ) {
@@ -1292,6 +1322,7 @@ sub flatten_ranks {
             # Combine!
                # print STDERR "Combining readings at same rank: $key\n";
             $self->merge_readings( $unique_rank_rdg{$key}, $rdg );
+            # TODO see if this now makes a common point.
         } else {
             $unique_rank_rdg{$key} = $rdg;
         }
@@ -1413,16 +1444,16 @@ is( $c->common_successor( 'n21', 'n26' )->id,
 sub common_predecessor {
        my $self = shift;
        my( $r1, $r2 ) = $self->_objectify_args( @_ );
-       return $self->common_in_path( $r1, $r2, 'predecessors' );
+       return $self->_common_in_path( $r1, $r2, 'predecessors' );
 }
 
 sub common_successor {
        my $self = shift;
        my( $r1, $r2 ) = $self->_objectify_args( @_ );
-       return $self->common_in_path( $r1, $r2, 'successors' );
+       return $self->_common_in_path( $r1, $r2, 'successors' );
 }
 
-sub common_in_path {
+sub _common_in_path {
        my( $self, $r1, $r2, $dir ) = @_;
        my $iter = $r1->rank > $r2->rank ? $r1->rank : $r2->rank;
        $iter = $self->end->rank - $iter if $dir eq 'successors';
@@ -1457,10 +1488,12 @@ sub throw {
 no Moose;
 __PACKAGE__->meta->make_immutable;
 
-=head1 BUGS / TODO
+=head1 LICENSE
 
-=over
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
 
-=item * Get rid of $backup in reading_sequence
+=head1 AUTHOR
 
-=back
+Tara L Andrews E<lt>aurum@cpan.orgE<gt>
index 1d700a6..80f737c 100644 (file)
@@ -160,24 +160,50 @@ sub is_meta {
        return $self->is_start || $self->is_end || $self->is_lacuna || $self->is_ph;    
 }
 
-# Some syntactic sugar
+=head1 Convenience methods
+
+=head2 related_readings
+
+Calls Collation's related_readings with $self as the first argument.
+
+=cut
+
 sub related_readings {
        my $self = shift;
        return $self->collation->related_readings( $self, @_ );
 }
 
+=head2 predecessors
+
+Returns a list of Reading objects that immediately precede $self in the collation.
+
+=cut
+
 sub predecessors {
        my $self = shift;
        my @pred = $self->collation->sequence->predecessors( $self->id );
        return map { $self->collation->reading( $_ ) } @pred;
 }
 
+=head2 successors
+
+Returns a list of Reading objects that immediately follow $self in the collation.
+
+=cut
+
 sub successors {
        my $self = shift;
        my @succ = $self->collation->sequence->successors( $self->id );
        return map { $self->collation->reading( $_ ) } @succ;
 }
 
+=head2 set_identical( $other_reading)
+
+Backwards compatibility method, to add a transposition relationship
+between $self and $other_reading.  Don't use this.
+
+=cut
+
 sub set_identical {
        my( $self, $other ) = @_;
        return $self->collation->add_relationship( $self, $other, 
index efefaab..07c6293 100644 (file)
@@ -10,6 +10,25 @@ enum 'RelationshipScope' => qw( local tradition global );
 
 no Moose::Util::TypeConstraints;
 
+=head1 NAME
+
+Text::Tradition::Collation::Relationship - represents a syntactic or semantic
+relationship between two readings
+    
+=head1 DESCRIPTION
+
+Text::Tradition is a library for representation and analysis of collated
+texts, particularly medieval ones.  A relationship connects two readings
+within a collation, usually when they appear in the same place in different
+texts.
+
+=head1 CONSTRUCTOR
+
+=head2 new
+
+Creates a new relationship. Usually called via $collation->add_relationship.
+Options include:
+
 =over 4
 
 =item * type - Can be one of spelling, orthographic, grammatical, meaning, lexical, collated, repetition, transposition.  All but the last two are only valid relationships between readings that occur at the same point in the text.
@@ -24,6 +43,20 @@ no Moose::Util::TypeConstraints;
 
 =back
 
+=head1 ACCESSORS
+
+=head2 type
+
+=head2 displayform
+
+=head2 scope
+
+=head2 non_correctable
+
+=head2 non_independent
+
+See the option descriptions above.
+
 =cut
 
 has 'type' => (
@@ -69,11 +102,25 @@ has 'non_independent' => (
        );
        
 # A read-only meta-Boolean attribute.
+
+=head2 colocated
+
+Returns true if the relationship type is one that requires that its readings
+occupy the same place in the collation.
+
+=cut
+
 sub colocated {
        my $self = shift;
        return $self->type !~ /^(repetition|transposition)$/;
 }
 
+=head2 nonlocal
+
+Returns true if the relationship scope is anything other than 'local'.
+
+=cut
+
 sub nonlocal {
        my $self = shift;
        return $self->scope ne 'local';
index e9749ad..93c4699 100644 (file)
@@ -365,7 +365,7 @@ sub merge_readings {
        $self->delete_reading( $deleted );
 }
 
-sub as_graphml { 
+sub _as_graphml { 
        my( $self, $graphml_ns, $xmlroot, $node_hash, $nodeid_key, $edge_keys ) = @_;
        
     my $rgraph = $xmlroot->addNewChild( $graphml_ns, 'graph' );
index 7f97648..f01d19b 100644 (file)
@@ -82,7 +82,7 @@ my $t = Text::Tradition->new(
        my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
        ok( $d->save( $t ), "Updated tradition with stemma" );
        is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
-       is( $d->tradition( $uuid )->stemma, $s, "...and it has the correct stemma" );
+       is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
        try {
                $d->save( $s );
        } catch( Text::Tradition::Error $e ) {
@@ -107,7 +107,7 @@ is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
        is( scalar $f->tradition_ids, 2, "Directory index has both traditions" );
        my $tf = $f->tradition( $uuid );
        is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
-       my $sid = $f->object_to_id( $tf->stemma );
+       my $sid = $f->object_to_id( $tf->stemma(0) );
        try {
                $f->tradition( $sid );
        } catch( Text::Tradition::Error $e ) {
@@ -207,4 +207,12 @@ sub throw {
 
 1;
        
-               
\ No newline at end of file
+=head1 LICENSE
+
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
+
+=head1 AUTHOR
+
+Tara L Andrews E<lt>aurum@cpan.orgE<gt>
index 18cb10e..42a9997 100644 (file)
@@ -25,7 +25,11 @@ Text::Tradition::Error - throwable error class for CollateX package
 
 A basic exception class to throw around, as it were.
 
-=cut
+=head1 LICENSE
+
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
 
 =head1 AUTHOR
 
index 96d54ff..7513135 100644 (file)
@@ -303,7 +303,7 @@ sub merge_base {
 #                             $rel->type, $rel->from->id, $rel->to->id );
 #         }
 #     }
-    $collation->calculate_ranks();
+    $collation->calculate_common_readings(); # will implicitly rank
 }
 
 =item B<read_base>
index 0d9db22..7191f7e 100644 (file)
@@ -156,7 +156,7 @@ sub parse {
     }
     
     # Rank the readings.
-    $collation->calculate_ranks() if $collation->linear;
+    $collation->calculate_common_readings(); # will implicitly rank
 
     # Save the text for each witness so that we can ensure consistency
     # later on
index 3e30651..cc960b4 100644 (file)
@@ -195,6 +195,7 @@ sub make_nodes {
        my( $c, $idx, @tokens ) = @_;
        my %unique;
        my @readings;
+       my $commonctr = 0;
        foreach my $j ( 0 .. $#tokens ) {
                if( $tokens[$j] ) {
                        my $word = _restore_punct( $tokens[$j] );
@@ -203,17 +204,30 @@ sub make_nodes {
                                $rdg = $unique{$word};
                        } else {
                                my %args = ( 'id' => join( ',', $idx, $j+1 ),
+                                       'rank' => $idx,
                                        'text' => $word,
                                        'collation' => $c );
-                               $args{'is_lacuna'} = 1 if $word eq '#LACUNA#';
+                               if( $word eq '#LACUNA#' ) {
+                                       $args{'is_lacuna'} = 1 
+                               } else {
+                                       $commonctr++;
+                               }
                                $rdg = Text::Tradition::Collation::Reading->new( %args );
                                $unique{$word} = $rdg;
                        }
                        push( @readings, $rdg );
                } else {
+                       $commonctr++;
                        push( @readings, undef );
                }
        }
+       if( $commonctr == 1 ) {
+               # Whichever reading isn't a lacuna is a common node.
+               foreach my $rdg ( values %unique ) {
+                       next if $rdg->is_lacuna;
+                       $rdg->is_common( 1 );
+               }
+       }
        map { $c->add_reading( $_ ) } values( %unique );
        return @readings;
 }
index b1082f3..ca58c33 100644 (file)
@@ -118,11 +118,11 @@ if( $t ) {
 =cut
 
 my( $IDKEY, $TOKENKEY, $TRANSPOS_KEY, $RANK_KEY,
-       $START_KEY, $END_KEY, $LACUNA_KEY,
+       $START_KEY, $END_KEY, $LACUNA_KEY, $COMMON_KEY,
        $SOURCE_KEY, $TARGET_KEY, $WITNESS_KEY, $EXTRA_KEY, $RELATIONSHIP_KEY,
        $SCOPE_KEY, $CORRECT_KEY, $INDEP_KEY )
     = qw/ id text identical rank 
-         is_start is_end is_lacuna 
+         is_start is_end is_lacuna is_common
          source target witness extra relationship
          scope non_correctable non_independent /;
 
@@ -167,6 +167,7 @@ sub parse {
         my $reading_options = { 
                'id' => $n->{$IDKEY},
                'is_lacuna' => $n->{$LACUNA_KEY},
+               'is_common' => $n->{$COMMON_KEY},
                };
         my $rank = $n->{$RANK_KEY};
                $reading_options->{'rank'} = $rank if $rank;
index 3131774..3d5fd03 100644 (file)
@@ -189,6 +189,9 @@ sub parse {
     # text and identical rank that can be merged.
     $tradition->collation->flatten_ranks();
     
+    # And now that we've done that, calculate the common nodes.
+    $tradition->collation->calculate_common_readings();
+    
     # Save the text for each witness so that we can ensure consistency
     # later on
        $tradition->collation->text_from_paths();       
index 40cf668..723c0aa 100644 (file)
@@ -177,14 +177,13 @@ sub parse {
     # add them to the witness paths.
     foreach my $idx ( 1 .. $#{$alignment_table} ) {
         my $row = $alignment_table->[$idx];
-        my $nodes = make_nodes( $c, $row, $idx );
+        my $nodes = _make_nodes( $c, $row, $idx );
         foreach my $w ( 0 .. $#{$row} ) {
             # push the appropriate node onto the appropriate witness path
             my $word = $row->[$w];
             if( $word ) {
                 my $reading = $nodes->{$word};
                 my $wit = $witnesses[$w];
-                $DB::single = 1 unless $wit;
                 push( @{$wit->path}, $reading );
             } # else skip it for empty readings.
         }
@@ -244,11 +243,13 @@ sub parse {
        }
 }
 
-sub make_nodes {
+sub _make_nodes {
     my( $collation, $row, $index ) = @_;
     my %unique;
+    my $commonctr = 0; # Holds the number of unique readings + gaps, ex. lacunae.
     foreach my $w ( @$row ) {
         $unique{$w} = 1 if $w;
+        $commonctr +=1 unless ( $w && $w eq '#LACUNA#' );
     }
     my $ctr = 1;
     foreach my $w ( keys %unique ) {
@@ -257,7 +258,11 @@ sub make_nodes {
                'rank' => $index,
                'text' => $w,
                };
-       $rargs->{'is_lacuna'} = 1 if $w eq '#LACUNA#';
+       if( $w eq '#LACUNA#' ) {
+               $rargs->{'is_lacuna'} = 1;
+       } elsif( $commonctr == 1 ) {
+               $rargs->{'is_common'} = 1;
+       }
         my $r = $collation->add_reading( $rargs );
         $unique{$w} = $r;
         $ctr++;
index a01cbd4..2cca6e8 100644 (file)
@@ -36,11 +36,11 @@ sub collate_variants {
     # transposed reading nodes to be merged into one (producing a
     # nonlinear, bidirectional graph) or not (producing a relatively
     # linear, unidirectional graph.)
-    return $collation->linear ? collate_linearly( @_ )
-        : collate_nonlinearly( @_ );
+    return $collation->linear ? _collate_linearly( @_ )
+        : _collate_nonlinearly( @_ );
 }
 
-sub collate_linearly {
+sub _collate_linearly {
     my( $collation, $lemma_set, @variant_sets ) = @_;
 
     my @unique;
@@ -87,7 +87,7 @@ sub collate_linearly {
     return $substitutions;
 }
 
-sub collate_nonlinearly {
+sub _collate_nonlinearly {
     my( $collation, $lemma_set, @variant_sets ) = @_;
     
     my @unique;
@@ -132,6 +132,12 @@ sub _collation_hash {
     return cmp_str( $node );
 }
 
+=head2 B<cmp_str>
+
+Don't use this. Really.
+
+=cut
+
 sub cmp_str {
     my( $reading ) = @_;
     my $word = $reading->text();
@@ -166,6 +172,12 @@ sub check_for_repeated {
     return @repeated;
 }
 
+=head2 B<add_hash_entry>( $hash, $key, $entry )
+
+Very simple utility for adding $entry to the list at $hash->{$key}.
+
+=cut
+
 sub add_hash_entry {
     my( $hash, $key, $entry ) = @_;
     if( exists $hash->{$key} ) {
@@ -175,24 +187,6 @@ sub add_hash_entry {
     }
 }
 
-sub is_monotonic {
-    my( @readings ) = @_;
-    my( $common, $min, $max ) = ( -1, -1, -1 );
-    foreach my $rdg ( @readings ) {
-#         print STDERR "Checking reading " . $rdg->id . "/" . $rdg->text . " - " 
-#         . $rdg->position->reference ."\n";
-        return 0 if $rdg->position->common < $common;
-        if( $rdg->position->common == $common ) {
-            return 0 if $rdg->position->min <= $min;
-            return 0 if $rdg->position->max <= $max;
-        }
-        $common = $rdg->position->common;
-        $min = $rdg->position->min;
-        $max = $rdg->position->max;
-    }
-    return 1;
-}
-
 1;
 
 =head1 BUGS / TODO
index 35c0f1e..b875e4e 100644 (file)
@@ -10,6 +10,91 @@ use Text::Tradition::Error;
 use Text::Tradition::StemmaUtil qw/ character_input phylip_pars parse_newick /;
 use Moose;
 
+=head1 NAME
+
+Text::Tradition::Stemma - a representation of a I<stemma codicum> for a Text::Tradition
+
+=head1 SYNOPSIS
+
+  use Text::Tradition;
+  my $t = Text::Tradition->new( 
+    'name' => 'this is a text',
+    'input' => 'TEI',
+    'file' => '/path/to/tei_parallel_seg_file.xml' );
+
+  my $s = $tradition->add_stemma( dotfile => '/path/to/stemma.dot' );
+    
+=head1 DESCRIPTION
+
+Text::Tradition is a library for representation and analysis of collated
+texts, particularly medieval ones.  The Collation is the central feature of
+a Tradition, where the text, its sequence of readings, and its relationships
+between readings are actually kept.
+
+=head1 DOT SYNTAX
+
+The easiest way to define a stemma (which is a directed acyclic graph, denoting 
+the scholar's hypothesis concerning which text(s) were copied from which other(s)) 
+is to use a special form of the 'dot' syntax of GraphViz.  
+
+Each stemma opens with the line
+
+ digraph Stemma {
+and continues with a list of all manuscript witnesses in the stemma, whether
+extant witnesses or missing archetypes or hyparchetypes.  Each of these is
+listed by its sigil on its own line, e.g.:
+
+  alpha [ class=hypothetical ]
+  1 [ class=hypothetical,label=* ]
+  Ms4 [ class=extant ]
+  
+Extant witnesses are listed with class=extant; missing or postulated witnesses
+are listed with class=hypothetical.  Anonymous hyparchetypes must be given a 
+unique name or number, but can be represented as anonymous with the addition 
+of 'label=*' to their lines.  Greek letters or other special characters may be
+used as names, but they must always be wrapped in double quotes.
+
+Links between manuscripts are then listed with arrow notation, as below. These 
+lines show the direction of copying, one step at a time, for the entire stemma.
+
+  alpha -> 1
+  1 -> Ms4
+  
+The final line in the definition should be the closing brace:
+
+ }
+  
+Thus for a set of extant manuscripts A, B, and C, where A and B were copied 
+from the archetype O and C was copied from B, the definition would be:
+
+ digraph Stemma {
+     O [ class=hypothetical]
+     A [ class=extant ]
+     B [ class=extant ]
+     C [ class=extant ]
+     O -> A
+     O -> B
+     B -> C
+ }
+
+=head1 CONSTRUCTOR
+
+=head2 new
+
+The constructor.  This should generally be called from Text::Tradition, but
+if called directly it takes the following options:
+
+=over
+
+=item * collation - The collation with which the stemma is associated.
+
+=item * dot - A filehandle open to a DOT representation of the stemma graph.
+
+=back
+
+=cut
+
 has collation => (
     is => 'ro',
     isa => 'Text::Tradition::Collation',
@@ -40,11 +125,11 @@ sub BUILD {
     my( $self, $args ) = @_;
     # If we have been handed a dotfile, initialize it into a graph.
     if( exists $args->{'dot'} ) {
-        $self->graph_from_dot( $args->{'dot'} );
+        $self->_graph_from_dot( $args->{'dot'} );
     }
 }
 
-sub graph_from_dot {
+sub _graph_from_dot {
        my( $self, $dotfh ) = @_;
        my $reader = Graph::Reader::Dot->new();
        my $graph = $reader->read_graph( $dotfh );
@@ -60,6 +145,27 @@ sub graph_from_dot {
        }
 }
 
+=head1 METHODS
+
+=head2 as_dot( \%options )
+
+Returns a normal dot representation of the stemma layout, suitable for rendering
+with GraphViz.  Options include:
+
+=over
+
+=item * graph - A hashref of global graph options.
+
+=item * node - A hashref of global node options.
+
+=item * edge - A hashref of global edge options.
+
+=back
+
+See the GraphViz documentation for the list of available options.
+
+=cut
+
 sub as_dot {
     my( $self, $opts ) = @_;
     
@@ -111,9 +217,12 @@ sub as_dot {
     return join( "\n", @dotlines );
 }
 
+=head2 editable
+
+Returns a version of the graph rendered in our definition format.
+
+=cut
 
-# Another version of dot output meant for graph editing, thus
-# much simpler.
 sub editable {
        my $self = shift;
        my @dotlines;
@@ -155,7 +264,12 @@ sub _by_vertex {
        return $a->[0].$a->[1] cmp $b->[0].$b->[1];
 }
 
-# Render the stemma as SVG.
+=head2 as_svg
+
+Returns an SVG representation of the graph, calling as_dot first.
+
+=cut
+
 sub as_svg {
     my( $self, $opts ) = @_;
     my $dot = $self->as_dot( $opts );
@@ -172,6 +286,12 @@ sub as_svg {
     return $svg;
 }
 
+=head2 witnesses
+
+Returns a list of the extant witnesses represented in the stemma.
+
+=cut
+
 sub witnesses {
     my $self = shift;
     my @wits = grep { $self->graph->get_vertex_attribute( $_, 'class' ) eq 'extant' }
@@ -179,6 +299,14 @@ sub witnesses {
     return @wits;
 }
 
+=head2 distance_trees( program => $program )
+
+Returns a set of undirected graphs, which are the result of running a distance
+tree calculation program on the collation.  Currently the only supported
+program is phylip_pars.
+
+=cut
+
 #### Methods for calculating phylogenetic trees ####
 
 before 'distance_trees' => sub {
@@ -199,6 +327,13 @@ before 'distance_trees' => sub {
     }
 };
 
+=head2 run_phylip_pars
+
+Runs Phylip Pars on the collation, returning the results in Newick format.
+Used for the distance_trees calculation.
+
+=cut
+
 sub run_phylip_pars {
        my $self = shift;
        my $cdata = character_input( $self->collation->make_alignment_table() );
@@ -217,3 +352,13 @@ no Moose;
 __PACKAGE__->meta->make_immutable;
     
 1;
+
+=head1 LICENSE
+
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
+
+=head1 AUTHOR
+
+Tara L Andrews E<lt>aurum@cpan.orgE<gt>
index 15e7f89..e48c0ff 100644 (file)
@@ -16,7 +16,20 @@ use Text::Tradition::Error;
 @EXPORT_OK = qw/ make_character_matrix character_input phylip_pars 
                                 parse_newick newick_to_svg /;
 
-sub make_character_matrix {
+=head1 NAME
+
+Text::Tradition::StemmaUtil - standalone utilities for distance tree calculations
+
+=head1 DESCRIPTION
+
+This package contains a set of utilities for running phylogenetic analysis on
+text collations.
+
+=head1 SUBROUTINES
+
+=cut
+
+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'} ) ] } 
@@ -27,7 +40,7 @@ sub make_character_matrix {
         my @pos_readings = map { $_->{'tokens'}->[$token_index] }
                                                        @{$table->{'alignment'}};
         my @pos_text = map { $_ ? $_->{'t'} : $_ } @pos_readings;
-        my @chars = convert_characters( \@pos_text );
+        my @chars = _convert_characters( \@pos_text );
         foreach my $idx ( 0 .. $#matrix ) {
             push( @{$matrix[$idx]}, $chars[$idx] );
         }
@@ -45,7 +58,7 @@ sub _normalize_witname {
     return sprintf( "%-10s", $witname );
 }
 
-sub convert_characters {
+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
@@ -78,9 +91,17 @@ sub convert_characters {
     return @chars;
 }
 
+=head2 character_input( $alignment_table )
+
+Returns a character matrix string suitable for Phylip programs, which 
+corresponds to the given alignment table.  See Text::Tradition::Collation 
+for a description of the alignment table format.
+
+=cut
+
 sub character_input {
     my $table = shift;
-    my $character_matrix = make_character_matrix( $table );
+    my $character_matrix = _make_character_matrix( $table );
     my $input = '';
     my $rows = scalar @{$character_matrix};
     my $columns = scalar @{$character_matrix->[0]} - 1;
@@ -91,6 +112,12 @@ sub character_input {
     return $input;
 }
 
+=head2 phylip_pars( $character_matrix )
+
+Runs Phylip Pars on the given character matrix.  Returns results in Newick format.
+
+=cut
+
 sub phylip_pars {
        my( $charmatrix ) = @_;
     # Set up a temporary directory for all the default Phylip files.
@@ -158,6 +185,12 @@ sub phylip_pars {
     throw( join( '', @error ) );
 }
 
+=head2 parse_newick( $newick_string )
+
+Parses the given Newick tree(s) into one or more undirected Graph objects.
+
+=cut
+
 sub parse_newick {
     my $newick = shift;
     my @trees;
@@ -173,6 +206,13 @@ sub parse_newick {
     return \@trees;
 }
 
+=head2 newick_to_svg( $newick_string )
+
+Uses the FigTree utility (if installed) to transform the given Newick tree(s)
+into a graph visualization.
+
+=cut
+
 sub newick_to_svg {
        my $newick = shift;
     my $program = File::Which::which( 'figtree' );
@@ -220,3 +260,14 @@ sub throw {
                );
 }
 
+1;
+
+=head1 LICENSE
+
+This package is free software and is provided "as is" without express
+or implied warranty.  You can redistribute it and/or modify it under
+the same terms as Perl itself.
+
+=head1 AUTHOR
+
+Tara L Andrews E<lt>aurum@cpan.orgE<gt>
index 212edfc..1b184a4 100755 (executable)
@@ -12,9 +12,10 @@ binmode STDERR, ":utf8";
 binmode STDOUT, ":utf8";
 eval { no warnings; binmode $DB::OUT, ":utf8"; };
 
-my( $informat, $inbase, $outformat, $help, $linear, $name, $HACK, $sep, $stemmafile, $dsn ) 
+my( $informat, $inbase, $outformat, $help, $linear, $name, $HACK, $sep, $stemmafile, 
+       $dsn, $dbuser, $dbpass ) 
     = ( '', '', '', '', 1, 'Tradition', 0, "\t", '',
-       "dbi:SQLite:dbname=stemmaweb/db/traditions.db" );
+       "dbi:SQLite:dbname=stemmaweb/db/traditions.db", undef, undef );
 
 GetOptions( 'i|in=s'    => \$informat,
             'b|base=s'  => \$inbase,
@@ -23,6 +24,8 @@ GetOptions( 'i|in=s'    => \$informat,
             'n|name=s'  => \$name,
             'h|help'    => \$help,
             's|stemma=s' => \$stemmafile,
+            'u|user=s'  => \$dbuser,
+            'p|pass=s'  => \$dbpass,
             'sep=s'            => \$sep,
             'hack'      => \$HACK,
             'dsn=s'            => \$dsn,
@@ -91,8 +94,11 @@ if( $outformat eq 'stemma' ) {
         print STDERR "Bad result: $tree";
     }
 } elsif( $outformat eq 'db' ) {
+       my $extra_args = { 'create' => 1 };
+       $extra_args->{'user'} = $dbuser if $dbuser;
+       $extra_args->{'password'} = $dbpass if $dbpass;
        my $dir = Text::Tradition::Directory->new( 'dsn' => $dsn, 
-               'extra_args' => { 'create' => 1 } );
+               'extra_args' => $extra_args );
        my $scope = $dir->new_scope;
        my $uuid = $dir->store( $tradition );
        print STDERR "Saved tradition to database with ID $uuid\n";
index f4828e1..25b91de 100644 (file)
@@ -1,6 +1,7 @@
 package stemmaweb::Controller::Relation;
 use Moose;
 use namespace::autoclean;
+use TryCatch;
 
 BEGIN { extends 'Catalyst::Controller' }
 
@@ -11,31 +12,115 @@ stemmaweb::Controller::Relation - Controller for the relationship mapper
 
 =head1 DESCRIPTION
 
-The stemma analysis tool with the pretty colored table.
+The reading relationship mapper with draggable nodes.
 
 =head1 METHODS
 
+=head2 index
+
  GET relation/$textid
  
 Renders the application for the text identified by $textid.
 
-=head2 index
-
-The relationship editor tool.
-
 =cut
 
 sub index :Path :Args(1) {
        my( $self, $c, $textid ) = @_;
        my $m = $c->model('Directory');
        my $tradition = $m->tradition( $textid );
-       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';    
+       my $collation = $tradition->collation;
+       my $svg_str = $collation->as_svg;
+       $svg_str =~ s/\n//gs;
+       $c->stash->{'svg_string'} = $svg_str;
+       $c->stash->{'template'} = 'relate.tt';
+}
+
+sub dispatcher :Path :Args(2) {
+       my( $self, $c, $textid, $forward ) = @_;
+       $c->stash->{'tradition'} = $c->model('Directory')->tradition( $textid );
+       $c->forward( $forward );        
+}
+
+=head2 relationship_definition
+
+ GET relation/definitions
+Returns a data structure giving the valid types and scopes for a relationship.
+
+=cut
+
+sub definitions :Local :Args(0) {
+       my( $self, $c ) = @_;
+       my $valid_relationships = [ qw/ spelling orthographic grammatical meaning / ];
+       my $valid_scopes = [ qw/ local global / ];
+       $c->stash->{'result'} = { 'types' => $valid_relationships, 'scopes' => $valid_scopes };
+       $c->forward('View::JSON');
 }
 
+=head2 relationship
+
+ POST relation/$textid/relationship
+   source_id: $source, target_id: $target, rel_type: $type, scope: $scope
+   
+Sets the specified relationship between the readings in $source and $target.
+Returns 200 and a list of node pairs where the relationship was added on success;
+returns 403 and an { error: message } struct on failure.
+
+=cut
+
+sub relationship :Private {
+       my( $self, $c ) = @_;
+       my $tradition = delete $c->stash->{'tradition'};
+       my $collation = $tradition->collation;
+       my $node = $c->request->param('source_id');
+       my $target = $c->request->param('target_id');
+       my $relation = $c->request->param('rel_type');
+       my $note = $c->request->param('note');
+       my $scope = $c->request->param('scope');
+
+       my $opts = { 'type' => $relation,
+                                'scope' => $scope };
+       
+       try {
+               my @vectors = $collation->add_relationship( $node, $target, $opts );
+               my $m = $c->model('Directory');
+               $m->save( $tradition );
+               $c->stash->{'result'} = \@vectors;
+       } catch( Text::Tradition::Error $e ) {
+               $c->response->status( '403' );
+               $c->stash->{'result'} = { 'error' => $e->message };
+       }
+       $c->forward('View::JSON');
+}
+
+=head2 relationships
+
+ GET relation/$textid/relationships
+
+Returns a list of relationships that exist in the specified text. Each
+relationship is returned in a struct that looks like:
+
+{ source: $sid, target: $tid, type: $rel_type, scope: $rel_scope }
+
+=cut
+
+sub relationships :Private {
+       my( $self, $c ) = @_;
+       my $collation = delete $c->stash->{'collation'};
+       # TODO make this API
+       my @pairs = $collation->relationships; # returns the edges
+       my @all_relations;
+       foreach my $p ( @pairs ) {
+               my $relobj = $collation->relations->get_relationship( @$p );
+               push( @all_relations, 
+                       { source => $p->[0], target => $p->[1], 
+                         type => $relobj->type, scope => $relobj->scope } );
+       }
+       $c->stash->{'result'} = \@all_relations;
+       $c->forward('View::JSON');
+}              
+
+
 =head2 end
 
 Attempt to render a view, if needed.
index 4b4e5e8..de51958 100644 (file)
@@ -128,8 +128,8 @@ sub stemma :Local :Args(1) {
                $m->store( $tradition );
        }
        
-       $c->stash->{'result'} = $tradition->stemma
-               ? $tradition->stemma->as_svg
+       $c->stash->{'result'} = $tradition->stemma_count
+               ? $tradition->stemma(0)->as_svg
                : '';
        $c->forward('View::SVG');
 }
index bdabcaa..46c0359 100644 (file)
@@ -30,17 +30,22 @@ sub index :Path :Args(1) {
     my( $self, $c, $textid ) = @_;
     my $m = $c->model('Directory');
        my $tradition = $m->tradition( $textid );
-       my $stemma = $tradition->stemma;
-       # TODO Think about caching the stemma in a session 
-       $c->stash->{svg} = $stemma->as_svg;
-       $c->stash->{text_title} = $tradition->name;
-       $c->stash->{template} = 'stexaminer.tt'; 
-       # TODO Run the analysis as AJAX from the loaded page.
-       my $t = run_analysis( $tradition );
-       $c->stash->{variants} = $t->{'variants'};
-       $c->stash->{total} = $t->{'variant_count'};
-       $c->stash->{genealogical} = $t->{'genealogical_count'};
-       $c->stash->{conflict} = $t->{'conflict_count'};
+       if( $tradition->stemma_count ) {
+               my $stemma = $tradition->stemma(0);
+               # TODO Think about caching the stemma in a session 
+               $c->stash->{svg} = $stemma->as_svg;
+               $c->stash->{text_title} = $tradition->name;
+               $c->stash->{template} = 'stexaminer.tt'; 
+               # TODO Run the analysis as AJAX from the loaded page.
+               my $t = run_analysis( $tradition );
+               $c->stash->{variants} = $t->{'variants'};
+               $c->stash->{total} = $t->{'variant_count'};
+               $c->stash->{genealogical} = $t->{'genealogical_count'};
+               $c->stash->{conflict} = $t->{'conflict_count'};
+       } else {
+               $c->stash->{error} = 'Tradition ' . $tradition->name 
+                       . 'has no stemma for analysis.';
+       }
 }
 
 =head2 end
diff --git a/stemmaweb/root/css/relationship.css b/stemmaweb/root/css/relationship.css
new file mode 100644 (file)
index 0000000..315cee0
--- /dev/null
@@ -0,0 +1,117 @@
+<style type="text/css" media="screen">
+body { 
+    margin: 0; 
+    padding: 0 20px; 
+    font: 1em "Trebuchet MS", verdana, arial, sans-serif; 
+    font-size: 85%; 
+    background: #EDF1FA;
+}
+.error {
+    color: red;
+}
+#graph_container {
+    height: 104px;
+    width: 90%;
+    margin-top: 5px;
+    border: 2px solid #B0C6F7;
+}
+#enlargement_container {
+    height: 504px;
+    width: 90%;
+    margin-top: 5px;
+    border: 2px solid #B0C6F7;
+    background: #fff;
+    padding-top: 5px;
+    padding-bottom: 5px;    
+}
+#dialog_overlay {
+    display: none;
+    position: absolute;
+    background-image: url("../css/cupertino/images/ui-bg_diagonals-thick_90_eeeeee_40x40.png");
+    background-repeat: repeat;
+    background-attachment: scroll;
+    background-position: 50% 50%;
+    background-color: #EEEEEE; 
+    opacity: 0.7;
+    padding-bottom: 5px;
+    padding-top: 5px;
+    border: 2px solid #B0C6F7;
+}
+#update_workspace_button {
+    position: relative;
+    top: -538px;
+    left: 44%;
+    height: 17px;
+    width: 17px;
+    margin-top: 8px;
+    border: 1px solid #B0C6F7;
+    background-image: url('../images/act_arrs.gif');
+    background-position: 0px 0px;
+    background-color: #fcfcfc;
+    z-index: 50;
+}
+#workspace_container {
+    height: 504px;
+    width: 43%;
+    position: relative;
+    top: -545px;
+    left: 47%;
+    margin-top: 10px;
+    border: 2px solid #B0C6F7;
+    background: #fff;
+}
+#graph_underlay {
+    height: 100%; 
+    width: 100%; 
+    background: #fff;
+    position: relative;
+    z-index: -2;
+}
+#visor {
+    height: 100px; 
+    width: 20%; 
+    background-color: #61EDAE; 
+    opacity: 0.15; 
+    position: relative;
+    left: 40%; 
+    top: -104px;
+    z-index: -1;
+    border-left: 2px solid #3C73FA;
+    border-right: 2px solid #3C73FA;
+    border-bottom: 2px solid #B0C6F7;
+    border-top: 2px solid #B0C6F7;
+}
+#graph {
+    position: relative; top: -208px;
+    padding: 0px;
+    overflow: auto;
+    /* cursor: -moz-grab !important; */ /* should this be applied with JS? */
+}
+#enlargement {
+    padding: 0px;
+    overflow: hidden;
+}
+#workspace {
+    padding: 0px;
+    overflow: hidden;
+}
+#keymap {
+    position: absolute;
+    left: 80%;
+    top: 100px;
+    border: 2px solid #B0C6F7;
+    background: #fff;
+}
+#keymap ul {
+    padding: 10px;
+    padding-left: 14px;
+    padding-right: 14px;
+    margin: 0px;
+}
+#keymap li {
+    border-left: 16px solid #fff;
+    padding-left: 8px;
+    list-style: none;
+    margin-bottom: 3px;
+}
+</style>
index 11fe773..c7d543a 100644 (file)
@@ -29,18 +29,32 @@ h2 {
        height: 450px;
        border: 1px #c6dcf1 solid;
 }
+.traditionname {
+       text-decoration: underline;
+}
+.selected {
+       font-style: italic;
+}
 #variant_graph {
        clear: both;
        width: 900px;
        height: 400px;
        border: 1px #c6dcf1 solid;
        overflow: auto; 
+       text-align: center;
+}
+#variant_graph img {
+       margin-top: expression(( 400 - this.height ) / 2);
 }
 #stemma_graph {
     float: left;
     width: 500px;
     height: 450px;
     border: 1px #c6dcf1 solid;
+    text-align: center;
+}
+#stemma_graph img {
+       margin-top: expression(( 450 - this.height ) / 2);
 }
 /* Additional components for the stexaminer */
 #variants_table {
diff --git a/stemmaweb/root/images/act_arrs.gif b/stemmaweb/root/images/act_arrs.gif
new file mode 100644 (file)
index 0000000..d2fe22b
Binary files /dev/null and b/stemmaweb/root/images/act_arrs.gif differ
diff --git a/stemmaweb/root/images/ajax-loader.gif b/stemmaweb/root/images/ajax-loader.gif
new file mode 100644 (file)
index 0000000..7f0c6ec
Binary files /dev/null and b/stemmaweb/root/images/ajax-loader.gif differ
index e05e5c5..8db28dd 100644 (file)
@@ -1,13 +1,23 @@
 function loadTradition( textid ) {
-    
+    // First insert the placeholder image
+    var basepath = window.location.pathname
+    if( basepath.lastIndexOf('/') == basepath.length - 1 ) { 
+       basepath = basepath.slice( 0, basepath.length - 1) 
+    };
+    var imghtml = '<img src="' + basepath + '/images/ajax-loader.gif" alt="Loading SVG..."/>'
+    $('#stemma_graph').empty();
+    $('#variant_graph').empty();
+    $('#stemma_graph').append( imghtml );
+    $('#variant_graph').append( imghtml );
+    // Then get and load the actual content.
     // TODO: scale #stemma_grpah both horizontally and vertically
     // TODO: load svgs from SVG.Jquery (to make scaling react in Safari)
-       $('#stemma_graph').load( "stemma/" + textid , function() {
+       $('#stemma_graph').load( basepath + "/stemma/" + textid , function() {
        var stemma_svg_element = $('#stemma_graph svg').svg().svg('get').root();
        console.log( stemma_svg_element );
        stemma_svg_element.height.baseVal.value = $('#stemma_graph').height();
        });
-    $('#variant_graph').load( "variantgraph/" + textid , function() {
+    $('#variant_graph').load( basepath + "/variantgraph/" + textid , function() {
        var variant_svg_element = $('#variant_graph svg').svg().svg('get').root();
        var svg_height = variant_svg_element.height.baseVal.value;
        var svg_width = variant_svg_element.width.baseVal.value;
diff --git a/stemmaweb/root/js/jquery.mousewheel.min.js b/stemmaweb/root/js/jquery.mousewheel.min.js
new file mode 100644 (file)
index 0000000..05ebb0a
--- /dev/null
@@ -0,0 +1,11 @@
+/* Copyright (c) 2009 Brandon Aaron (http://brandonaaron.net)
+ * Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
+ * and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
+ * Thanks to: http://adomas.org/javascript-mouse-wheel/ for some pointers.
+ * Thanks to: Mathias Bank(http://www.mathias-bank.de) for a scope bug fix.
+ *
+ * Version: 3.0.2
+ * 
+ * Requires: 1.2.2+
+ */
+(function(c){var a=["DOMMouseScroll","mousewheel"];c.event.special.mousewheel={setup:function(){if(this.addEventListener){for(var d=a.length;d;){this.addEventListener(a[--d],b,false)}}else{this.onmousewheel=b}},teardown:function(){if(this.removeEventListener){for(var d=a.length;d;){this.removeEventListener(a[--d],b,false)}}else{this.onmousewheel=null}}};c.fn.extend({mousewheel:function(d){return d?this.bind("mousewheel",d):this.trigger("mousewheel")},unmousewheel:function(d){return this.unbind("mousewheel",d)}});function b(f){var d=[].slice.call(arguments,1),g=0,e=true;f=c.event.fix(f||window.event);f.type="mousewheel";if(f.wheelDelta){g=f.wheelDelta/120}if(f.detail){g=-f.detail/3}d.unshift(f,g);return c.event.handle.apply(this,d)}})(jQuery);
\ No newline at end of file
index e69de29..45ca260 100644 (file)
@@ -0,0 +1,469 @@
+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() {
+  // some initial scaling
+  var svg_element = $('#svgbasics').children('svg');
+  var svg_graph = svg_element.svg().svg('get').root();
+  var svg_vbwidth = svg_graph.viewBox.baseVal.width;
+  var svg_vbheight = svg_graph.viewBox.baseVal.height;
+  var scroll_padding = $('#graph_container').width();
+  // (Use attr('width') to set width attr, otherwise style="width: npx;" is set.)
+  var svg_element_width = svg_vbwidth/svg_vbheight * parseInt(svg_element.attr('height'));
+  svg_element_width += scroll_padding;
+  svg_element.attr( 'width', svg_element_width );
+  $('ellipse').attr( {stroke:'black', fill:'#fff'} );
+}
+
+function svgEnlargementLoaded() {
+  // some initial scaling
+  var svg_element = $('#svgenlargement').children('svg');
+  var svg_graph = svg_element.svg().svg('get').root()
+  var svg_vbwidth = svg_graph.viewBox.baseVal.width;
+  var svg_vbheight = svg_graph.viewBox.baseVal.height;
+  var scroll_padding = $('#enlargement_container').width();
+  // (Use attr('width') to set width attr, otherwise style="width: npx;" is set.)
+  var svg_element_width = svg_vbwidth/svg_vbheight * parseInt(svg_element.attr('height'));
+  svg_element_width += scroll_padding;
+  svg_element.attr( 'width', svg_element_width );
+  $('ellipse').attr( {stroke:'black', fill:'#fff'} );
+  var svg_height = parseInt( $('#svgenlargement').height() );
+  scroll_enlargement_ratio = svg_height/svg_vbheight;
+}
+
+function get_ellipse( node_id ) {
+  return $('#svgenlargement .node').children('title').filter( function(index) {
+    return $(this).text() == node_id;
+  }).siblings('ellipse');
+}
+
+function get_node_obj( node_id ) {
+  return get_ellipse( node_id ).data( 'node_obj' );
+}
+
+function get_edge( edge_id ) {
+  return $('#svgenlargement .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.get_id = function() {
+    return self.ellipse.siblings('title').text()
+  }
+  
+  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' );
+    first_node_g_element = $("#svgenlargement g .node" ).filter( ":first" );
+    if( first_node_g_element.attr('id') !== self.get_g().attr('id') ) { self.get_g().insertBefore( first_node_g_element ) };
+  }
+
+  this.mousemove_listener = function(evt) {
+    self.dx = (evt.clientX - self.x) / mousemove_enlargement_ratio;
+    self.dy = (evt.clientY - self.y) / mousemove_enlargement_ratio;
+    self.move_elements();
+  }
+
+  this.mouseup_listener = function(evt) {    
+    if( $('ellipse[fill="#ffccff"]').size() > 0 ) {
+        var source_node_id = self.ellipse.siblings('title').text();
+        var target_node_id = $('ellipse[fill="#ffccff"]').siblings("title").text();
+        $('#source_node_id').val( source_node_id );
+        $('#target_node_id').val( target_node_id );
+        $('#dialog-form').dialog( 'open' );
+    };
+    $('body').unbind('mousemove');
+    $('body').unbind('mouseup');
+    self.ellipse.attr( 'fill', '#fff' );
+    self.ellipse.hover( self.enter_node, self.leave_node );
+        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.enter_node = function(evt) {
+    self.ellipse.attr( 'fill', '#ffccff' );
+  }
+
+  this.leave_node = function(evt) {
+    self.ellipse.attr( 'fill', '#fff' );
+  }
+
+  this.greyout_edges = function() {
+      $.each( self.node_elements, function(index, value) {
+        value.grey_out('.edge');
+      });
+  }
+
+  this.ungreyout_edges = function() {
+      $.each( self.node_elements, function(index, value) {
+        value.un_grey_out('.edge');
+      });
+  }
+
+  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.update_elements = function() {
+      self.node_elements = node_elements_for(self.ellipse);
+  }
+
+  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 )" );
+  }
+  this.grey_out = function(filter) {
+      if( this.shape.parent(filter).size() != 0 ) {
+          this.shape.attr({'stroke':'#e5e5e5', 'fill':'#e5e5e5'});
+      }
+  }
+  this.un_grey_out = function(filter) {
+      if( this.shape.parent(filter).size() != 0 ) {
+        this.shape.attr({'stroke':'#000000', 'fill':'#000000'});
+      }
+  }
+}
+
+function svgpath( path_element, svg_element ) {
+  this.svg_element = svg_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;
+  }
+  this.grey_out = function(filter) {
+      if( this.svg_element.parent(filter).size() != 0 ) {
+          this.svg_element.attr('stroke', '#e5e5e5');
+          this.svg_element.siblings('text').attr('fill', '#e5e5e5');
+      }
+  }
+  this.un_grey_out = function(filter) {
+      if( this.svg_element.parent(filter).size() != 0 ) {
+          this.svg_element.attr('stroke', '#000000');
+          this.svg_element.siblings('text').attr('fill', '#000000');
+      }
+  }
+}
+
+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( $('#svgenlargement .edge,#svgenlargement .relation').children('title'), function(index) {
+    title = $(this).text();
+    if( edge_in_pattern.test(title) ) {
+        polygon = $(this).siblings('polygon');
+        if( polygon.size() > 0 ) {
+            edge_elements.push( new svgshape( polygon ) );
+        }
+        path_segments = $(this).siblings('path')[0].pathSegList;
+        edge_elements.push( new svgpath( path_segments.getItem(path_segments.numberOfItems - 1), $(this).siblings('path') ) );
+    }
+    if( edge_out_pattern.test(title) ) {
+      path_segments = $(this).siblings('path')[0].pathSegList;
+      edge_elements.push( new svgpath( path_segments.getItem(0), $(this).siblings('path') ) );
+    }
+  });
+  return edge_elements;
+} 
+
+function relation_factory() {
+    var self = this;
+    this.color_memo = null;
+    //TODO: colors hard coded for now
+    this.temp_color = '#FFA14F';
+    this.relation_colors = [ "#5CCCCC", "#67E667", "#F9FE72", "#6B90D4", "#FF7673", "#E467B3", "#AA67D5", "#8370D8", "#FFC173" ];
+
+    this.create_temporary = function( source_node_id, target_node_id ) {
+    var relation = $('#svgenlargement .relation').filter( function(index) {
+        var relation_id = $(this).children('title').text();
+            if( ( relation_id == ( source_node_id + '->' + target_node_id ) ) || ( relation_id == ( target_node_id + '->' + source_node_id ) ) ) {
+            return true;
+        } 
+    } );
+    if( relation.size() == 0 ) {
+            draw_relation( source_node_id, target_node_id, self.temp_color );
+        } else {
+            self.color_memo = relation.children('path').attr( 'stroke' );
+            relation.children('path').attr( 'stroke', self.temp_color );
+        }
+    }
+    this.remove_temporary = function() {
+        var path_element = $('#svgenlargement .relation').children('path[stroke="' + self.temp_color + '"]');
+        if( self.color_memo != null ) {
+            path_element.attr( 'stroke', self.color_memo );
+            self.color_memo = null;
+        } else {
+            path_element.parent('g').remove();
+        }
+    }
+    this.create = function( source_node_id, target_node_id, color_index ) {
+        //TODO: Protect from (color_)index out of bound..
+        var relation_color = self.relation_colors[ color_index ];
+        draw_relation( source_node_id, target_node_id, relation_color );
+        get_node_obj( source_node_id ).update_elements();
+        get_node_obj( target_node_id ).update_elements();
+    }
+    this.remove = function( source_node_id, target_id ) {
+        //TODO (When needed)
+        console.log( "Unsupported function node_obj.remove()." );
+    }
+}
+
+function draw_relation( source_id, target_id, relation_color ) {
+        var source_ellipse = get_ellipse( source_id );
+        var target_ellipse = get_ellipse( target_id );
+        var svg = $('#svgenlargement').children('svg').svg().svg('get');
+        var path = svg.createPath(); 
+        var sx = parseInt( source_ellipse.attr('cx') );
+        var rx = parseInt( source_ellipse.attr('rx') );
+        var sy = parseInt( source_ellipse.attr('cy') );
+        var ex = parseInt( target_ellipse.attr('cx') );
+        var ey = parseInt( target_ellipse.attr('cy') );
+        var relation = svg.group( $("#svgenlargement svg g"), {'class':'relation'} );
+        svg.title( relation, source_id + '->' + target_id );
+        svg.path( relation, path.move( sx, sy ).curveC( sx + (2*rx), sy, ex + (2*rx), ey, ex, ey ), {fill: 'none', stroke: relation_color, strokeWidth: 4});
+    var relation_element = $('#svgenlargement .relation').filter( ':last' );
+    relation_element.insertBefore( $('#svgenlargement g g').filter(':first') );
+}
+
+$(document).ready(function () {
+  
+  relation_manager = new relation_factory();
+  
+  scroll_ratio =  $('#enlargement').height() / $('#graph').height();
+  
+  $('#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 ) {
+      if ( $('#update_workspace_button').data('locked') != true ) {
+          var scroll_left = $(this).data('scrollLeft') + $(this).data('x') - event.clientX;
+          this.scrollLeft = scroll_left;
+          var enlarged_scroll_left = scroll_left * scroll_ratio; 
+          $('#enlargement').scrollLeft( enlarged_scroll_left );
+          color_enlarged();
+      }
+    }
+  }).mousewheel(function (event, delta) {
+      if ( $('#update_workspace_button').data('locked') != true ) {
+          var scroll_left = delta * 30;
+          this.scrollLeft -= scroll_left;
+          var enlarged_scroll_left = $('#enlargement').scrollLeft();
+          enlarged_scroll_left -= (scroll_left * scroll_ratio);
+          $('#enlargement').scrollLeft( enlarged_scroll_left );
+          color_enlarged();
+      }
+  }).css({
+    'overflow' : 'hidden',
+    'cursor' : '-moz-grab'
+  });
+  
+
+  $( "#dialog-form" ).dialog({
+    autoOpen: false,
+    height: 270,
+    width: 290,
+    modal: true,
+    buttons: {
+      "Ok": function() {
+        $('#status').empty();
+        form_values = $('#collapse_node_form').serialize()
+        ncpath = window.location.pathname + '/relationship';
+        var jqjson = $.post( ncpath, form_values, function(data) {
+            $.each( data, function(item, source_target) { 
+                relation_manager.create( source_target[0], source_target[1], $('#rel_type').attr('selectedIndex') );
+            });
+            relation_manager.remove_temporary();
+            $( "#dialog-form" ).dialog( "close" );
+        }, 'json');
+      },
+      Cancel: function() {
+          relation_manager.remove_temporary();
+          $( this ).dialog( "close" );
+      }
+    },
+    create: function(event, ui) { 
+        $(this).data( 'relation_drawn', false );
+        //TODO? Err handling?
+        var jqjson = $.getJSON( 'relationship_definition', function(data) {
+            var types = data.types.sort();
+            $.each( types, function(index, value) {   
+                 $('#rel_type').append( $('<option>').attr( "value", value ).text(value) ); 
+                 $('#keymaplist').append( $('<li>').css( "border-color", relation_manager.relation_colors[index] ).text(value) ); 
+            });
+            var scopes = data.scopes;
+            $.each( scopes, function(index, value) {   
+                 $('#scope').append( $('<option>').attr( "value", value ).text(value) ); 
+            });
+        });        
+    },
+    open: function() {
+        relation_manager.create_temporary( $('#source_node_id').val(), $('#target_node_id').val() );
+      $(".ui-widget-overlay").css("background", "none");
+      $("#dialog_overlay").show();
+      $("#dialog_overlay").height( $("#enlargement_container").height() );
+      $("#dialog_overlay").width( $("#enlargement_container").width() );
+      $("#dialog_overlay").offset( $("#enlargement_container").offset() );
+    },
+    close: function() {
+        $( '#status' ).empty();
+        $("#dialog_overlay").hide();
+    }
+  }).ajaxError( function(event, jqXHR, ajaxSettings, thrownError) {
+      if( ( ajaxSettings.url.split("?")[0] == 'set_relationship' ) && jqXHR.status == 403 ) {
+          $('#status').append( '<p class="error">The relationship can not be made in this way between these nodes.</p>' );
+      }
+  } );
+
+  $('#update_workspace_button').click( function() {
+     var svg_enlargement = $('#svgenlargement').svg().svg('get').root();
+     if( $(this).data('locked')==true) {
+         $.each( ellipses_in_magnifier, function( index, ellipse ) {
+             ellipse.data( 'node_obj' ).ungreyout_edges();
+             ellipse.data( 'node_obj' ).set_draggable( false );
+             ellipse.data( 'node_obj', null );
+         })
+         svg_enlargement.children[0].setAttribute( 'transform', $(this).data('transform_memo') );
+         $('#enlargement').scrollLeft( $(this).data('scrollleft_memo') );
+         $(this).data('locked', false);
+         $(this).css('background-position', '0px 0px');
+     } else {
+         $(this).css('background-position', '0px 17px');
+         var y_min = parseInt( ellipses_in_magnifier[0].attr('cy') ) - parseInt( ellipses_in_magnifier[0].attr('ry') ); 
+         var y_max = parseInt( ellipses_in_magnifier[0].attr('cy') ) + parseInt( ellipses_in_magnifier[0].attr('ry') ); 
+         $.each( ellipses_in_magnifier, function( index, ellipse ) {
+             var ny_min = parseInt( ellipse.attr('cy') ) - parseInt( ellipse.attr('ry') ); 
+             var ny_max = parseInt( ellipse.attr('cy') ) + parseInt( ellipse.attr('ry') ); 
+             if( ny_min < y_min ) { y_min = ny_min }; 
+             if( ny_max > y_max ) { y_max = ny_max };
+             if( ellipse.data( 'node_obj' ) == null ) {
+                 ellipse.data( 'node_obj', new node_obj( ellipse ) );
+             } else {
+                 ellipse.data( 'node_obj' ).set_draggable( true );
+             }
+             ellipse.data( 'node_obj' ).greyout_edges();
+         })
+         var graph_frag_height = y_max - y_min ;
+         var svg_enlargement_vbheight = svg_enlargement.viewBox.baseVal.height;
+         var svg_enlargement_vbwidth = svg_enlargement.viewBox.baseVal.width;
+         var scale = svg_enlargement_vbheight / graph_frag_height;
+         mousemove_enlargement_ratio = scroll_enlargement_ratio * scale;
+         var scroll_padding = $('#enlargement_container').width();
+         var scroll_scale =  svg_enlargement_vbwidth / ( parseFloat( $('#svgenlargement svg').attr('width') ) - scroll_padding );
+         var vbx_of_scroll = ( $('#enlargement').scrollLeft() ) * scroll_scale;
+         var translate_x = vbx_of_scroll;
+         var transform = svg_enlargement.children[0].getAttribute('transform');
+         $(this).data('transform_memo', transform );
+         $(this).data('scrollleft_memo', $('#enlargement').scrollLeft() ); 
+         $(this).data('locked', true );
+         $('#enlargement').scrollLeft(0);
+         transform = 'scale(' + scale + ') translate(' + (-1 * translate_x) + ',' + (-1 * y_min) + ')';
+         svg_enlargement.children[0].setAttribute( 'transform', transform );
+     }
+  });
+  
+});
+
+$(window).mouseout(function (event) {
+  if ($('#graph').data('down')) {
+    try {
+      if (event.originalTarget.nodeName == 'BODY' || event.originalTarget.nodeName == 'HTML') {
+        $('#graph').data('down', false);
+      }                
+    } catch (e) {}
+  }
+});
+
+function color_enlarged() {
+    ellipses_in_magnifier = [];
+    var scroll_offset = parseInt( $('#enlargement').scrollLeft() );
+    var scroll_padding = $('#enlargement_container').width()/2;
+    $('#svgenlargement ellipse,#svgbasics ellipse' ).each( function( index ) {
+        var cpos_inscrollcoor = parseInt( $(this).attr('cx') ) * scroll_enlargement_ratio;
+        if ( ( cpos_inscrollcoor > (scroll_offset - scroll_padding) ) && ( cpos_inscrollcoor < ( scroll_offset + scroll_padding ) ) ) {
+           $(this).attr( {stroke:'green', fill:'#b3f36d'} );
+           if( $(this).parents('#svgenlargement').size() == 1 ) { ellipses_in_magnifier.push( $(this) ) };
+        } else {
+           $(this).attr( {stroke:'black', fill:'#fff'} );
+        }
+    });   
+}
+
+
+
+
index bf49c2c..1313c4b 100644 (file)
@@ -3,7 +3,7 @@
       <ul>
 [% SET i = 0 -%]
 [% FOREACH t IN texts -%]
-        <li><span class="traditionname" onClick="loadTradition('[% t.id %]')">[% t.name %]</span></li>
+        <li><span class="traditionname" onClick="$('.traditionname').removeClass('selected');$(this).addClass('selected');loadTradition('[% t.id %]')">[% t.name %]</span></li>
 [% i = i + 1 -%]
 [% END -%]
      </table>
index 02542dd..53fad4f 100644 (file)
@@ -4,10 +4,11 @@
     <META http-equiv="Content-Type" content="text/html; charset=utf-8">
     <link type="text/css" href="[% c.uri_for('/css/cupertino/jquery-ui-1.8.13.custom.css') %]" rel="stylesheet" />
     <link type="text/css" href="[% c.uri_for('/css/style.css') %]" rel="stylesheet" />
-    <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/jquery.svg.js"></script>
-    <script type="text/javascript" src="/js/jquery.svgdom.js"></script>
+    <script type="text/javascript" src="[% c.uri_for('/js/jquery-1.4.4.min.js') %]"></script>
+    <script type="text/javascript" src="[% c.uri_for('/js/jquery-ui-1.8.10.custom.min.js') %]"></script>
+    <script type="text/javascript" src="[% c.uri_for('/js/jquery.mousewheel.min.js') %]"></script>
+    <script type="text/javascript" src="[% c.uri_for('/js/jquery.svg.js') %]"></script>
+    <script type="text/javascript" src="[% c.uri_for('/js/jquery.svgdom.js') %]"></script>
     <script type="text/javascript" src="[% applicationjs %]"></script>
     [% content %]
     <title>[% pagetitle %]</title>
index 910b1d1..90d0437 100644 (file)
@@ -1,6 +1,6 @@
 [% WRAPPER header.tt
        pagetitle = "Stemmaweb - Text tradition tools"
-       applicationjs = "js/componentload.js"
+       applicationjs = c.uri_for( 'js/componentload.js' )
 %]
     <script type="text/javascript">
 $(document).ready(function() {
index e69de29..408df7c 100644 (file)
@@ -0,0 +1,59 @@
+[% WRAPPER header.tt
+       pagetitle = "Stemmaweb - Relationship mapper"
+       applicationjs = "../js/relationship.js"
+%]
+<script type="text/javascript">
+$(function() {
+  $('#svgbasics').svg({loadURL: '[% svg_string %]', onLoad: svgLoaded});
+  $('#svgenlargement').svg({loadURL: '[% svg_string %]', onLoad: svgEnlargementLoaded});
+});
+</script>
+<link type="text/css" href="[% c.uri_for('/css/relationship.css') %]" rel="stylesheet" />
+[% END %]
+
+       <div id="graph_container">      
+        <div id="graph_underlay"></div>
+           <div id="visor"></div>
+           <div id="graph">
+                   <!-- width seems to need to be as wide as the graph -->
+                   <!-- a translation of -487 in the svg itself was needed to adjust the graph to the left of the div -->
+                   <!-- unclear how to cope with height that doesn't fit box -->
+                   <div id="svgbasics" style="height: 100px;"></div>
+               </div>
+       </div>
+
+       <div id="enlargement_container">        
+               <div id="enlargement">
+               <div id="svgenlargement" style="height: 500px;"></div>
+               </div>
+       </div>
+       
+       <div id="update_workspace_button"></div>
+       
+       <div id="dialog-form" title="Create relation between two nodes..">
+               <form id="collapse_node_form">
+               <fieldset>
+                       <input type="hidden" name="source_id" id="source_node_id"/>
+                       <input type="hidden" name="target_id" id="target_node_id"/>
+                       <label for="rel_type">Relation type..&nbsp;</label>
+                       <select name="rel_type" id="rel_type" class=".ui-widget select">
+                       </select>
+                       <br/><br/>
+                       <label for="scope">Scope of relation..&nbsp;</label>
+                       <select name="scope" id="scope" class=".ui-widget select">
+                       </select>
+                       <br/><br/>
+                       <label for="note">Annotation on note..&nbsp;</label>
+                       <input type="text" width="60" name="note" id="note" class=".ui-widget input" />
+               </fieldset>
+       <div id="status"></div>         
+               </form>
+       </div>
+       <div id="dialog_overlay"></div>
+
+    <div id="keymap">
+        <ul id="keymaplist">
+        </ul>
+    </div>
+    
+[% PROCESS footer.tt %]
\ No newline at end of file
index 4e1c6e7..4728bbf 100644 (file)
@@ -7,4 +7,23 @@ eval "use Test::Pod::Coverage 1.04";
 plan skip_all => 'Test::Pod::Coverage 1.04 required' if $@;
 plan skip_all => 'set TEST_POD to enable this test' unless $ENV{TEST_POD};
 
-all_pod_coverage_ok();
+my %mods;
+map { $mods{$_} = 1 } all_modules();
+if( -e 'MANIFEST.SKIP' ) {
+       open( SKIP, 'MANIFEST.SKIP' ) or die "Could not open skip file";
+       while(<SKIP>) {
+               chomp;
+               next unless /^lib/;
+               s/^lib\///;
+               s/\.pm//;
+               s/\//::/g;
+               delete $mods{$_};
+       }
+       close SKIP;
+}
+               
+foreach my $mod ( keys %mods ) {
+       pod_coverage_ok( $mod, { also_private => [ qw/ BUILD throw / ] } );
+}
+
+done_testing();
\ No newline at end of file
index d379d5d..426d8cc 100644 (file)
--- a/t/graph.t
+++ b/t/graph.t
@@ -33,7 +33,7 @@ my @svg_edges = $svg_xpc->findnodes( '//svg:g[@class="edge"]' );
 is( scalar @svg_edges, 32, "Correct number of edges in the graph" );
 
 # Test svg creation for a subgraph
-my $part_svg = $parser->parse_string( $collation->svg_subgraph( 15 ) ); # start, no end
+my $part_svg = $parser->parse_string( $collation->as_svg( { from => 15 } ) ); # start, no end
 is( $part_svg->documentElement->nodeName(), 'svg', "Got an svg subgraph to end" );
 my $part_xpc = XML::LibXML::XPathContext->new( $part_svg->documentElement() );
 $part_xpc->registerNs( 'svg', 'http://www.w3.org/2000/svg' );
@@ -47,7 +47,7 @@ open( OUT, ">test.svg" );
 print OUT $part_svg->toString();
 close OUT;
 
-$part_svg = $parser->parse_string( $collation->svg_subgraph( 10, 13 ) ); # start, no end
+$part_svg = $parser->parse_string( $collation->as_svg( { from => 10, to => 13 } ) ); # start, no end
 is( $part_svg->documentElement->nodeName(), 'svg', "Got an svg subgraph in the middle" );
 $part_xpc = XML::LibXML::XPathContext->new( $part_svg->documentElement() );
 $part_xpc->registerNs( 'svg', 'http://www.w3.org/2000/svg' );
@@ -59,7 +59,7 @@ is( scalar( @svg_edges ), 11,
        "Correct number of edges in the subgraph" );
 
 
-$part_svg = $parser->parse_string( $collation->svg_subgraph( 0, 5 ) ); # start, no end
+$part_svg = $parser->parse_string( $collation->as_svg( { to => 5 } ) ); # start, no end
 is( $part_svg->documentElement->nodeName(), 'svg', "Got an svg subgraph from start" );
 $part_xpc = XML::LibXML::XPathContext->new( $part_svg->documentElement() );
 $part_xpc->registerNs( 'svg', 'http://www.w3.org/2000/svg' );
index 741fbad..23fd8d9 100644 (file)
@@ -5,7 +5,7 @@ use File::Which;
 use Test::More;
 use lib 'lib';
 use Text::Tradition;
-use Text::Tradition::StemmaUtil qw/ make_character_matrix /;
+use Text::Tradition::StemmaUtil;
 use XML::LibXML;
 use XML::LibXML::XPathContext;
 
@@ -30,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 = make_character_matrix( $c->make_alignment_table() );
+my $m = Text::Tradition::StemmaUtil::_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
index dd13636..6e47f40 100644 (file)
@@ -57,10 +57,12 @@ my $t = Text::Tradition->new(
     'file'  => 't/data/simple.txt',
     );
 
+is( $t->stemma_count, 0, "No stemmas added yet" );
 my $s;
 ok( $s = $t->add_stemma( dotfile => 't/data/simple.dot' ), "Added a simple stemma" );
 is( ref( $s ), 'Text::Tradition::Stemma', "Got a stemma object returned" );
-is( $t->stemma, $s, "Stemma is the right one" );
+is( $t->stemma_count, 1, "Tradition claims to have a stemma" );
+is( $t->stemma(0), $s, "Tradition hands back the right stemma" );
 }
 
 
index e4de126..2e18c20 100644 (file)
@@ -36,7 +36,7 @@ my $t = Text::Tradition->new(
        my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
        ok( $d->save( $t ), "Updated tradition with stemma" );
        is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
-       is( $d->tradition( $uuid )->stemma, $s, "...and it has the correct stemma" );
+       is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
        try {
                $d->save( $s );
        } catch( Text::Tradition::Error $e ) {
@@ -61,7 +61,7 @@ is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
        is( scalar $f->tradition_ids, 2, "Directory index has both traditions" );
        my $tf = $f->tradition( $uuid );
        is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
-       my $sid = $f->object_to_id( $tf->stemma );
+       my $sid = $f->object_to_id( $tf->stemma(0) );
        try {
                $f->tradition( $sid );
        } catch( Text::Tradition::Error $e ) {