use Text::Tradition::Collation::RelationshipStore;
use Text::Tradition::Error;
use XML::LibXML;
+use XML::LibXML::XPathContext;
use Moose;
has 'sequence' => (
=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;
+ # HACK part 3 - remove silent node+edge
+ my $parser = XML::LibXML->new();
+ my $svgdom = $parser->parse_string( $svg );
+ my $xpc = XML::LibXML::XPathContext->new( $svgdom->documentElement );
+ $xpc->registerNs( 'svg', 'http://www.w3.org/2000/svg' );
+ my @hacknodes = $xpc->findnodes( '//svg:g[contains(child::svg:title, "#SILENT#")]' );
+ foreach my $h ( @hacknodes ) {
+ $h->parentNode->removeChild( $h );
+ }
+ return decode_utf8( $svgdom->toString() );
}
-=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 ) {
if( $endrank ) {
$dot .= "\t\"#SUBEND#\" [ label=\"...\" ];\n";
}
+ if( !$startrank && !$endrank ) {
+ ## HACK part 1
+ $dot .= "\tsubgraph { rank=same \"#START#\" \"#SILENT#\" }\n";
+ $dot .= "\t\"#SILENT#\" [ color=white,penwidth=0,label=\"\" ];"
+ }
my %used; # Keep track of the readings that actually appear in the graph
foreach my $reading ( $self->readings ) {
# Only output readings within our rank range.
$used{$reading->id} = 1;
# Need not output nodes without separate labels
next if $reading->id eq $reading->text;
+ my $rattrs;
my $label = $reading->text;
$label =~ s/\"/\\\"/g;
- $dot .= sprintf( "\t\"%s\" [ label=\"%s\" ];\n", $reading->id, $label );
+ $rattrs->{'label'} = $label;
+ $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
my( %substart, %subend );
foreach my $edge ( @edges ) {
# Do we need to output this edge?
- if( $used{$edge->[0]} && $used{$edge->[1]} ) {;
+ if( $used{$edge->[0]} && $used{$edge->[1]} ) {
my $label = $self->path_display_label( $self->path_witnesses( $edge ) );
my $variables = { %edge_attrs, 'label' => $label };
# Account for the rank gap if necessary
$variables->{'minlen'} = $self->reading( $edge->[1] )->rank
- $self->reading( $edge->[0] )->rank;
}
+ # 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
+
my $varopts = _dot_attr_string( $variables );
$dot .= sprintf( "\t\"%s\" -> \"%s\" %s;\n",
$edge->[0], $edge->[1], $varopts );
my $varopts = _dot_attr_string( $variables );
$dot .= "\t\"$node\" -> \"#SUBEND#\" $varopts;";
}
+ # HACK part 2
+ if( !$startrank && !$endrank ) {
+ $dot .= "\t\"#END#\" -> \"#SILENT#\" [ color=white,penwidth=0 ];\n";
+ }
$dot .= "}\n";
return $dot;
@edge = @$e;
}
my @wits = keys %{$self->sequence->get_edge_attributes( @edge )};
- return sort @wits;
+ return @wits;
}
sub path_display_label {
- my( $self, @wits ) = @_;
+ my $self = shift;
+ my @wits = sort @_;
my $maj = scalar( $self->tradition->witnesses ) * 0.6;
if( scalar @wits > $maj ) {
# TODO break out a.c. wits
my $edge_ctr = 0;
foreach my $e ( sort { $a->[0] cmp $b->[0] } $self->sequence->edges() ) {
# We add an edge in the graphml for every witness in $e.
- foreach my $wit ( $self->path_witnesses( $e ) ) {
+ foreach my $wit ( sort $self->path_witnesses( $e ) ) {
my( $id, $from, $to ) = ( 'e'.$edge_ctr++,
$node_hash{ $e->[0] },
$node_hash{ $e->[1] } );
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 ) {
if( $self->sequence->has_edge_attribute( @$le, $self->baselabel ) ) {
$base_le = $le;
}
- my @le_wits = $self->path_witnesses( $le );
+ my @le_wits = sort $self->path_witnesses( $le );
if( _is_within( \@path_wits, \@le_wits ) ) {
# This is the right path.
return $direction eq 'next' ? $le->[1] : $le->[0];
return @answer;
}
+=head2 common_readings
+
+Returns the list of common readings in the graph (i.e. those readings that are
+shared by all non-lacunose witnesses.)
+
+=cut
+
+sub common_readings {
+ my $self = shift;
+ my @common = grep { $_->is_common } $self->readings;
+ return @common;
+}
+
=head2 path_text( $sigil, $mainsigil [, $start, $end ] )
Returns the text of a witness (plus its backup, if we are using a layer)
# 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;
}
}
}
+=head2 calculate_common_readings
+
+Goes through the graph identifying the readings that appear in every witness
+(apart from those with lacunae at that spot.) Marks them as common and returns
+the list.
+
+=begin testing
+
+use Text::Tradition;
+
+my $cxfile = 't/data/Collatex-16.xml';
+my $t = Text::Tradition->new(
+ 'name' => 'inline',
+ 'input' => 'CollateX',
+ 'file' => $cxfile,
+ );
+my $c = $t->collation;
+
+my @common = $c->calculate_common_readings();
+is( scalar @common, 8, "Found correct number of common readings" );
+my @marked = sort $c->common_readings();
+is( scalar @common, 8, "All common readings got marked as such" );
+my @expected = qw/ n1 n12 n16 n19 n20 n5 n6 n7 /;
+is_deeply( \@marked, \@expected, "Found correct list of common readings" );
+
+=end testing
+
+=cut
+
+sub calculate_common_readings {
+ my $self = shift;
+ my @common;
+ my $table = $self->make_alignment_table( 1 );
+ foreach my $idx ( 0 .. $table->{'length'} - 1 ) {
+ my @row = map { $_->{'tokens'}->[$idx]->{'t'} } @{$table->{'alignment'}};
+ my %hash;
+ foreach my $r ( @row ) {
+ if( $r ) {
+ $hash{$r->id} = $r unless $r->is_meta;
+ } else {
+ $hash{'UNDEF'} = $r;
+ }
+ }
+ if( keys %hash == 1 && !exists $hash{'UNDEF'} ) {
+ my( $r ) = values %hash;
+ $r->is_common( 1 );
+ push( @common, $r );
+ }
+ }
+ return @common;
+}
+
=head2 text_from_paths
Calculate the text array for all witnesses from the path, for later consistency