generate svg with relationships invisible; fix graphml output
[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     foreach my $node( $self->readings ) {
411         my $newlabels = {};
412         # We will visit each node, so we only look ahead.
413         foreach my $edge ( $node->outgoing() ) {
414             next unless $edge->class eq 'edge.path';
415             add_hash_entry( $newlabels, $edge->to->name, $edge->name );
416             $self->del_path( $edge );
417         }
418
419         foreach my $newdest ( keys %$newlabels ) {
420             my $label;
421             my @compressed_wits = ();
422             if( @{$newlabels->{$newdest}} < $majority ) {
423                 $label = join( ', ', @{$newlabels->{$newdest}} );
424             } else {
425                 ## TODO FIX THIS HACK
426                 my @aclabels;
427                 foreach my $wit ( @{$newlabels->{$newdest}} ) {
428                     if( $wit =~ /^(.*?)(\s*\(?a\.\s*c\.\)?)$/ ) {
429                         push( @aclabels, $wit );
430                     } else {
431                         push( @compressed_wits, $wit );
432                     }
433                 }
434                 $label = join( ', ', 'majority', @aclabels );
435             }
436             
437             my $newpath = 
438                 $self->add_path( $node, $self->reading( $newdest ), $label );
439             if( @compressed_wits ) {
440                 $newpath->hidden_witnesses( \@compressed_wits );
441             }
442         }
443     }
444
445     $self->collapsed( 1 );
446 }
447
448 sub expand_graph_paths {
449     my $self = shift;
450     # Our collation graph has only one path between any two nodes.
451     # This is great for display, but not so great for analysis.
452     # Expand this so that each witness has its own path between any
453     # two reading nodes.
454     return unless $self->collapsed;
455     
456     print STDERR "Expanding witness paths in graph...\n";
457     foreach my $path( $self->paths ) {
458         my $from = $path->from;
459         my $to = $path->to;
460         my @wits = split( /, /, $path->label );
461         if( $path->has_hidden_witnesses ) {
462             push( @wits, @{$path->hidden_witnesses} );
463         }
464         $self->del_path( $path );
465         foreach ( @wits ) {
466             $self->add_path( $from, $to, $_ );
467         }
468     }
469     $self->collapsed( 0 );
470 }
471
472 =back
473
474 =head2 Navigation methods
475
476 =over
477
478 =item B<start>
479
480 my $beginning = $collation->start();
481
482 Returns the beginning of the collation, a meta-reading with label '#START#'.
483
484 =cut
485
486 sub start {
487     # Return the beginning reading of the graph.
488     my $self = shift;
489     my( $new_start ) = @_;
490     if( $new_start ) {
491         $self->del_reading( '#START#' );
492         $self->graph->rename_node( $new_start, '#START#' );
493     }
494     return $self->reading('#START#');
495 }
496
497 =item B<reading_sequence>
498
499 my @readings = $graph->reading_sequence( $first, $last, $path[, $alt_path] );
500
501 Returns the ordered list of readings, starting with $first and ending
502 with $last, along the given witness path.  If no path is specified,
503 assume that the path is that of the base text (if any.)
504
505 =cut
506
507 sub reading_sequence {
508     my( $self, $start, $end, $witness, $backup ) = @_;
509
510     $witness = $self->baselabel unless $witness;
511     my @readings = ( $start );
512     my %seen;
513     my $n = $start;
514     while( $n && $n ne $end ) {
515         if( exists( $seen{$n->name()} ) ) {
516             warn "Detected loop at " . $n->name();
517             last;
518         }
519         $seen{$n->name()} = 1;
520         
521         my $next = $self->next_reading( $n, $witness, $backup );
522         warn "Did not find any path for $witness from reading " . $n->name
523             unless $next;
524         push( @readings, $next );
525         $n = $next;
526     }
527     # Check that the last reading is our end reading.
528     my $last = $readings[$#readings];
529     warn "Last reading found from " . $start->label() .
530         " for witness $witness is not the end!"
531         unless $last eq $end;
532     
533     return @readings;
534 }
535
536 =item B<next_reading>
537
538 my $next_reading = $graph->next_reading( $reading, $witpath );
539
540 Returns the reading that follows the given reading along the given witness
541 path.  
542
543 =cut
544
545 sub next_reading {
546     # Return the successor via the corresponding path.
547     my $self = shift;
548     return $self->_find_linked_reading( 'next', @_ );
549 }
550
551 =item B<prior_reading>
552
553 my $prior_reading = $graph->prior_reading( $reading, $witpath );
554
555 Returns the reading that precedes the given reading along the given witness
556 path.  
557
558 =cut
559
560 sub prior_reading {
561     # Return the predecessor via the corresponding path.
562     my $self = shift;
563     return $self->_find_linked_reading( 'prior', @_ );
564 }
565
566 sub _find_linked_reading {
567     my( $self, $direction, $node, $path, $alt_path ) = @_;
568     my @linked_paths = $direction eq 'next' 
569         ? $node->outgoing() : $node->incoming();
570     return undef unless scalar( @linked_paths );
571     
572     # We have to find the linked path that contains all of the
573     # witnesses supplied in $path.
574     my( @path_wits, @alt_path_wits );
575     @path_wits = $self->witnesses_of_label( $path ) if $path;
576     @alt_path_wits = $self->witnesses_of_label( $alt_path ) if $alt_path;
577     my $base_le;
578     my $alt_le;
579     foreach my $le ( @linked_paths ) {
580         if( $le->name eq $self->baselabel ) {
581             $base_le = $le;
582         } else {
583             my @le_wits = $self->witnesses_of_label( $le->name );
584             if( _is_within( \@path_wits, \@le_wits ) ) {
585                 # This is the right path.
586                 return $direction eq 'next' ? $le->to() : $le->from();
587             } elsif( _is_within( \@alt_path_wits, \@le_wits ) ) {
588                 $alt_le = $le;
589             }
590         }
591     }
592     # Got this far? Return the alternate path if it exists.
593     return $direction eq 'next' ? $alt_le->to() : $alt_le->from()
594         if $alt_le;
595
596     # Got this far? Return the base path if it exists.
597     return $direction eq 'next' ? $base_le->to() : $base_le->from()
598         if $base_le;
599
600     # Got this far? We have no appropriate path.
601     warn "Could not find $direction node from " . $node->label 
602         . " along path $path";
603     return undef;
604 }
605
606 # Some set logic.
607 sub _is_within {
608     my( $set1, $set2 ) = @_;
609     my $ret = @$set1; # will be 0, i.e. false, if set1 is empty
610     foreach my $el ( @$set1 ) {
611         $ret = 0 unless grep { /^\Q$el\E$/ } @$set2;
612     }
613     return $ret;
614 }
615
616
617 ## INITIALIZATION METHODS - for use by parsers
618 # Walk the paths for each witness in the graph, and return the nodes
619 # that the graph has in common.  If $using_base is true, some 
620 # different logic is needed.
621
622 sub walk_witness_paths {
623     my( $self, $end ) = @_;
624     # For each witness, walk the path through the graph.
625     # Then we need to find the common nodes.  
626     # TODO This method is going to fall down if we have a very gappy 
627     # text in the collation.
628     my $paths = {};
629     my @common_readings;
630     foreach my $wit ( @{$self->tradition->witnesses} ) {
631         my $curr_reading = $self->start;
632         my @wit_path = $self->reading_sequence( $self->start, $end, 
633                                                 $wit->sigil );
634         $wit->path( \@wit_path );
635
636         # Detect the common readings.
637         @common_readings = _find_common( \@common_readings, \@wit_path );
638     }
639
640     # Mark all the nodes as either common or not.
641     foreach my $cn ( @common_readings ) {
642         print STDERR "Setting " . $cn->name . " / " . $cn->label 
643             . " as common node\n";
644         $cn->make_common;
645     }
646     foreach my $n ( $self->readings() ) {
647         $n->make_variant unless $n->is_common;
648     }
649     # Return an array of the common nodes in order.
650     return @common_readings;
651 }
652
653 sub _find_common {
654     my( $common_readings, $new_path ) = @_;
655     my @cr;
656     if( @$common_readings ) {
657         foreach my $n ( @$new_path ) {
658             push( @cr, $n ) if grep { $_ eq $n } @$common_readings;
659         }
660     } else {
661         push( @cr, @$new_path );
662     }
663     return @cr;
664 }
665
666 sub _remove_common {
667     my( $common_readings, $divergence ) = @_;
668     my @cr;
669     my %diverged;
670     map { $diverged{$_->name} = 1 } @$divergence;
671     foreach( @$common_readings ) {
672         push( @cr, $_ ) unless $diverged{$_->name};
673     }
674     return @cr;
675 }
676
677
678 # An alternative to walk_witness_paths, for use when a collation is
679 # constructed from a base text and an apparatus.  We have the
680 # sequences of readings and just need to add path edges.
681
682 sub make_witness_paths {
683     my( $self ) = @_;
684
685     my @common_readings;
686     foreach my $wit ( @{$self->tradition->witnesses} ) {
687         print STDERR "Making path for " . $wit->sigil . "\n";
688         $self->make_witness_path( $wit );
689         @common_readings = _find_common( \@common_readings, $wit->path );
690         @common_readings = _find_common( \@common_readings, $wit->uncorrected_path );
691     }
692     return @common_readings;
693 }
694
695 sub make_witness_path {
696     my( $self, $wit ) = @_;
697     my @chain = @{$wit->path};
698     my $sig = $wit->sigil;
699     foreach my $idx ( 0 .. $#chain-1 ) {
700         $self->add_path( $chain[$idx], $chain[$idx+1], $sig );
701     }
702     @chain = @{$wit->uncorrected_path};
703     foreach my $idx( 0 .. $#chain-1 ) {
704         my $source = $chain[$idx];
705         my $target = $chain[$idx+1];
706         $self->add_path( $source, $target, "$sig (a.c.)" )
707             unless $self->has_path( $source, $target, $sig );
708     }
709 }
710
711 sub common_readings {
712     my $self = shift;
713     my @common = grep { $_->is_common } $self->readings();
714     return sort { _cmp_position( $a->position, $b->position ) } @common;
715 }
716
717 # Calculate the relative positions of nodes in the graph, if they
718 # were not given to us.
719 sub calculate_positions {
720     my( $self, @ordered_common ) = @_;
721
722     # We have to calculate the position identifiers for each word,
723     # keyed on the common nodes.  This will be 'fun'.  The end result
724     # is a hash per witness, whose key is the word node and whose
725     # value is its position in the text.  Common nodes are always N,1
726     # so have identical positions in each text.
727
728     my $node_pos = {};
729     foreach my $wit ( @{$self->tradition->witnesses} ) {
730         print STDERR "Calculating positions in " . $wit->sigil . "\n";
731         _update_positions_from_path( $wit->path, @ordered_common );
732         _update_positions_from_path( $wit->uncorrected_path, @ordered_common )
733             if $wit->has_ante_corr;
734     }
735     
736     # DEBUG
737     foreach my $r ( $self->readings() ) {
738         print STDERR "Reading " . $r->name . "/" . $r->label . " has no position\n"
739             unless( $r->has_position );
740     }
741
742     $self->init_lemmata();
743 }
744
745 sub _update_positions_from_path {
746     my( $path, @ordered_common ) = @_;
747
748     # First we walk the given path, making a matrix for the witness
749     # that corresponds to its eventual position identifier.  Common
750     # nodes always start a new row, and are thus always in the first
751     # column.
752     my $wit_matrix = [];
753     my $cn = 0;  # We should hit the common readings in order.
754     my $row = [];
755     foreach my $wn ( @{$path} ) {
756         if( $wn eq $ordered_common[$cn] ) {
757             # Set up to look for the next common node, and
758             # start a new row of words.
759             $cn++;
760             push( @$wit_matrix, $row ) if scalar( @$row );
761             $row = [];
762         }
763         push( @$row, $wn );
764     }
765     push( @$wit_matrix, $row );  # Push the last row onto the matrix
766
767     # Now we have a matrix per witness, so that each row in the
768     # matrix begins with a common node, and continues with all the
769     # variant words that appear in the witness.  We turn this into
770     # real positions in row,cell format.  But we need some
771     # trickery in order to make sure that each node gets assigned
772     # to only one position.
773     
774     foreach my $li ( 1..scalar(@$wit_matrix) ) {
775         foreach my $di ( 1..scalar(@{$wit_matrix->[$li-1]}) ) {
776             my $reading = $wit_matrix->[$li-1]->[$di-1];
777             my $position = "$li,$di";
778
779             # If we have seen this node before, we need to compare
780             # its position with what went before.
781             unless( $reading->has_position &&
782                     _cmp_position( $position, $reading->position ) < 1 ) {
783                 # The new position ID replaces the old one.
784                 $reading->position( $position );
785             } # otherwise, the old position needs to stay.
786         }
787     }
788 }
789
790 sub _cmp_position {
791     my( $a, $b ) = @_;
792     if ( $a && $b ) {
793         my @pos_a = split(/,/, $a );
794         my @pos_b = split(/,/, $b );
795
796         my $big_cmp = $pos_a[0] <=> $pos_b[0];
797         return $big_cmp if $big_cmp;
798         # else 
799         return $pos_a[1] <=> $pos_b[1];
800     } elsif ( $b ) { # a is undefined
801         return -1;
802     } elsif ( $a ) { # b is undefined
803         return 1;
804     }
805     return 0; # they are both undefined
806 }
807
808 sub all_positions {
809     my $self = shift;
810     my %positions = ();
811     map { $positions{$_->position} = 1 } $self->readings;
812     my @answer = sort { _cmp_position( $a, $b ) } keys( %positions );
813     return @answer;
814 }
815
816 sub readings_at_position {
817     my( $self, $pos ) = @_;
818     my @answer = grep { $_->position eq $pos } $self->readings;
819     return @answer;
820 }
821
822 ## Lemmatizer functions
823
824 sub init_lemmata {
825     my $self = shift;
826     
827     foreach my $position ( $self->all_positions ) {
828         $self->lemmata->{$position} = undef;
829     }
830
831     foreach my $cr ( $self->common_readings ) {
832         $self->lemmata->{$cr->position} = $cr->name;
833     }
834 }
835     
836 =item B<lemma_readings>
837
838 my @state = $graph->lemma_readings( @readings_delemmatized );
839
840 Takes a list of readings that have just been delemmatized, and returns
841 a set of tuples of the form ['reading', 'state'] that indicates what
842 changes need to be made to the graph.
843
844 =over
845
846 =item * 
847
848 A state of 1 means 'lemmatize this reading'
849
850 =item * 
851
852 A state of 0 means 'delemmatize this reading'
853
854 =item * 
855
856 A state of undef means 'an ellipsis belongs in the text here because
857 no decision has been made / an earlier decision was backed out'
858
859 =back
860
861 =cut
862
863 sub lemma_readings {
864     my( $self, @toggled_off_nodes ) = @_;
865
866     # First get the positions of those nodes which have been
867     # toggled off.
868     my $positions_off = {};
869     map { $positions_off->{ $_->position } = $_->name } @toggled_off_nodes;
870
871     # Now for each position, we have to see if a node is on, and we
872     # have to see if a node has been turned off.
873     my @answer;
874     foreach my $pos ( $self->all_positions() ) {
875         # Find the state of this position.  If there is an active node,
876         # its name will be the state; otherwise the state will be 0 
877         # (nothing at this position) or undef (ellipsis at this position)
878         my $active = $self->lemmata->{$pos};
879         
880         # Is there a formerly active node that was toggled off?
881         if( exists( $positions_off->{$pos} ) ) {
882             my $off_node = $positions_off->{$pos};
883             if( $active && $active ne $off_node) {
884                 push( @answer, [ $off_node, 0 ], [ $active, 1 ] );
885             } else {
886                 push( @answer, [ $off_node, $active ] );
887             }
888
889         # No formerly active node, so we just see if there is a currently
890         # active one.
891         } elsif( $active ) {
892             # Push the active node, whatever it is.
893             push( @answer, [ $active, 1 ] );
894         } else {
895             # Push the state that is there. Arbitrarily use the first node
896             # at that position.
897             my @pos_nodes = $self->readings_at_position( $pos );
898             push( @answer, [ $pos_nodes[0]->name, $self->lemmata->{$pos} ] );
899         }
900     }
901     
902     return @answer;
903 }
904
905 =item B<toggle_reading>
906
907 my @readings_delemmatized = $graph->toggle_reading( $reading_name );
908
909 Takes a reading node name, and either lemmatizes or de-lemmatizes
910 it. Returns a list of all readings that are de-lemmatized as a result
911 of the toggle.
912
913 =cut
914
915 sub toggle_reading {
916     my( $self, $rname ) = @_;
917     
918     return unless $rname;
919     my $reading = $self->reading( $rname );
920     if( !$reading || $reading->is_common() ) {
921         # Do nothing, it's a common node.
922         return;
923     } 
924     
925     my $pos = $reading->position;
926     my $old_state = $self->lemmata->{$pos};
927     my @readings_off;
928     if( $old_state && $old_state eq $rname ) {
929         # Turn off the node. We turn on no others by default.
930         push( @readings_off, $reading );
931     } else {
932         # Turn on the node.
933         $self->lemmata->{$pos} = $rname;
934         # Any other 'on' readings in the same position should be off.
935         push( @readings_off, $self->same_position_as( $reading ) );
936         # Any node that is an identical transposed one should be off.
937         push( @readings_off, $reading->identical_readings );
938     }
939     @readings_off = unique_list( @readings_off );
940
941     # Turn off the readings that need to be turned off.
942     my @readings_delemmatized;
943     foreach my $n ( @readings_off ) {
944         my $state = $self->lemmata->{$n->position};
945         if( $state && $state eq $n->name ) { 
946             # this reading is still on, so turn it off
947             push( @readings_delemmatized, $n );
948             my $new_state = undef;
949             if( $n eq $reading ) {
950                 # This is the reading that was clicked, so if there are no
951                 # other readings there, turn off the position.  In all other
952                 # cases, restore the ellipsis.
953                 my @other_n = $self->same_position_as( $n );
954                 $new_state = 0 unless @other_n;
955             }
956             $self->lemmata->{$n->position} = $new_state;
957         } elsif( $old_state && $old_state eq $n->name ) { 
958             # another reading has already been turned on here
959             push( @readings_delemmatized, $n );
960         } # else some other reading was on anyway, so pass.
961     }
962     return @readings_delemmatized;
963 }
964
965 sub same_position_as {
966     my( $self, $reading ) = @_;
967     my $pos = $reading->position;
968     my @same = grep { $_ ne $reading } $self->readings_at_position( $reading->position );
969     return @same;
970 }
971
972 # Return the string that joins together a list of witnesses for
973 # display on a single path.
974 sub path_label {
975     my $self = shift;
976     return join( $self->wit_list_separator, @_ );
977 }
978
979 sub witnesses_of_label {
980     my( $self, $label ) = @_;
981     my $regex = $self->wit_list_separator;
982     my @answer = split( /\Q$regex\E/, $label );
983     return @answer;
984 }    
985
986 sub unique_list {
987     my( @list ) = @_;
988     my %h;
989     map { $h{$_->name} = $_ } @list;
990     return values( %h );
991 }
992
993 sub add_hash_entry {
994     my( $hash, $key, $entry ) = @_;
995     if( exists $hash->{$key} ) {
996         push( @{$hash->{$key}}, $entry );
997     } else {
998         $hash->{$key} = [ $entry ];
999     }
1000 }
1001
1002 no Moose;
1003 __PACKAGE__->meta->make_immutable;