clean up svg output; allow non-linear GraphML graphs
[scpubgit/stemmatology.git] / lib / Text / Tradition / Collation.pm
1 package Text::Tradition::Collation;
2
3 use Graph::Easy;
4 use IPC::Run qw( run binary );
5 use Text::Tradition::Collation::Reading;
6 use Text::Tradition::Collation::Path;
7 use XML::LibXML;
8 use Moose;
9
10 has 'graph' => (
11     is => 'ro',
12     isa => 'Graph::Easy',
13     handles => {
14         add_reading => 'add_node',
15         del_reading => 'del_node',
16         add_path => 'add_edge',
17         del_path => 'del_edge',
18         reading => 'node',
19         path => 'edge',
20         readings => 'nodes',
21         paths => 'edges',
22         relationships => 'edges',
23     },
24     default => sub { Graph::Easy->new( undirected => 0 ) },
25     );
26                 
27
28 has 'tradition' => (
29     is => 'rw',
30     isa => 'Text::Tradition',
31     );
32
33 has 'svg' => (
34     is => 'ro',
35     isa => 'Str',
36     writer => '_save_svg',
37     predicate => 'has_svg',
38     );
39
40 has 'graphml' => (
41     is => 'ro',
42     isa => 'Str',
43     writer => '_save_graphml',
44     predicate => 'has_graphml',
45     );
46
47 # Keeps track of the lemmas within the collation.  At most one lemma
48 # per position in the graph.
49 has 'lemmata' => (
50     is => 'ro',
51     isa => 'HashRef[Maybe[Str]]',
52     default => sub { {} },
53     );
54
55 has 'wit_list_separator' => (
56     is => 'rw',
57     isa => 'Str',
58     default => ', ',
59     );
60
61 has 'baselabel' => (
62     is => 'rw',
63     isa => 'Str',
64     default => 'base text',
65     );
66
67 has 'collapsed' => (
68     is => 'rw',
69     isa => 'Bool',
70     );
71
72 has 'linear' => (
73     is => 'rw',
74     isa => 'Bool',
75     default => 1,
76     );
77
78
79 # The collation can be created two ways:
80 # 1. Collate a set of witnesses (with CollateX I guess) and process
81 #    the results as in 2.
82 # 2. Read a pre-prepared collation in one of a variety of formats,
83 #    and make the graph from that.
84
85 # The graph itself will (for now) be immutable, and the positions
86 # within the graph will also be immutable.  We need to calculate those
87 # positions upon graph construction.  The equivalences between graph
88 # nodes will be mutable, entirely determined by the user (or possibly
89 # by some semantic pre-processing provided by the user.)  So the
90 # constructor should just make an empty equivalences object.  The
91 # constructor will also need to make the witness objects, if we didn't
92 # come through option 1.
93
94 sub BUILD {
95     my( $self, $args ) = @_;
96     $self->graph->use_class('node', 'Text::Tradition::Collation::Reading');
97     $self->graph->use_class('edge', 'Text::Tradition::Collation::Path');
98
99     # Pass through any graph-specific options.
100     my $shape = exists( $args->{'shape'} ) ? $args->{'shape'} : 'ellipse';
101     $self->graph->set_attribute( 'node', 'shape', $shape );
102 }
103
104 # Wrapper around add_path 
105
106 around add_path => sub {
107     my $orig = shift;
108     my $self = shift;
109
110     # Make sure there are three arguments
111     unless( @_ == 3 ) {
112         warn "Call add_path with args source, target, witness";
113         return;
114     }
115     # Make sure the proposed path does not yet exist
116     my( $source, $target, $wit ) = @_;
117     $source = $self->reading( $source )
118         unless ref( $source ) eq 'Text::Tradition::Collation::Reading';
119     $target = $self->reading( $target )
120         unless ref( $target ) eq 'Text::Tradition::Collation::Reading';
121     foreach my $path ( $source->edges_to( $target ) ) {
122         if( $path->label eq $wit ) {
123             return;
124         }
125     }
126     # Do the deed
127     $self->$orig( @_ );
128 };
129
130 # Wrapper around paths
131 around paths => sub {
132     my $orig = shift;
133     my $self = shift;
134
135     my @result = grep { $_->class eq 'edge.path' } $self->$orig( @_ );
136     return @result;
137 };
138
139 around relationships => sub {
140     my $orig = shift;
141     my $self = shift;
142     my @result = grep { $_->class eq 'edge.relationship' } $self->$orig( @_ );
143     return @result;
144 };
145
146 # Wrapper around merge_nodes
147
148 sub merge_readings {
149     my $self = shift;
150     my $first_node = shift;
151     my $second_node = shift;
152     $first_node->merge_from( $second_node );
153     unshift( @_, $first_node, $second_node );
154     return $self->graph->merge_nodes( @_ );
155 }
156
157 # Extra graph-alike utility
158 sub has_path {
159     my( $self, $source, $target, $label ) = @_;
160     my @paths = $source->edges_to( $target );
161     my @relevant = grep { $_->label eq $label } @paths;
162     return scalar @paths;
163 }
164
165 ## Dealing with relationships between readings.  This is a different
166 ## sort of graph edge.
167
168 sub add_relationship {
169     my( $self, $type, $source, $target, $global ) = @_;
170     my $rel = Text::Tradition::Collation::Relationship->new(
171             'sort' => $type,
172             'global' => $global,
173             'orig_relation' => [ $source, $target ],
174     );
175     print STDERR sprintf( "Setting relationship %s between readings %s (%s)"
176                           . " and %s (%s)\n", $type, 
177                           $source->label, $source->name,
178                           $target->label, $target->name );
179     $self->graph->add_edge( $source, $target, $rel );
180     if( $global ) {
181         # Look for all readings with the source label, and if there are
182         # colocated readings with the target label, join them too.
183         foreach my $r ( $self->readings() ) {
184             next unless $r->label eq $source->label;
185             my @colocated = grep { $_->label eq $target->label }
186                 $self->same_position_as( $r );
187             if( @colocated ) {
188                 warn "Multiple readings with same label at same position!"
189                     if @colocated > 1;
190                 my $dup_rel = Text::Tradition::Collation::Relationship->new(
191                     'sort' => $type,
192                     'global' => $global,
193                     'orig_relation' => [ $source, $target ],
194                     );
195                 $self->graph->add_edge( $r, $colocated[0], $dup_rel );
196             }
197         }
198     }
199 }
200
201 =head2 Output method(s)
202
203 =over
204
205 =item B<as_svg>
206
207 print $graph->as_svg( $recalculate );
208
209 Returns an SVG string that represents the graph.  Uses GraphViz to do
210 this, because Graph::Easy doesn\'t cope well with long graphs. Unless
211 $recalculate is passed (and is a true value), the method will return a
212 cached copy of the SVG after the first call to the method.
213
214 =cut
215
216 sub as_svg {
217     my( $self, $recalc ) = @_;
218     return $self->svg if $self->has_svg;
219     
220     $self->collapse_graph_paths();
221     
222     my @cmd = qw/dot -Tsvg/;
223     my( $svg, $err );
224     my $in = $self->as_dot();
225     run( \@cmd, \$in, ">", binary(), \$svg );
226     $self->_save_svg( $svg );
227     $self->expand_graph_paths();
228     return $svg;
229 }
230
231 =item B<as_dot>
232
233 print $graph->as_dot( $view, $recalculate );
234
235 Returns a string that is the collation graph expressed in dot
236 (i.e. GraphViz) format.  The 'view' argument determines what kind of
237 graph is produced.
238     * 'path': a graph of witness paths through the collation (DEFAULT)
239     * 'relationship': a graph of how collation readings relate to 
240       each other
241
242 =cut
243
244 sub as_dot {
245     my( $self, $view ) = @_;
246     $view = 'path' unless $view;
247     # TODO consider making some of these things configurable
248     my $dot = sprintf( "digraph %s {\n", $self->tradition->name );
249     $dot .= "\tedge [ arrowhead=open ];\n";
250     $dot .= "\tgraph [ rankdir=LR ];\n";
251     $dot .= sprintf( "\tnode [ fontsize=%d, fillcolor=%s, style=%s, shape=%s ];\n",
252                      11, "white", "filled", $self->graph->get_attribute( 'node', 'shape' ) );
253
254     foreach my $reading ( $self->readings ) {
255         next if $reading->name eq $reading->label;
256         $dot .= sprintf( "\t\"%s\" [ label=\"%s\" ]\n", $reading->name, $reading->label );
257     }
258
259     my @edges = $view eq 'relationship' ? $self->relationships : $self->paths;
260     foreach my $edge ( @edges ) {
261         $dot .= sprintf( "\t\"%s\" -> \"%s\" [ color=\"%s\", fontcolor=\"%s\", label=\"%s\" ]\n",
262                          $edge->from->name, $edge->to->name, '#000000', '#000000', $edge->label );
263     }
264
265     $dot .= "}\n";
266     return $dot;
267 }
268
269 =item B<as_graphml>
270
271 print $graph->as_graphml( $recalculate )
272
273 Returns a GraphML representation of the collation graph, with
274 transposition information and position information. Unless
275 $recalculate is passed (and is a true value), the method will return a
276 cached copy of the SVG after the first call to the method.
277
278 =cut
279
280 sub as_graphml {
281     my( $self, $recalc ) = @_;
282     return $self->graphml if $self->has_graphml;
283
284     # Some namespaces
285     my $graphml_ns = 'http://graphml.graphdrawing.org/xmlns';
286     my $xsi_ns = 'http://www.w3.org/2001/XMLSchema-instance';
287     my $graphml_schema = 'http://graphml.graphdrawing.org/xmlns ' .
288         'http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd';
289
290     # Create the document and root node
291     my $graphml = XML::LibXML->createDocument( "1.0", "UTF-8" );
292     my $root = $graphml->createElementNS( $graphml_ns, 'graphml' );
293     $graphml->setDocumentElement( $root );
294     $root->setNamespace( $xsi_ns, 'xsi', 0 );
295     $root->setAttributeNS( $xsi_ns, 'schemaLocation', $graphml_schema );
296
297     # Add the data keys for nodes
298     my @node_data = ( 'name', 'reading', 'identical', 'position' );
299     foreach my $ndi ( 0 .. $#node_data ) {
300         my $key = $root->addNewChild( $graphml_ns, 'key' );
301         $key->setAttribute( 'attr.name', $node_data[$ndi] );
302         $key->setAttribute( 'attr.type', 'string' );
303         $key->setAttribute( 'for', 'node' );
304         $key->setAttribute( 'id', 'd'.$ndi );
305     }
306
307     # Add the data keys for edges, i.e. witnesses
308     my %wit_hash;
309     my $wit_ctr = 0;
310     foreach my $wit ( @{$self->tradition->witnesses} ) {
311         my $wit_key = 'w' . $wit_ctr++;
312         $wit_hash{$wit->sigil} = $wit_key;
313         my $key = $root->addNewChild( $graphml_ns, 'key' );
314         $key->setAttribute( 'attr.name', _make_xml_attr( $wit->sigil ) );
315         $key->setAttribute( 'attr.type', 'string' );
316         $key->setAttribute( 'for', 'edge' );
317         $key->setAttribute( 'id', $wit_key );
318         my $ackey = $root->addNewChild( $graphml_ns, 'key' );
319         $ackey->setAttribute( 'attr.name', _make_xml_attr( $wit->sigil ) . "_ante_corr" );
320         $ackey->setAttribute( 'attr.type', 'string' );
321         $ackey->setAttribute( 'for', 'edge' );
322         $ackey->setAttribute( 'id', $wit_key . "a" );
323     }
324
325     # Add the graph, its nodes, and its edges
326     my $graph = $root->addNewChild( $graphml_ns, 'graph' );
327     $graph->setAttribute( 'edgedefault', 'directed' );
328     $graph->setAttribute( 'id', 'g0' ); # TODO make this meaningful
329     $graph->setAttribute( 'parse.edgeids', 'canonical' );
330     $graph->setAttribute( 'parse.edges', scalar($self->paths) );
331     $graph->setAttribute( 'parse.nodeids', 'canonical' );
332     $graph->setAttribute( 'parse.nodes', scalar($self->readings) );
333     $graph->setAttribute( 'parse.order', 'nodesfirst' );
334
335     my $node_ctr = 0;
336     my %node_hash;
337     foreach my $n ( $self->readings ) {
338         my %this_node_data = ();
339         foreach my $ndi ( 0 .. $#node_data ) {
340             my $key = $node_data[$ndi];
341             if( $key eq 'name' ) {
342                 $this_node_data{'d'.$ndi} = $n->name;
343             } elsif( $key eq 'token' ) {
344                 $this_node_data{'d'.$ndi} = $n->label;
345             } elsif( $key eq 'identical' && $n->has_primary ) {
346                 $this_node_data{'d'.$ndi} = $n->primary->name;
347             } elsif( $key eq 'position' ) {
348                 $this_node_data{'d'.$ndi} = $n->position;
349             }
350         }
351         my $node_el = $graph->addNewChild( $graphml_ns, 'node' );
352         my $node_xmlid = 'n' . $node_ctr++;
353         $node_hash{ $n->name } = $node_xmlid;
354         $node_el->setAttribute( 'id', $node_xmlid );
355             
356         foreach my $dk ( keys %this_node_data ) {
357             my $d_el = $node_el->addNewChild( $graphml_ns, 'data' );
358             $d_el->setAttribute( 'key', $dk );
359             $d_el->appendText( $this_node_data{$dk} );
360         }
361     }
362
363     my $edge_ctr = 0;
364     foreach my $e ( $self->paths() ) {
365         my( $name, $from, $to ) = ( 'e'.$edge_ctr++,
366                                     $node_hash{ $e->from()->name() },
367                                     $node_hash{ $e->to()->name() } );
368         my $edge_el = $graph->addNewChild( $graphml_ns, 'edge' );
369         $edge_el->setAttribute( 'source', $from );
370         $edge_el->setAttribute( 'target', $to );
371         $edge_el->setAttribute( 'id', $name );
372         # Add the witness
373         my $base = $e->label;
374         my $ante_corr;
375         if( $e->label =~ /^(.*?)\s+(\(a\.c\.\))$/ ) {
376             ( $base, $ante_corr ) = ( $1, $2 );
377         }
378         my $key = $wit_hash{$base};
379         $key .= "a" if $ante_corr;
380         my $wit_el = $edge_el->addNewChild( $graphml_ns, 'data' );
381         $wit_el->setAttribute( 'key', $key );
382         $wit_el->appendText( $e->label );
383     }
384
385     # Return the thing
386     $self->_save_graphml( $graphml->toString(1) );
387     return $graphml->toString(1);
388 }
389
390 sub _make_xml_attr {
391     my $str = shift;
392     $str =~ s/\s/_/g;
393     $str =~ s/\W//g;
394     $str =~ "a$str" if $str =~ /^\d/;
395     return $str;
396 }
397
398 sub collapse_graph_paths {
399     my $self = shift;
400     # Our collation graph has an path per witness.  This is great for
401     # calculation purposes, but terrible for display.  Thus we want to
402     # display only one path between any two nodes.
403
404     return if $self->collapsed;
405
406     print STDERR "Collapsing witness paths in graph...\n";
407
408     # Don't list out every witness if we have more than half to list.
409     my $majority = int( scalar( @{$self->tradition->witnesses} ) / 2 ) + 1;
410     # But don't compress if there are only a few witnesses.
411     $majority = 4 if $majority < 4;
412     foreach my $node( $self->readings ) {
413         my $newlabels = {};
414         # We will visit each node, so we only look ahead.
415         foreach my $edge ( $node->outgoing() ) {
416             next unless $edge->class eq 'edge.path';
417             add_hash_entry( $newlabels, $edge->to->name, $edge->name );
418             $self->del_path( $edge );
419         }
420
421         foreach my $newdest ( keys %$newlabels ) {
422             my $label;
423             my @compressed_wits = ();
424             if( @{$newlabels->{$newdest}} < $majority ) {
425                 $label = join( ', ', sort( @{$newlabels->{$newdest}} ) );
426             } else {
427                 ## TODO FIX THIS HACK
428                 my @aclabels;
429                 foreach my $wit ( @{$newlabels->{$newdest}} ) {
430                     if( $wit =~ /^(.*?)(\s*\(?a\.\s*c\.\)?)$/ ) {
431                         push( @aclabels, $wit );
432                     } else {
433                         push( @compressed_wits, $wit );
434                     }
435                 }
436                 $label = join( ', ', 'majority', sort( @aclabels ) );
437             }
438             
439             my $newpath = 
440                 $self->add_path( $node, $self->reading( $newdest ), $label );
441             if( @compressed_wits ) {
442                 $newpath->hidden_witnesses( \@compressed_wits );
443             }
444         }
445     }
446
447     $self->collapsed( 1 );
448 }
449
450 sub expand_graph_paths {
451     my $self = shift;
452     # Our collation graph has only one path between any two nodes.
453     # This is great for display, but not so great for analysis.
454     # Expand this so that each witness has its own path between any
455     # two reading nodes.
456     return unless $self->collapsed;
457     
458     print STDERR "Expanding witness paths in graph...\n";
459     foreach my $path( $self->paths ) {
460         my $from = $path->from;
461         my $to = $path->to;
462         my @wits = split( /, /, $path->label );
463         if( $path->has_hidden_witnesses ) {
464             push( @wits, @{$path->hidden_witnesses} );
465         }
466         $self->del_path( $path );
467         foreach ( @wits ) {
468             $self->add_path( $from, $to, $_ );
469         }
470     }
471     $self->collapsed( 0 );
472 }
473
474 =back
475
476 =head2 Navigation methods
477
478 =over
479
480 =item B<start>
481
482 my $beginning = $collation->start();
483
484 Returns the beginning of the collation, a meta-reading with label '#START#'.
485
486 =cut
487
488 sub start {
489     # Return the beginning reading of the graph.
490     my $self = shift;
491     my( $new_start ) = @_;
492     if( $new_start ) {
493         $self->del_reading( '#START#' );
494         $self->graph->rename_node( $new_start, '#START#' );
495     }
496     return $self->reading('#START#');
497 }
498
499 =item B<reading_sequence>
500
501 my @readings = $graph->reading_sequence( $first, $last, $path[, $alt_path] );
502
503 Returns the ordered list of readings, starting with $first and ending
504 with $last, along the given witness path.  If no path is specified,
505 assume that the path is that of the base text (if any.)
506
507 =cut
508
509 sub reading_sequence {
510     my( $self, $start, $end, $witness, $backup ) = @_;
511
512     $witness = $self->baselabel unless $witness;
513     my @readings = ( $start );
514     my %seen;
515     my $n = $start;
516     while( $n && $n ne $end ) {
517         if( exists( $seen{$n->name()} ) ) {
518             warn "Detected loop at " . $n->name();
519             last;
520         }
521         $seen{$n->name()} = 1;
522         
523         my $next = $self->next_reading( $n, $witness, $backup );
524         warn "Did not find any path for $witness from reading " . $n->name
525             unless $next;
526         push( @readings, $next );
527         $n = $next;
528     }
529     # Check that the last reading is our end reading.
530     my $last = $readings[$#readings];
531     warn "Last reading found from " . $start->label() .
532         " for witness $witness is not the end!"
533         unless $last eq $end;
534     
535     return @readings;
536 }
537
538 =item B<next_reading>
539
540 my $next_reading = $graph->next_reading( $reading, $witpath );
541
542 Returns the reading that follows the given reading along the given witness
543 path.  
544
545 =cut
546
547 sub next_reading {
548     # Return the successor via the corresponding path.
549     my $self = shift;
550     return $self->_find_linked_reading( 'next', @_ );
551 }
552
553 =item B<prior_reading>
554
555 my $prior_reading = $graph->prior_reading( $reading, $witpath );
556
557 Returns the reading that precedes the given reading along the given witness
558 path.  
559
560 =cut
561
562 sub prior_reading {
563     # Return the predecessor via the corresponding path.
564     my $self = shift;
565     return $self->_find_linked_reading( 'prior', @_ );
566 }
567
568 sub _find_linked_reading {
569     my( $self, $direction, $node, $path, $alt_path ) = @_;
570     my @linked_paths = $direction eq 'next' 
571         ? $node->outgoing() : $node->incoming();
572     return undef unless scalar( @linked_paths );
573     
574     # We have to find the linked path that contains all of the
575     # witnesses supplied in $path.
576     my( @path_wits, @alt_path_wits );
577     @path_wits = $self->witnesses_of_label( $path ) if $path;
578     @alt_path_wits = $self->witnesses_of_label( $alt_path ) if $alt_path;
579     my $base_le;
580     my $alt_le;
581     foreach my $le ( @linked_paths ) {
582         if( $le->name eq $self->baselabel ) {
583             $base_le = $le;
584         } else {
585             my @le_wits = $self->witnesses_of_label( $le->name );
586             if( _is_within( \@path_wits, \@le_wits ) ) {
587                 # This is the right path.
588                 return $direction eq 'next' ? $le->to() : $le->from();
589             } elsif( _is_within( \@alt_path_wits, \@le_wits ) ) {
590                 $alt_le = $le;
591             }
592         }
593     }
594     # Got this far? Return the alternate path if it exists.
595     return $direction eq 'next' ? $alt_le->to() : $alt_le->from()
596         if $alt_le;
597
598     # Got this far? Return the base path if it exists.
599     return $direction eq 'next' ? $base_le->to() : $base_le->from()
600         if $base_le;
601
602     # Got this far? We have no appropriate path.
603     warn "Could not find $direction node from " . $node->label 
604         . " along path $path";
605     return undef;
606 }
607
608 # Some set logic.
609 sub _is_within {
610     my( $set1, $set2 ) = @_;
611     my $ret = @$set1; # will be 0, i.e. false, if set1 is empty
612     foreach my $el ( @$set1 ) {
613         $ret = 0 unless grep { /^\Q$el\E$/ } @$set2;
614     }
615     return $ret;
616 }
617
618
619 ## INITIALIZATION METHODS - for use by parsers
620 # Walk the paths for each witness in the graph, and return the nodes
621 # that the graph has in common.  If $using_base is true, some 
622 # different logic is needed.
623
624 sub walk_witness_paths {
625     my( $self, $end ) = @_;
626     # For each witness, walk the path through the graph.
627     # Then we need to find the common nodes.  
628     # TODO This method is going to fall down if we have a very gappy 
629     # text in the collation.
630     my $paths = {};
631     my @common_readings;
632     foreach my $wit ( @{$self->tradition->witnesses} ) {
633         my $curr_reading = $self->start;
634         my @wit_path = $self->reading_sequence( $self->start, $end, 
635                                                 $wit->sigil );
636         $wit->path( \@wit_path );
637
638         # Detect the common readings.
639         @common_readings = _find_common( \@common_readings, \@wit_path );
640     }
641
642     # Mark all the nodes as either common or not.
643     foreach my $cn ( @common_readings ) {
644         print STDERR "Setting " . $cn->name . " / " . $cn->label 
645             . " as common node\n";
646         $cn->make_common;
647     }
648     foreach my $n ( $self->readings() ) {
649         $n->make_variant unless $n->is_common;
650     }
651     # Return an array of the common nodes in order.
652     return @common_readings;
653 }
654
655 sub _find_common {
656     my( $common_readings, $new_path ) = @_;
657     my @cr;
658     if( @$common_readings ) {
659         foreach my $n ( @$new_path ) {
660             push( @cr, $n ) if grep { $_ eq $n } @$common_readings;
661         }
662     } else {
663         push( @cr, @$new_path );
664     }
665     return @cr;
666 }
667
668 sub _remove_common {
669     my( $common_readings, $divergence ) = @_;
670     my @cr;
671     my %diverged;
672     map { $diverged{$_->name} = 1 } @$divergence;
673     foreach( @$common_readings ) {
674         push( @cr, $_ ) unless $diverged{$_->name};
675     }
676     return @cr;
677 }
678
679
680 # An alternative to walk_witness_paths, for use when a collation is
681 # constructed from a base text and an apparatus.  We have the
682 # sequences of readings and just need to add path edges.
683
684 sub make_witness_paths {
685     my( $self ) = @_;
686
687     my @common_readings;
688     foreach my $wit ( @{$self->tradition->witnesses} ) {
689         print STDERR "Making path for " . $wit->sigil . "\n";
690         $self->make_witness_path( $wit );
691         @common_readings = _find_common( \@common_readings, $wit->path );
692         @common_readings = _find_common( \@common_readings, $wit->uncorrected_path );
693     }
694     return @common_readings;
695 }
696
697 sub make_witness_path {
698     my( $self, $wit ) = @_;
699     my @chain = @{$wit->path};
700     my $sig = $wit->sigil;
701     foreach my $idx ( 0 .. $#chain-1 ) {
702         $self->add_path( $chain[$idx], $chain[$idx+1], $sig );
703     }
704     @chain = @{$wit->uncorrected_path};
705     foreach my $idx( 0 .. $#chain-1 ) {
706         my $source = $chain[$idx];
707         my $target = $chain[$idx+1];
708         $self->add_path( $source, $target, "$sig (a.c.)" )
709             unless $self->has_path( $source, $target, $sig );
710     }
711 }
712
713 sub common_readings {
714     my $self = shift;
715     my @common = grep { $_->is_common } $self->readings();
716     return sort { _cmp_position( $a->position, $b->position ) } @common;
717 }
718
719 # Calculate the relative positions of nodes in the graph, if they
720 # were not given to us.
721 sub calculate_positions {
722     my( $self, @ordered_common ) = @_;
723
724     # We have to calculate the position identifiers for each word,
725     # keyed on the common nodes.  This will be 'fun'.  The end result
726     # is a hash per witness, whose key is the word node and whose
727     # value is its position in the text.  Common nodes are always N,1
728     # so have identical positions in each text.
729
730     my $node_pos = {};
731     foreach my $wit ( @{$self->tradition->witnesses} ) {
732         print STDERR "Calculating positions in " . $wit->sigil . "\n";
733         _update_positions_from_path( $wit->path, @ordered_common );
734         _update_positions_from_path( $wit->uncorrected_path, @ordered_common )
735             if $wit->has_ante_corr;
736     }
737     
738     # DEBUG
739     foreach my $r ( $self->readings() ) {
740         print STDERR "Reading " . $r->name . "/" . $r->label . " has no position\n"
741             unless( $r->has_position );
742     }
743
744     $self->init_lemmata();
745 }
746
747 sub _update_positions_from_path {
748     my( $path, @ordered_common ) = @_;
749
750     # First we walk the given path, making a matrix for the witness
751     # that corresponds to its eventual position identifier.  Common
752     # nodes always start a new row, and are thus always in the first
753     # column.
754     my $wit_matrix = [];
755     my $cn = 0;  # We should hit the common readings in order.
756     my $row = [];
757     foreach my $wn ( @{$path} ) {
758         if( $wn eq $ordered_common[$cn] ) {
759             # Set up to look for the next common node, and
760             # start a new row of words.
761             $cn++;
762             push( @$wit_matrix, $row ) if scalar( @$row );
763             $row = [];
764         }
765         push( @$row, $wn );
766     }
767     push( @$wit_matrix, $row );  # Push the last row onto the matrix
768
769     # Now we have a matrix per witness, so that each row in the
770     # matrix begins with a common node, and continues with all the
771     # variant words that appear in the witness.  We turn this into
772     # real positions in row,cell format.  But we need some
773     # trickery in order to make sure that each node gets assigned
774     # to only one position.
775     
776     foreach my $li ( 1..scalar(@$wit_matrix) ) {
777         foreach my $di ( 1..scalar(@{$wit_matrix->[$li-1]}) ) {
778             my $reading = $wit_matrix->[$li-1]->[$di-1];
779             my $position = "$li,$di";
780
781             # If we have seen this node before, we need to compare
782             # its position with what went before.
783             unless( $reading->has_position &&
784                     _cmp_position( $position, $reading->position ) < 1 ) {
785                 # The new position ID replaces the old one.
786                 $reading->position( $position );
787             } # otherwise, the old position needs to stay.
788         }
789     }
790 }
791
792 sub _cmp_position {
793     my( $a, $b ) = @_;
794     if ( $a && $b ) {
795         my @pos_a = split(/,/, $a );
796         my @pos_b = split(/,/, $b );
797
798         my $big_cmp = $pos_a[0] <=> $pos_b[0];
799         return $big_cmp if $big_cmp;
800         # else 
801         return $pos_a[1] <=> $pos_b[1];
802     } elsif ( $b ) { # a is undefined
803         return -1;
804     } elsif ( $a ) { # b is undefined
805         return 1;
806     }
807     return 0; # they are both undefined
808 }
809
810 sub all_positions {
811     my $self = shift;
812     my %positions = ();
813     map { $positions{$_->position} = 1 } $self->readings;
814     my @answer = sort { _cmp_position( $a, $b ) } keys( %positions );
815     return @answer;
816 }
817
818 sub readings_at_position {
819     my( $self, $pos ) = @_;
820     my @answer = grep { $_->position eq $pos } $self->readings;
821     return @answer;
822 }
823
824 ## Lemmatizer functions
825
826 sub init_lemmata {
827     my $self = shift;
828     
829     foreach my $position ( $self->all_positions ) {
830         $self->lemmata->{$position} = undef;
831     }
832
833     foreach my $cr ( $self->common_readings ) {
834         $self->lemmata->{$cr->position} = $cr->name;
835     }
836 }
837     
838 =item B<lemma_readings>
839
840 my @state = $graph->lemma_readings( @readings_delemmatized );
841
842 Takes a list of readings that have just been delemmatized, and returns
843 a set of tuples of the form ['reading', 'state'] that indicates what
844 changes need to be made to the graph.
845
846 =over
847
848 =item * 
849
850 A state of 1 means 'lemmatize this reading'
851
852 =item * 
853
854 A state of 0 means 'delemmatize this reading'
855
856 =item * 
857
858 A state of undef means 'an ellipsis belongs in the text here because
859 no decision has been made / an earlier decision was backed out'
860
861 =back
862
863 =cut
864
865 sub lemma_readings {
866     my( $self, @toggled_off_nodes ) = @_;
867
868     # First get the positions of those nodes which have been
869     # toggled off.
870     my $positions_off = {};
871     map { $positions_off->{ $_->position } = $_->name } @toggled_off_nodes;
872
873     # Now for each position, we have to see if a node is on, and we
874     # have to see if a node has been turned off.
875     my @answer;
876     foreach my $pos ( $self->all_positions() ) {
877         # Find the state of this position.  If there is an active node,
878         # its name will be the state; otherwise the state will be 0 
879         # (nothing at this position) or undef (ellipsis at this position)
880         my $active = $self->lemmata->{$pos};
881         
882         # Is there a formerly active node that was toggled off?
883         if( exists( $positions_off->{$pos} ) ) {
884             my $off_node = $positions_off->{$pos};
885             if( $active && $active ne $off_node) {
886                 push( @answer, [ $off_node, 0 ], [ $active, 1 ] );
887             } else {
888                 push( @answer, [ $off_node, $active ] );
889             }
890
891         # No formerly active node, so we just see if there is a currently
892         # active one.
893         } elsif( $active ) {
894             # Push the active node, whatever it is.
895             push( @answer, [ $active, 1 ] );
896         } else {
897             # Push the state that is there. Arbitrarily use the first node
898             # at that position.
899             my @pos_nodes = $self->readings_at_position( $pos );
900             push( @answer, [ $pos_nodes[0]->name, $self->lemmata->{$pos} ] );
901         }
902     }
903     
904     return @answer;
905 }
906
907 =item B<toggle_reading>
908
909 my @readings_delemmatized = $graph->toggle_reading( $reading_name );
910
911 Takes a reading node name, and either lemmatizes or de-lemmatizes
912 it. Returns a list of all readings that are de-lemmatized as a result
913 of the toggle.
914
915 =cut
916
917 sub toggle_reading {
918     my( $self, $rname ) = @_;
919     
920     return unless $rname;
921     my $reading = $self->reading( $rname );
922     if( !$reading || $reading->is_common() ) {
923         # Do nothing, it's a common node.
924         return;
925     } 
926     
927     my $pos = $reading->position;
928     my $old_state = $self->lemmata->{$pos};
929     my @readings_off;
930     if( $old_state && $old_state eq $rname ) {
931         # Turn off the node. We turn on no others by default.
932         push( @readings_off, $reading );
933     } else {
934         # Turn on the node.
935         $self->lemmata->{$pos} = $rname;
936         # Any other 'on' readings in the same position should be off.
937         push( @readings_off, $self->same_position_as( $reading ) );
938         # Any node that is an identical transposed one should be off.
939         push( @readings_off, $reading->identical_readings );
940     }
941     @readings_off = unique_list( @readings_off );
942
943     # Turn off the readings that need to be turned off.
944     my @readings_delemmatized;
945     foreach my $n ( @readings_off ) {
946         my $state = $self->lemmata->{$n->position};
947         if( $state && $state eq $n->name ) { 
948             # this reading is still on, so turn it off
949             push( @readings_delemmatized, $n );
950             my $new_state = undef;
951             if( $n eq $reading ) {
952                 # This is the reading that was clicked, so if there are no
953                 # other readings there, turn off the position.  In all other
954                 # cases, restore the ellipsis.
955                 my @other_n = $self->same_position_as( $n );
956                 $new_state = 0 unless @other_n;
957             }
958             $self->lemmata->{$n->position} = $new_state;
959         } elsif( $old_state && $old_state eq $n->name ) { 
960             # another reading has already been turned on here
961             push( @readings_delemmatized, $n );
962         } # else some other reading was on anyway, so pass.
963     }
964     return @readings_delemmatized;
965 }
966
967 sub same_position_as {
968     my( $self, $reading ) = @_;
969     my $pos = $reading->position;
970     my @same = grep { $_ ne $reading } $self->readings_at_position( $reading->position );
971     return @same;
972 }
973
974 # Return the string that joins together a list of witnesses for
975 # display on a single path.
976 sub path_label {
977     my $self = shift;
978     return join( $self->wit_list_separator, @_ );
979 }
980
981 sub witnesses_of_label {
982     my( $self, $label ) = @_;
983     my $regex = $self->wit_list_separator;
984     my @answer = split( /\Q$regex\E/, $label );
985     return @answer;
986 }    
987
988 sub unique_list {
989     my( @list ) = @_;
990     my %h;
991     map { $h{$_->name} = $_ } @list;
992     return values( %h );
993 }
994
995 sub add_hash_entry {
996     my( $hash, $key, $entry ) = @_;
997     if( exists $hash->{$key} ) {
998         push( @{$hash->{$key}}, $entry );
999     } else {
1000         $hash->{$key} = [ $entry ];
1001     }
1002 }
1003
1004 no Moose;
1005 __PACKAGE__->meta->make_immutable;