diff --git a/.bowerrc b/.bowerrc new file mode 100644 index 00000000..e28246d4 --- /dev/null +++ b/.bowerrc @@ -0,0 +1,3 @@ +{ + "directory": "www/lib" +} diff --git a/.extlib/.gitignore b/.extlib/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/.extlib/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..869d0624 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +Gemfile.lock +node_modules +www/lib +MYMETA.yml +MYMETA.json +Makefile +data diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..7ec1d6db --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.1.0 diff --git a/ArrayRepr.pm b/ArrayRepr.pm new file mode 100644 index 00000000..a05f1342 --- /dev/null +++ b/ArrayRepr.pm @@ -0,0 +1,223 @@ +package ArrayRepr; + +use strict; +use warnings; +use Carp; + +=head1 DESCRIPTION + + The ArrayRepr class is for operating on indexed representations of objects. + + For example, if we have a lot of objects with similar attributes, e.g.: + + [ + {start: 1, end: 2, strand: -1}, + {start: 5, end: 6, strand: 1}, + ... + ] + + we can represent them more compactly (e.g., in JSON) something like this: + + class = ["start", "end", "strand"] + + [ + [1, 2, -1], + [5, 6, 1], + ... + ] + + If we want to represent a few different kinds of objects in our big list, + we can have multiple "class" arrays, and tag each object to identify + which "class" array describes it. + + For example, if we have a lot of instances of a few types of objects, + like this: + + [ + {start: 1, end: 2, strand: 1, id: 1}, + {start: 5, end: 6, strand: 1, id: 2}, + ... + {start: 10, end: 20, chunk: 1}, + {start: 30, end: 40, chunk: 2}, + ... + ] + + We could use the first array position to indicate the "class" for the + object, like this: + + classes = [["start", "end", "strand", "id"], ["start", "end", "chunk"]] + + [ + [0, 1, 2, 1, 1], + [0, 5, 6, 1, 2], + ... + [1, 10, 20, 1], + [1, 30, 40, 1] + ] + + Also, if we occasionally want to add an ad-hoc attribute, we could just + stick an optional dictionary onto the end: + + classes = [["start", "end", "strand", "id"], ["start", "end", "chunk"]] + + [ + [0, 1, 2, 1, 1], + [0, 5, 6, 1, 2, {foo: 1}] + ] + + Given that individual objects are being represented by arrays, generic + code needs some way to differentiate arrays that are meant to be objects + from arrays that are actually meant to be arrays. + So for each class, we include a dict with : true mappings + for each attribute that is meant to be an array. + + Also, in cases where some attribute values are the same for all objects + in a particular set, it may be convenient to define a prototype ("proto") + with default values for all objects in the set + + In the end, we get something like this: + + classes = [ + { "attributes" : [ "Start", "End", "Subfeatures" ], + "proto" : { "Chrom" : "chr1" }, + "isArrayAttr" : { "Subfeatures" : true } + } + ] + + That's what this class facilitates. + +=cut + +sub new { + my ($class, $classes) = @_; + + # fields is an array of (map from attribute name to attribute index) + my @fields; + for my $attributes ( map $_->{attributes}, @$classes ) { + my $field_index = 1; + push @fields, { map { $_ => $field_index++ } @$attributes }; + } + + my $self = { + 'classes' => $classes, + 'fields' => \@fields + }; + + bless $self, $class; + return $self; +} + +sub attrIndices { + my ($self, $attr) = @_; + return [ map { $_->{$attr} } @{$self->{'fields'}} ]; +} + +sub get { + my ($self, $obj, $attr) = @_; + my $fields = $self->{'fields'}->[$obj->[0]]; + if (defined($fields) && defined($fields->{$attr})) { + return $obj->[$fields->{$attr}]; + } else { + my $cls = $self->{'classes'}->[$obj->[0]]; + return unless defined($cls); + my $adhocIndex = $#{$cls->{'attributes'}} + 2; + if (($adhocIndex > $#{$obj}) + or (not defined($obj->[$adhocIndex]->{$attr})) ) { + if (defined($cls->{'proto'}) + and (defined($cls->{'proto'}->{$attr})) ) { + return $cls->{'proto'}->{$attr}; + } + return undef; + } + return $obj->[$adhocIndex]->{$attr}; + } +} + +sub fastGet { + # this method can be used if the attribute is guaranteed to be in + # the attributes array for the object's class + my ($self, $obj, $attr) = @_; + return $obj->[$self->{'fields'}->[$obj->[0]]->{$attr}]; +} + +sub set { + my ($self, $obj, $attr, $val) = @_; + my $fields = $self->{'fields'}->[$obj->[0]]; + if (defined($fields) && defined($fields->{$attr})) { + $obj->[$fields->{$attr}] = $val; + } else { + my $cls = $self->{'classes'}->[$obj->[0]]; + return unless defined($cls); + my $adhocIndex = $#{$cls->{'attributes'}} + 2; + if ($adhocIndex > $#{$obj}) { + $obj->[$adhocIndex] = {} + } + $obj->[$adhocIndex]->{$attr} = $val; + } +} + +sub fastSet { + # this method can be used if the attribute is guaranteed to be in + # the attributes array for the object's class + my ($self, $obj, $attr, $val) = @_; + $obj->[$self->{'fields'}->[$obj->[0]]->{$attr}] = $val; +} + +sub makeSetter { + my ($self, $attr) = @_; + return sub { + my ($obj, $val) = @_; + $self->set($obj, $attr, $val); + }; +} + +sub makeGetter { + my ($self, $attr) = @_; + return sub { + my ($obj) = @_; + return $self->get($obj, $attr); + }; +} + +sub makeFastSetter { + # this method can be used if the attribute is guaranteed to be in + # the attributes array for the object's class + my ($self, $attr) = @_; + my $indices = $self->attrIndices($attr); + return sub { + my ($obj, $val) = @_; + if (defined($indices->[$obj->[0]])) { + $obj->[$indices->[$obj->[0]]] = $val; + } else { + # report error? + } + }; +} + +sub makeFastGetter { + # this method can be used if the attribute is guaranteed to be in + # the attributes array for the object's class + my ($self, $attr) = (@_); + my $indices = $self->attrIndices($attr); + croak "no attribute '$attr' found in representation" unless grep defined, @$indices; + return sub { + my ($obj) = @_; + if ( defined $obj && defined $obj->[0] && defined $indices->[ $obj->[0] ] ) { + return $obj->[$indices->[$obj->[0]]]; + } else { + # report error? + return undef; + } + }; +} + +sub construct { + my ($self, $dict, $cls) = @_; + my $result = []; + foreach my $key (keys %$dict) { + $self->set($result, $key, $dict->{$key}); + } + return $result; +} + +1; diff --git a/Bio/FeatureIO/bed.pm b/Bio/FeatureIO/bed.pm new file mode 100644 index 00000000..062b18d6 --- /dev/null +++ b/Bio/FeatureIO/bed.pm @@ -0,0 +1,373 @@ +=pod + +=head1 NAME + +Bio::FeatureIO::bed - read/write features from UCSC BED format + +=head1 SYNOPSIS + + my $in = Bio::FeatureIO(-format => 'bed', -file => 'file.bed'); + for my $feat ($in->next_feature) { + # do something with $feat (a Bio::SeqFeature::Annotated object) + } + + my $out = Bio::FeatureIO(-format=>'bed'); + for my $feat ($seq->get_seqFeatures) { + $out->write_feature($feat); + } + +=head1 DESCRIPTION + +See L. + +Currently for read and write only the first 6 fields (chr, start, end, name, +score, strand) are supported. + +=head1 FEEDBACK + +=head2 Mailing Lists + +User feedback is an integral part of the evolution of this and other +Bioperl modules. Send your comments and suggestions preferably to +the Bioperl mailing list. Your participation is much appreciated. + + bioperl-l@bioperl.org - General discussion + http://bioperl.org/wiki/Mailing_lists - About the mailing lists + +=head2 Support + +Please direct usage questions or support issues to the mailing list: + +L + +rather than to the module maintainer directly. Many experienced and +reponsive experts will be able look at the problem and quickly +address it. Please include a thorough description of the problem +with code and data examples if at all possible. + +=head2 Reporting Bugs + +Report bugs to the Bioperl bug tracking system to help us keep track +of the bugs and their resolution. Bug reports can be submitted via +the web: + + http://bugzilla.open-bio.org/ + +=head1 AUTHOR - Allen Day + +Email allenday@ucla.edu + +=head1 CONTRIBUTORS + +Sendu Bala, bix@sendu.me.uk + +=head1 APPENDIX + +The rest of the documentation details each of the object methods. +Internal methods are usually preceded with a _ + +=cut + + +# Let the code begin... + + +package Bio::FeatureIO::bed; + +use strict; +use base qw(Bio::FeatureIO); +use Bio::SeqFeature::Annotated; +use Bio::Annotation::SimpleValue; +use Bio::OntologyIO; +use Scalar::Util qw(looks_like_number); +use List::Util qw(min max); + +=head2 _initialize + + Title : _initialize + Function: initializes BED for reading/writing + Args : all optional: + name description + ---------------------------------------------------------- + -name the name for the BED track, stored in header + name defaults to localtime() + -description the description for the BED track, stored in + header. defaults to localtime(). + -use_score whether or not the score attribute of + features should be used when rendering them. + the higher the score the darker the color. + defaults to 0 (false) + -thin_type feature type of thin subfeature blocks. + defaults to "UTR" + -thick_type feature type of thick subfeature blocks + defaults to "CDS" + + +=cut + +sub _initialize { + my($self,%arg) = @_; + + $self->SUPER::_initialize(%arg); + + $self->name($arg{-name} || scalar(localtime())); + $self->description($arg{-description} || scalar(localtime())); + $self->use_score($arg{-use_score} || 0); + $self->thin_type($arg{-thin_type} || "UTR"); + $self->thick_type($arg{-thick_type} || "CDS"); + + $self->_print(sprintf('track name="%s" description="%s" useScore=%d', + $self->name, + $self->description, + $self->use_score ? 1 : 0 + )."\n") if $self->mode eq 'w'; +} + +=head2 use_score + + Title : use_score + Usage : $obj->use_score($newval) + Function: should score be used to adjust feature color when rendering? set to true if so. + Example : + Returns : value of use_score (a scalar) + Args : on set, new value (a scalar or undef, optional) + + +=cut + +sub use_score{ + my $self = shift; + + return $self->{'use_score'} = shift if @_; + return $self->{'use_score'}; +} + +=head2 name + + Title : name + Usage : $obj->name($newval) + Function: name of BED track + Example : + Returns : value of name (a scalar) + Args : on set, new value (a scalar or undef, optional) + + +=cut + +sub name{ + my $self = shift; + + return $self->{'name'} = shift if @_; + return $self->{'name'}; +} + +=head2 description + + Title : description + Usage : $obj->description($newval) + Function: description of BED track + Example : + Returns : value of description (a scalar) + Args : on set, new value (a scalar or undef, optional) + + +=cut + +sub description{ + my $self = shift; + + return $self->{'description'} = shift if @_; + return $self->{'description'}; +} + +=head2 thin_type + + Title : thin_type + Usage : $obj->thin_type($newval) + Function: feature type for subfeature blocks + Example : $obj->thin_type("UTR") + Returns : value of thin_type (a string) + Args : on set, new value (a string or undef, optional) + + +=cut + +sub thin_type { + my $self = shift; + + return $self->{'thin_type'} = shift if @_; + return $self->{'thin_type'}; +} + +=head2 thick_type + + Title : thick_type + Usage : $obj->thick_type($newval) + Function: feature type for thick subfeature blocks + Example : $obj->thick_type("CDS") + Returns : value of thick_type (a string) + Args : on set, new value (a string or undef, optional) + + +=cut + +sub thick_type { + my $self = shift; + + return $self->{'thick_type'} = shift if @_; + return $self->{'thick_type'}; +} + +sub write_feature { + my($self,$feature) = @_; + $self->throw("only Bio::SeqFeature::Annotated objects are writeable") unless $feature->isa('Bio::SeqFeature::Annotated'); + + my $chrom = $feature->seq_id || ''; + my $chrom_start = $feature->start || 0; # output start is supposed to be 0-based + my $chrom_end = ($feature->end + 1) || 1; # output end is supposed to not be part of the feature + + #try to make a reasonable name + my $name = undef; + my @v; + if (@v = ($feature->annotation->get_Annotations('Name'))){ + $name = $v[0]; + $self->warn("only using first of feature's multiple names: ".join ',', map {$_->value} @v) if scalar(@v) > 1; + } elsif (@v = ($feature->annotation->get_Annotations('ID'))){ + $name = $v[0]; + $self->warn("only using first of feature's multiple IDs: ".join ',', map {$_->value} @v) if scalar(@v) > 1; + } else { + $name = 'anonymous'; + } + + if (ref($name)) { + $name = $name->value; + } + if (ref($chrom)) { + $chrom = $chrom->value; + } + + my $score = $feature->score || 0; + my $strand = $feature->strand == 0 ? '-' : '+'; #default to + + + my @bedline = ($chrom,$chrom_start,$chrom_end,$name,$score,$strand); + + my @subfeatures; + if (@subfeatures = $feature->get_SeqFeatures()) { + my @thin_features = grep { $_->primary_tag eq $self->thin_type } @subfeatures; + my @thick_features = grep { $_->primary_tag eq $self->thick_type } @subfeatures; + if (@thick_features) { + #thick start + push @bedline, min(map { $_->start } @thick_features); + #thick end + push @bedline, max(map { $_->end } @thick_features) + 1; + } else { + push @bedline, $feature->start; + push @bedline, $feature->end; + } + my @block_features = sort {$a->start <=> $b->start} (@thin_features, @thick_features); + if (@block_features) { + #item RGB + push @bedline, 0; + #block count + push @bedline, $#block_features + 1; + #block sizes + push @bedline, + join(",", map { $_->end - $_->start + 1 } @block_features) . ","; + #block starts + push @bedline, + join(",", map { $_->start - $feature->start } @block_features) . ","; + } + } + + $self->_print(join("\t", @bedline)."\n"); +} + +sub next_feature { + my $self = shift; + my $line = $self->_readline || return; + + my ($seq_id, $start, $end, $name, $score, $strand, + $thick_start, $thick_end, $item_rgb, $block_count, + $block_sizes, $block_starts) = split(/\s+/, $line); + $strand ||= '+'; + + unless (looks_like_number($start) && looks_like_number($end)) { + # skip what is probably a header line + return $self->next_feature; + } + + my $feature = Bio::SeqFeature::Annotated->new(-start => $start + 1, # start is 0 based + -end => $end, # end is not part of the feature + ($score ne "") ? (-score => $score) : (), + $strand ? (-strand => $strand eq '+' ? 1 : -1) : ()); + + $feature->seq_id($seq_id); + if ($name) { + my $sv = Bio::Annotation::SimpleValue->new(-tagname => 'Name', -value => $name); + $feature->annotation->add_Annotation($sv); + $feature->name($name); + } + + if (defined($thick_start) && $thick_start ne "") { + my $parent_strand = $strand ? ($strand eq '+' ? 1 : -1) : 0; + + if ($block_count > 0) { + my @length_list = split(",", $block_sizes); + my @offset_list = split(",", $block_starts); + + if (($block_count != ($#length_list + 1)) + || ($block_count != ($#offset_list + 1)) ) { + warn "expected $block_count blocks, got " . ($#length_list + 1) . " lengths and " . ($#offset_list + 1) . " offsets for feature " . ($name ? $name : "$seq_id:$start..$end"); + } else { + for (my $i = 0; $i < $block_count; $i++) { + #block start and end, in absolute (sequence rather than feature) + #coords. These are still in interbase. + my $abs_block_start = $start + $offset_list[$i]; + my $abs_block_end = $abs_block_start + $length_list[$i]; + + #add a thin subfeature if this block extends left of the thick zone + if ($abs_block_start < $thick_start) { + $feature->add_SeqFeature( + Bio::SeqFeature::Generic->new( + -start => $abs_block_start + 1, + -end => min($thick_start, $abs_block_end), + -strand => $parent_strand, + -primary_tag => $self->thin_type) ); + } + + #add a thick subfeature if this block overlaps the thick zone + if (($abs_block_start < $thick_end) + && ($abs_block_end > $thick_start)) { + $feature->add_SeqFeature( + Bio::SeqFeature::Generic->new( + -start => max($thick_start, $abs_block_start) + 1, + -end => min($thick_end, $abs_block_end), + -strand => $parent_strand, + -primary_tag => $self->thick_type) ); + } + + #add a thin subfeature if this block extends right of the thick zone + if ($abs_block_end > $thick_end) { + $feature->add_SeqFeature( + Bio::SeqFeature::Generic->new( + -start => max($abs_block_start, $thick_end) + 1, + -end => $abs_block_end, + -strand => $parent_strand, + -primary_tag => $self->thin_type) ); + } + } + } + } else { + $feature->add_SeqFeature( + Bio::SeqFeature::Generic->new( + -start => $thick_start + 1, + -end => $thick_end, + -strand => $parent_strand, + -primary_tag => $self->thick_type) ); + } + } + + return $feature; +} + +1; diff --git a/Bio/JBrowse/Cmd.pm b/Bio/JBrowse/Cmd.pm new file mode 100644 index 00000000..b7ebea4c --- /dev/null +++ b/Bio/JBrowse/Cmd.pm @@ -0,0 +1,56 @@ +package Bio::JBrowse::Cmd; +use strict; +use warnings; + +use Getopt::Long (); +use Pod::Usage (); + +=head1 NAME + +Script - base class for a JBrowse command-line script + +=head1 DESCRIPTION + +This wheel is smaller than the ones on CPAN, but not really rounder. + +=cut + +sub new { + my $class = shift; + my $opts = $class->getopts(@_); + return bless { opt => $opts }, $class; +} + +sub getopts { + my $class = shift; + my $opts = { + $class->option_defaults, + }; + local @ARGV = @_; + Getopt::Long::GetOptions( $opts, $class->option_definitions ); + Pod::Usage::pod2usage( -verbose => 2 ) if $opts->{help}; + return $opts; +} + +sub opt { + if( @_ > 2 ) { + return $_[0]->{opt}{$_[1]} = $_[2]; + } else { + return $_[0]->{opt}{$_[1]} + } +} + +#override me +sub option_defaults { + ( ) +} + +#override me +sub option_definitions { + ( "help|h|?" ) +} + +sub run { +} + +1; diff --git a/Bio/JBrowse/Cmd/BioDBToJson.pm b/Bio/JBrowse/Cmd/BioDBToJson.pm new file mode 100644 index 00000000..2f4e5b4e --- /dev/null +++ b/Bio/JBrowse/Cmd/BioDBToJson.pm @@ -0,0 +1,161 @@ +package Bio::JBrowse::Cmd::BioDBToJson; +use strict; +use warnings; + +use GenomeDB; + +use base 'Bio::JBrowse::Cmd::NCFormatter'; + +use Data::Dumper (); +use Pod::Usage (); +use Bio::JBrowse::JSON; + +use Bio::JBrowse::FeatureStream::BioPerl; + +sub option_defaults { + ( out => 'data', + cssClass => 'feature', + sortMem => 1024 * 1024 * 512, + nclChunk => 50_000 + ) +} + +sub option_definitions {( + "conf=s", + "ref=s", + "refid=s", + "track=s", + "out=s", + "nclChunk=i", + "compress", + "sortMem=i", + "verbose|v+", + "quiet|q", + "help|?|h", +)} + + +sub run { + my ( $self ) = @_; + + my $verbose = $self->opt('verbose'); + my $quiet = $self->opt('quiet'); + + # quadruple the ncl chunk size if compressing + if( $self->opt('compress') ) { + $self->opt('nclChunk', $self->opt('nclChunk') * 4 ); + } + + Pod::Usage::pod2usage( 'must provide a --conf argument' ) unless defined $self->opt('conf'); + + my $gdb = GenomeDB->new( $self->opt('out') ); + + # determine which reference sequences we'll be operating on + my @refSeqs = @{ $gdb->refSeqs }; + if ( my $refid = $self->opt('refid') ) { + @refSeqs = grep { $_->{id} eq $refid } @refSeqs; + die "Didn't find a refseq with ID $refid (have you run prepare-refseqs.pl to supply information about your reference sequences?)" if $#refSeqs < 0; + } elsif ( my $ref = $self->opt('ref') ) { + @refSeqs = grep { $_->{name} eq $ref } @refSeqs; + die "Didn't find a refseq with name $ref (have you run prepare-refseqs.pl to supply information about your reference sequences?)" if $#refSeqs < 0; + } + @refSeqs or die "run prepare-refseqs.pl first to supply information about your reference sequences"; + + # read our conf file + -r $self->opt('conf') or die "conf file not found or not readable"; + my $config = Bio::JBrowse::JSON->new->decode_file( $self->opt('conf') ); + + # open and configure the db defined in the config file + eval "require $config->{db_adaptor}; 1" or die $@; + my $db = eval {$config->{db_adaptor}->new(%{$config->{db_args}})} or warn $@; + die "Could not open database: $@" unless $db; + if (my $refclass = $config->{'reference class'}) { + eval {$db->default_class($refclass)}; + } + $db->strict_bounds_checking(1) if $db->can('strict_bounds_checking'); + $db->absolute(1) if $db->can('absolute'); + + foreach my $seg (@refSeqs) { + my $segName = $seg->{name}; + print "\nworking on refseq $segName\n" unless $quiet; + + # get the list of tracks we'll be operating on + my @tracks = defined $self->opt('track') + ? grep { $_->{"track"} eq $self->opt('track') } @{$config->{tracks}} + : @{$config->{tracks}}; + + foreach my $trackCfg ( @tracks ) { + my $trackLabel = $trackCfg->{'track'}; + print "working on track $trackLabel\n" unless $quiet; + + my $mergedTrackCfg = $self->assemble_track_config( + $config, + { key => $trackLabel, + %$trackCfg, + compress => $self->opt('compress') ? 1 : 0, + }, + ); + + print "mergedTrackCfg: " . Data::Dumper::Dumper( $mergedTrackCfg ) if $verbose && !$quiet; + + my @feature_types = @{$trackCfg->{"feature"}}; + next unless @feature_types; + + print "searching for features of type: " . join(", ", @feature_types) . "\n" if $verbose && !$quiet; + # get the stream of the right features from the Bio::DB + my $db_stream = $db->get_seq_stream( -seq_id => $segName, + -type => \@feature_types); + + my $feature_stream = Bio::JBrowse::FeatureStream::BioPerl->new( + stream => sub { $db_stream->next_seq }, + track_label => $trackLabel + ); + + $self->_format( trackConfig => $mergedTrackCfg, + featureStream => $feature_stream, + trackLabel => $trackLabel, + ); + } + } +} + +sub assemble_track_config { + my ( $self, $global_config, $track_config ) = @_; + + # merge the config + my %cfg = ( + %{$global_config->{"TRACK DEFAULTS"}}, + %$track_config + ); + + # rename some of the config variables + my %renamed_keys = qw( + class className + subfeature_classes subfeatureClasses + urlTemplate linkTemplate + ); + for ( keys %cfg ) { + if( my $new_keyname = $renamed_keys{ $_ } ) { + $cfg{ $new_keyname } = delete $cfg{ $_ }; + } + } + + # move some of the config variables to a nested 'style' hash + my %style_keys = map { $_ => 1 } qw( + subfeatureClasses + arrowheadClass + className + histCss + featureCss + linkTemplate + ); + for ( keys %cfg ) { + if( $style_keys{$_} ) { + $cfg{style}{$_} = delete $cfg{$_}; + } + } + + return \%cfg; +} + +1; diff --git a/Bio/JBrowse/Cmd/FlatFileToJson.pm b/Bio/JBrowse/Cmd/FlatFileToJson.pm new file mode 100644 index 00000000..1c056b3a --- /dev/null +++ b/Bio/JBrowse/Cmd/FlatFileToJson.pm @@ -0,0 +1,239 @@ +#!/usr/bin/env perl + +=head1 NAME + +Script::FlatfileToJson - implementation of bin/flatfile-to-json.pl + +=head1 DESCRIPTION + +Do C for most of the documentation. + +=cut + +package Bio::JBrowse::Cmd::FlatFileToJson; + +use strict; +use warnings; + +use base 'Bio::JBrowse::Cmd::NCFormatter'; + +use Bio::JBrowse::JSON; + +sub option_defaults { + ( type => [], + out => 'data', + cssClass => 'feature', + sortMem => 1024 * 1024 * 512, + ) +} + +sub option_definitions { + ( + "gff=s", + "bed=s", + "gbk=s", + "bam=s", + "out=s", + "trackLabel=s", + "trackType=s", + "key=s", + "cssClass|className=s", + "autocomplete=s", + "getType", + "getPhase", + "getSubs|getSubfeatures", + "noSubfeatures", + "getLabel", + "urltemplate=s", + "menuTemplate=s", + "arrowheadClass=s", + "subfeatureClasses=s", + "clientConfig=s", + "thinType=s", + "thickType=s", + "type=s@", + "nclChunk=i", + "compress", + "sortMem=i", + "help|h|?", + ) +} + +sub run { + my ( $self ) = @_; + + Pod::Usage::pod2usage( "Must provide a --trackLabel parameter." ) unless defined $self->opt('trackLabel'); + unless( defined $self->opt('gff') || + defined $self->opt('bed') || + defined $self->opt('gbk') || + defined $self->opt('bam') + ) { + Pod::Usage::pod2usage( "You must supply either a --gff or --bed or --gbk parameter." ) + } + + $self->opt('bam') and die "BAM support has been moved to a separate program: bam-to-json.pl\n"; + + if( ! $self->opt('nclChunk') ) { + # default chunk size is 50KiB + my $nclChunk = 50000; + # $nclChunk is the uncompressed size, so we can make it bigger if + # we're compressing + $nclChunk *= 4 if $self->opt('compress'); + $self->opt( nclChunk => $nclChunk ); + } + + for my $optname ( qw( clientConfig subfeatureClasses ) ) { + if( my $o = $self->opt($optname) ) { + $self->opt( $optname => Bio::JBrowse::JSON->new->decode( $o )); + } + } + + + my %config = ( + trackType => $self->opt('trackType'), + style => { + %{ $self->opt('clientConfig') || {} }, + className => $self->opt('cssClass'), + ( $self->opt('urltemplate') ? ( linkTemplate => $self->opt('urltemplate') ) : () ), + ( $self->opt('arrowheadClass') ? ( arrowheadClass => $self->opt('arrowheadClass') ) : () ), + ( $self->opt('subfeatureClasses') ? ( subfeatureClasses => $self->opt('subfeatureClasses') ) : () ), + }, + ( $self->opt('menuTemplate') ? ( menuTemplate => $self->opt('menuTemplate') ) : () ), + key => defined( $self->opt('key') ) ? $self->opt('key') : $self->opt('trackLabel'), + compress => $self->opt('compress'), + ); + + my $feature_stream = $self->opt('gff') ? $self->make_gff_stream : + $self->opt('bed') ? $self->make_bed_stream : + $self->opt('gbk') ? $self->make_gbk_stream : + die "Please specify --gff or --bed or --gbk.\n"; + + # build a filtering subroutine for the features + my $types = $self->opt('type'); + @$types = split /,/, join ',', @$types; + my $filter = $self->make_feature_filter( $types ); + + $self->_format( trackConfig => \%config, + featureStream => $feature_stream, + featureFilter => $filter, + trackLabel => $self->opt('trackLabel') + ); + + return 0; +} + + +sub make_gff_stream { + my $self = shift; + + require Bio::GFF3::LowLevel::Parser; + require Bio::JBrowse::FeatureStream::GFF3_LowLevel; + + my $p = Bio::GFF3::LowLevel::Parser->new( $self->opt('gff') ); + + return Bio::JBrowse::FeatureStream::GFF3_LowLevel->new( + parser => $p, + no_subfeatures => $self->opt('noSubfeatures'), + track_label => $self->opt('trackLabel') + ); +} + +sub make_bed_stream { + my ( $self ) = @_; + + require Bio::FeatureIO; + require Bio::JBrowse::FeatureStream::BioPerl; + + my $io = Bio::FeatureIO->new( + -format => 'bed', + -file => $self->opt('bed'), + ($self->opt('thinType') ? ("-thin_type" => $self->opt('thinType') ) : ()), + ($self->opt('thickType') ? ("-thick_type" => $self->opt('thickType')) : ()), + ); + + return Bio::JBrowse::FeatureStream::BioPerl->new( + no_subfeatures => $self->opt('noSubfeatures'), + stream => sub { $io->next_feature }, + track_label => $self->opt('trackLabel'), + ); +} + +sub make_gbk_stream { + my $self = shift; + + require Bio::JBrowse::FeatureStream::Genbank::Parser; + require Bio::JBrowse::FeatureStream::Genbank; + + my $parser = Bio::JBrowse::FeatureStream::Genbank::Parser->new; + $parser->file( $self->opt('gbk') ); + + return Bio::JBrowse::FeatureStream::Genbank->new( + parser => $parser, + track_label => $self->opt('trackLabel') + ); + +} + +sub make_feature_filter { + my ( $self, $types ) = @_; + + my @filters; + + # add a filter for type:source if --type was specified + if( $types && @$types ) { + my @type_regexes = map { + my $t = $_; + $t .= ":.*" unless $t =~ /:/; + qr/^$t$/ + } @$types; + + push @filters, sub { + no warnings 'uninitialized'; + my ($f) = @_; + my $type = $f->{type} + or return 0; + my $source = $f->{source}; + my $t_s = "$type:$source"; + for( @type_regexes ) { + return 1 if $t_s =~ $_; + } + return 0; + }; + } + + # if no filtering, just return a pass-through now. + return sub { @_ } unless @filters; + + # make a sub that tells whether a single feature passes + my $pass_feature = sub { + my ($f) = @_; + $_->($f) || return 0 for @filters; + return 1; + }; + + # Apply this filtering rule through the whole feature hierarchy, + # returning features that pass. If a given feature passes, return + # it *and* all of its subfeatures, with no further filtering + # applied to the subfeatures. If a given feature does NOT pass, + # search its subfeatures to see if they do. + return sub { + _find_passing_features( $pass_feature, @_ ); + } +}; + +# given a subref that says whether an individual feature passes, +# return the LIST of features among the whole feature hierarchy that +# pass the filtering rule +sub _find_passing_features { + my $pass_feature = shift; + return map { + my $feature = $_; + $pass_feature->( $feature ) + # if this feature passes, we're done, just return it + ? ( $feature ) + # otherwise, look for passing features in its subfeatures + : _find_passing_features( $pass_feature, @{$feature->{subfeatures}} ); + } @_; +} + +1; diff --git a/Bio/JBrowse/Cmd/FormatSequences.pm b/Bio/JBrowse/Cmd/FormatSequences.pm new file mode 100644 index 00000000..abb61410 --- /dev/null +++ b/Bio/JBrowse/Cmd/FormatSequences.pm @@ -0,0 +1,406 @@ +package Bio::JBrowse::Cmd::FormatSequences; + +=head1 NAME + +Bio::JBrowse::Cmd::FormatSequences - script module to format reference +sequences (backend module for prepare-refseqs.pl) + +=cut + +use strict; +use warnings; + +use base 'Bio::JBrowse::Cmd'; +use Pod::Usage (); + +use File::Spec::Functions qw/ catfile catdir /; +use File::Path 'mkpath'; + +use POSIX; + +use Bio::JBrowse::JSON; +use JsonFileStorage; +use FastaDatabase; + +sub option_defaults {( + out => 'data', + chunksize => 20_000, + seqType => 'DNA' +)} + +sub option_definitions {( + "out=s", + "conf=s", + "noseq", + "gff=s", + "chunksize=s", + "fasta=s@", + "sizes=s@", + "refs=s", + "refids=s", + "reftypes=s", + "compress", + "trackLabel=s", + "seqType=s", + "key=s", + "help|h|?", + "nohash" +)} + +sub run { + my ( $self ) = @_; + + my $compress = $self->opt('compress'); + + $self->{storage} = JsonFileStorage->new( $self->opt('out'), $self->opt('compress'), { pretty => 0 } ); + + Pod::Usage::pod2usage( 'must provide either a --fasta, --sizes, --gff, or --conf option' ) + unless $self->opt('gff') || $self->opt('conf') || $self->opt('fasta') || $self->opt('sizes'); + + { + my $chunkSize = $self->opt('chunksize'); + $chunkSize *= 4 if $compress; + $self->{chunkSize} = $chunkSize; + } + + my $refs = $self->opt('refs'); + + if ( $self->opt('fasta') && @{$self->opt('fasta')} ) { + my $db = FastaDatabase->from_fasta( @{$self->opt('fasta')}); + + die "IDs not implemented for FASTA database" if defined $self->opt('refids'); + + if ( ! defined $refs && ! defined $self->opt('refids') ) { + $refs = join (",", $db->seq_ids); + } + + die "found no sequences in FASTA file" if "" eq $refs; + + $self->exportDB( $db, $refs, {} ); + $self->writeTrackEntry(); + #$self->exportFASTAFiles( $refs, $self->opt('fasta') ); + } + elsif ( $self->opt('gff') ) { + my $db; + my $gff = $self->opt('gff'); + my $gzip = ''; + if( $gff =~ /\.gz$/ ) { + require PerlIO::gzip; + $gzip = ':gzip'; + } + open my $fh, "<$gzip", $gff or die "$! reading GFF file $gff"; + my %refSeqs; + while ( <$fh> ) { + if ( /^\#\#\s*sequence-region\s+(\S+)\s+(-?\d+)\s+(-?\d+)/i ) { # header line + $refSeqs{$1} = { + name => $1, + start => $2 - 1, + end => int($3), + length => ($3 - $2 + 1) + }; + } + elsif( /^##FASTA\s*$/ ) { + # start of the sequence block, pass the filehandle to our fasta database + $db = FastaDatabase->from_fasta( $fh ); + last; + } + elsif( /^>/ ) { + # beginning of implicit sequence block, need to seek + # back + seek $fh, -length($_), SEEK_CUR; + $db = FastaDatabase->from_fasta( $fh ); + last; + } + } + if ( $db && ! defined $refs && ! defined $self->opt('refids') ) { + $refs = join (",", $db->seq_ids); + } + + $self->exportDB( $db, $refs, \%refSeqs ); + $self->writeTrackEntry(); + + } elsif ( $self->opt('conf') ) { + my $config = Bio::JBrowse::JSON->new->decode_file( $self->opt('conf') ); + + eval "require $config->{db_adaptor}; 1" or die $@; + + my $db = eval {$config->{db_adaptor}->new(%{$config->{db_args}})} + or warn $@; + + die "Could not open database: $@" unless $db; + + if (my $refclass = $config->{'reference class'}) { + eval {$db->default_class($refclass)}; + } + $db->strict_bounds_checking(1) if $db->can('strict_bounds_checking'); + + $self->exportDB( $db, $refs, {} ); + $self->writeTrackEntry(); + } + elsif( $self->opt('sizes') ) { + + my %refseqs; + for my $sizefile ( @{$self->opt('sizes')} ) { + open my $f, '<', $sizefile or warn "$! opening file $sizefile, skipping"; + next unless $f; + while( my $line = <$f> ) { + next unless $line =~ /\S/; + chomp $line; + my ( $name, $length ) = split /\s+/,$line,2; + s/^\s+|\s+$//g for $name, $length; + + $refseqs{$name} = { + name => $name, + start => 0, + end => $length, + length => $length + }; + } + } + + $self->writeRefSeqsJSON( \%refseqs ); + } +} + +sub trackLabel { + my ( $self ) = @_; + + # use --trackLabel if given + return $self->opt('trackLabel') if $self->opt('trackLabel'); + + # otherwise construct from seqType. uppercasing in case it is + # also used as the human-readable name + my $st = $self->opt('seqType'); + if( $st =~ /^[dr]na$/i ) { + return uc $st; + } + + return lc $st; +} + +sub exportDB { + my ( $self, $db, $refs, $refseqs ) = @_; + + my $compress = $self->opt('compress'); + my %refSeqs = %$refseqs; + my %exportedRefSeqs; + + my @queries; + + if ( defined $self->opt('refids') ) { + for my $refid (split ",", $self->opt('refids')) { + push @queries, [ -db_id => $refid ]; + } + } + if ( defined $refs ) { + for my $ref (split ",", $refs) { + push @queries, [ -name => $ref ]; + } + } + if( my $reftypes = $self->opt('reftypes') ) { + push @queries, [ -type => [ split /[\s,]+/, $reftypes ] ]; + } + + my $refCount = 0; + for my $query ( @queries ) { + my @segments = $db->features( @$query ); + + unless( @segments ) { + warn "WARNING: Reference sequence with @$query not found in input.\n"; + next; + } + + for my $seg ( @segments ) { + + my $refInfo = { + name => $self->refName($seg), + start => $seg->start - 1, + end => $seg->end, + length => $seg->length + }; + + if ( $refSeqs{ $refInfo->{name} } ) { + warn "WARNING: multiple reference sequences found named '$refInfo->{name}', using only the first one.\n"; + } else { + $refSeqs{ $refInfo->{name} } = $refInfo; + } + + unless( $self->opt('noseq') || $exportedRefSeqs{ $refInfo->{name} }++ ) { + $self->exportSeqChunksFromDB( $refInfo, $self->{chunkSize}, $db, + [ -name => $refInfo->{name} ], + $seg->start, $seg->end); + $refSeqs{ $refInfo->{name}}{seqChunkSize} = $self->{chunkSize}; + } + } + } + + unless( %refSeqs ) { + warn "No reference sequences found, exiting.\n"; + exit; + } + + $self->writeRefSeqsJSON( \%refSeqs ); +} + +sub writeRefSeqsJSON { + my ( $self, $refseqs ) = @_; + + mkpath( File::Spec->catdir($self->{storage}{outDir},'seq') ); + + $self->{storage}->modify( 'seq/refSeqs.json', + sub { + #add new ref seqs while keeping the order + #of the existing ref seqs + my $old = shift || []; + my %refs = %$refseqs; + for (my $i = 0; $i < @$old; $i++) { + if( $refs{$old->[$i]->{name}} ) { + $old->[$i] = delete $refs{$old->[$i]->{name}}; + } + } + foreach my $name (sort keys %refs) { + push @{$old}, $refs{$name}; + } + return $old; + }); + + if ( $self->opt('compress') ) { + # if we are compressing the sequence files, drop a .htaccess file + # in the seq/ dir that will automatically configure users with + # Apache (and AllowOverride on) to serve the .txt.gz files + # correctly + require GenomeDB; + my $hta = catfile( $self->opt('out'), 'seq', '.htaccess' ); + open my $hta_fh, '>', $hta or die "$! writing $hta"; + $hta_fh->print( GenomeDB->precompression_htaccess('.txtz','.jsonz') ); + } +} + +sub writeTrackEntry { + my ( $self ) = @_; + + my $compress = $self->opt('compress'); + + my $seqTrackName = $self->trackLabel; + unless( $self->opt('noseq') ) { + $self->{storage}->modify( 'trackList.json', + sub { + my $trackList = shift; + unless (defined($trackList)) { + $trackList = + { + 'formatVersion' => 1, + 'tracks' => [] + }; + } + my $tracks = $trackList->{'tracks'}; + my $i; + for ($i = 0; $i <= $#{$tracks}; $i++) { + last if ($tracks->[$i]->{'label'} + eq + $seqTrackName); + } + $tracks->[$i] = + { + 'label' => $seqTrackName, + 'key' => $self->opt('key') || 'Reference sequence', + 'type' => "SequenceTrack", + 'storeClass' => 'JBrowse/Store/Sequence/StaticChunked', + 'pinned' => 1, + 'chunkSize' => $self->{chunkSize}, + 'urlTemplate' => $self->seqUrlTemplate, + ( $compress ? ( 'compress' => 1 ): () ), + ( 'dna' eq lc $self->opt('seqType') ? () : ('showReverseStrand' => 0 ) ) + }; + return $trackList; + }); + } + + return; +} + +########################### + +sub refName { + my ( $self, $seg ) = @_; + my $segName = $seg->name; + $segName = $seg->{'uniquename'} if $seg->{'uniquename'}; + $segName =~ s/:.*$//; #get rid of coords if any + return $segName; +} + +sub openChunkFile { + my ( $self, $refInfo, $chunkNum ) = @_; + + my $compress = $self->opt('compress'); + + my ( $dir, $file ) = $self->opt('nohash') + # old style + ? ( catdir( $self->opt('out'), 'seq', + $refInfo->{name} + ), + "$chunkNum.txt" + ) + # new hashed structure + : ( catdir( $self->opt('out'), 'seq', + $self->_crc32_path( $refInfo->{name} ) + ), + "$refInfo->{name}-$chunkNum.txt" + ); + + $file .= 'z' if $compress; + + mkpath( $dir ); + open my $fh, '>'.($compress ? ':gzip' : ''), catfile( $dir, $file ) + or die "$! writing $file"; + return $fh; +} + +sub _crc32_path { + my ( $self, $str ) = @_; + my $crc = ( $self->{crc} ||= do { require Digest::Crc32; Digest::Crc32->new } ) + ->strcrc32( $str ); + my $hex = lc sprintf( '%08x', $crc ); + return catdir( $hex =~ /(.{1,3})/g ); +} + +sub seqUrlTemplate { + my ( $self ) = @_; + return $self->opt('nohash') + ? "seq/{refseq}/" # old style + : "seq/{refseq_dirpath}/{refseq}-"; # new hashed structure +} + + +sub exportSeqChunksFromDB { + my ( $self, $refInfo, $chunkSize, $db, $segDef, $start, $end ) = @_; + + $start = 1 if $start < 1; + $db->absolute( 1 ) if $db->can('absolute'); + + my $chunkStart = $start; + while( $chunkStart <= $end ) { + my $chunkEnd = $chunkStart + $chunkSize - 1; + $chunkEnd = $end if $chunkEnd > $end; + my $chunkNum = floor( ($chunkStart - 1) / $chunkSize ); + my ($seg) = $db->segment( @$segDef, + -start => $chunkStart, + -end => $chunkEnd, + -absolute => 1, + ); + unless( $seg ) { + die "Seq export query failed, please inform the developers of this error" + } + + $seg->start == $chunkStart + or die "requested $chunkStart .. $chunkEnd; got " . $seg->start . " .. " . $seg->end; + + $chunkStart = $chunkEnd + 1; + next unless $seg && $seg->seq && $seg->seq->seq; + + $self->openChunkFile( $refInfo, $chunkNum ) + ->print( $seg->seq->seq ); + } +} + +1; diff --git a/Bio/JBrowse/Cmd/NCFormatter.pm b/Bio/JBrowse/Cmd/NCFormatter.pm new file mode 100644 index 00000000..7cf33ce8 --- /dev/null +++ b/Bio/JBrowse/Cmd/NCFormatter.pm @@ -0,0 +1,96 @@ +package Bio::JBrowse::Cmd::NCFormatter; + +use base 'Bio::JBrowse::Cmd'; + +use GenomeDB; +use Bio::JBrowse::ExternalSorter; + +sub _format { + my ( $self, %args ) = @_; + my ( $trackLabel, $trackConfig, $feature_stream, $filter ) = @args{qw{ trackLabel trackConfig featureStream featureFilter }}; + $filter ||= sub { @_ }; + + my $types = $self->opt('type'); + @$types = split /,/, join ',', @$types; + + # The Bio::JBrowse::ExternalSorter will get flattened [chrom, [start, end, ...]] + # arrays from the feature_stream + my $sorter = Bio::JBrowse::ExternalSorter->new( + do { + my $startIndex = $feature_stream->startIndex; + my $endIndex = $feature_stream->endIndex; + sub ($$) { + $_[0]->[0] cmp $_[1]->[0] + || + $_[0]->[1]->[$startIndex] <=> $_[1]->[1]->[$startIndex] + || + $_[1]->[1]->[$endIndex] <=> $_[0]->[1]->[$endIndex]; + } + }, + $self->opt('sortMem'), + ); + + my %featureCounts; + while ( my @feats = $feature_stream->next_items ) { + + for my $feat ( $filter->( @feats ) ) { + my $chrom = $feat->{seq_id}; + $featureCounts{$chrom} += 1; + + $feat = $self->transform_feature( $feat ); + + my $row = [ $chrom, + $feature_stream->flatten_to_feature( $feat ), + $feature_stream->flatten_to_name( $feat ), + ]; + $sorter->add( $row ); + } + } + $sorter->finish(); + + ################################ + + my $gdb = GenomeDB->new( $self->opt('out') ); + + my $track = $gdb->getTrack( $trackLabel, { %$trackConfig, type => 'FeatureTrack' }, $trackConfig->{key} ) + || $gdb->createFeatureTrack( $trackLabel, + $trackConfig, + $trackConfig->{key}, + ); + + my $curChrom = 'NONE YET'; + my $totalMatches = 0; + while( my $feat = $sorter->get ) { + + use Storable (); + unless( $curChrom eq $feat->[0] ) { + $curChrom = $feat->[0]; + $track->finishLoad; #< does nothing if no load happening + $track->startLoad( $curChrom, + $self->opt('nclChunk'), + Storable::dclone( $feature_stream->arrayReprClasses ), + ); + } + $totalMatches++; + $track->addSorted( $feat->[1] ); + + # load the feature's name record into the track if necessary + if( my $namerec = $feat->[2] ) { + $track->nameHandler->addName( $namerec ); + } + } + + $gdb->writeTrackEntry( $track ); + + # If no features are found, check for mistakes in user input + if( !$totalMatches && @$types ) { + warn "WARNING: No matching features found for @$types\n"; + } +} + +# stub +sub transform_feature { + return $_[1]; +} + +1; diff --git a/Bio/JBrowse/Cmd/RemoveTrack.pm b/Bio/JBrowse/Cmd/RemoveTrack.pm new file mode 100644 index 00000000..eec96e63 --- /dev/null +++ b/Bio/JBrowse/Cmd/RemoveTrack.pm @@ -0,0 +1,96 @@ +=head1 NAME + +Script::RemoveTrack - implemention of bin/remove-track.pl + +=head1 DESCRIPTION + +Do C to see usage documentation. + +=cut + +package Bio::JBrowse::Cmd::RemoveTrack; +use strict; +use warnings; + +use File::Path (); +use File::Spec; + +use JsonFileStorage; +use base 'Bio::JBrowse::Cmd'; + +sub option_defaults { + ( dir => 'data/' ) +} + +sub option_definitions { + ( + shift->SUPER::option_definitions, + 'dir|out=s', + 'quiet|q', + 'delete|D', + 'trackLabel=s@', + ); +} + +sub run { + my ( $self ) = @_; + for my $label (@{ $self->opt('trackLabel') || []}) { + $self->delete_track( $label ); + } +} + +sub delete_track { + my ( $self, $trackLabel ) = @_; + + my $deleted_conf; + + # remove the track configuration and print it + JsonFileStorage->new( $self->opt('dir'), 0, { pretty => 1 }) + ->modify( 'trackList.json', sub { + my ( $json ) = @_; + $json or die "The trackList.json file in ".$self->opt('dir')." could not be read.\n"; + $json->{tracks} = [ + map { + if( $_->{label} eq $trackLabel ) { + # print the json + $self->print( "removing track configuration:\n".JSON->new->encode( $_ ) ); + $deleted_conf = $_; + () + } else { + $_ + } + } + @{$json->{tracks} || []} + ]; + return $json; + }); + + if( ! $deleted_conf ) { + $self->print( "No track found with label $trackLabel" ); + return; + } + + if( $self->opt('delete') ) { + # delete the track data + $self->print( "Deleting track data for $trackLabel" ); + my @trackdata_paths = ( + File::Spec->catdir( $self->opt('dir'), 'tracks', $deleted_conf->{label} || die ), + ); + if( !@trackdata_paths ) { + $self->print( "Unable to automatically remove track data for $trackLabel (type '$deleted_conf->{type}'). Please remove it manually." ); + } else { + $self->print( "Deleting: @trackdata_paths" ); + File::Path::rmtree( \@trackdata_paths ); + } + } else { + $self->print( "--delete not specified; not deleting data directory for $trackLabel" ); + } +} + +sub print { + my $self = shift; + print( @_, "\n" ) unless $self->opt('quiet'); + return; +} + +1; diff --git a/Bio/JBrowse/ExternalSorter.pm b/Bio/JBrowse/ExternalSorter.pm new file mode 100644 index 00000000..f19e32bd --- /dev/null +++ b/Bio/JBrowse/ExternalSorter.pm @@ -0,0 +1,195 @@ +=head1 NAME + +Bio::JBrowse::ExternalSorter - efficiently sort serializable items with a given comparison function + +=head1 SYNOPSIS + + # make a new sorter that sorts arrayrefs by column 4, then column 3 + my $sorter = Bio::JBrowse::ExternalSorter->new( + sub ($$) { + $_[0]->[4] <=> $_[1]->[4] + || + $_[1]->[3] <=> $_[0]->[3]; + }, $sortMem); + + for my $arrayref ( @arrayrefs ) { + $sorter->add( $arrayref ); + } + + # finalize sort + $sorter->finish; + + # iterate through the sorted arrayrefs + while( my $arrayref = $sorter->get ) { + + } + +=head1 METHODS + +=cut + + +package Bio::JBrowse::ExternalSorter; + +use strict; +use warnings; + +use Carp; +use PerlIO::gzip; +use Storable qw(store_fd fd_retrieve); +use Devel::Size qw(size total_size); +use Heap::Simple; +use File::Temp; + +=head1 new( \&comparison, $ramInBytes, $tmpDir ) + +Make a new sorter using the given comparison function, using at most +$ramInBytes bytes of RAM. Optionally, can also pass $tmpDir, a path +to the temporary directory to use for intermediate files. + +The comparison function must have a ($$) prototype. + +=cut + +sub new { + my ($class, $compare, $ram, $tmpDir) = @_; + my $self = { + tmpDir => $tmpDir, + compare => $compare, + ram => $ram, + segments => [], + curList => [], + curSize => 0, + finished => 0 + }; + bless $self, $class; + return $self; +} + +=head1 add( $item ) + +Add a new item to the sort buffer. + +=cut + +sub add { + my ($self, $item) = @_; + $self->{curSize} += total_size($item); + push @{$self->{curList}}, $item; + if ($self->{curSize} >= $self->{ram}) { + $self->flush(); + } +} + +=head1 finish() + +Call when all items have been added. Finalizes the sort. + +=cut + +sub finish { + my ($self) = @_; + my $compare = $self->{compare}; + if ($#{$self->{segments}} >= 0) { + $self->flush(); + my @unzipFiles = + map { + my $zip; + open $zip, "<:gzip", $_ + or croak "couldn't open $_: $!\n"; + unlink $_ + or croak "couldn't unlink $_: $!\n"; + $zip; + } @{$self->{segments}}; + my $readSegments = + Heap::Simple->new(order => sub {$compare->($_[0], $_[1]) < 0}, + elements => "Any"); + foreach my $fh (@unzipFiles) { + $readSegments->key_insert(readOne($fh), $fh); + } + $self->{readSegments} = $readSegments; + } else { + @{$self->{curList}} = sort $compare @{$self->{curList}}; + } + $self->{finished} = 1; +} + +=head1 flush() + +Write a sorted version of the list to temporary storage. + +=cut + +sub flush { + my ($self) = @_; + my $compare = $self->{compare}; + my @sorted = sort $compare @{$self->{curList}}; + + # each segment must have at least one element + return if ($#sorted < 0); + croak "Bio::JBrowse::ExternalSorter is already finished" + if $self->{finished}; + + my $fh = File::Temp->new( $self->{tmpDir} ? (DIR => $self->{tmpDir}) : (), + SUFFIX => '.sort', + UNLINK => 0 ) + or croak "couldn't open temp file: $!\n"; + my $fn = $fh->filename; + $fh->close() + or croak "couldn't close temp file: $!\n"; + open $fh, ">:gzip", $fn + or croak "couldn't reopen $fn: $!\n"; + foreach my $item (@sorted) { + store_fd($item, $fh) + or croak "couldn't write item: $!\n"; + } + $fh->flush() + or croak "couldn't flush segment file: $!\n"; + $fh->close() + or croak "couldn't close $fn: $!\n"; + push @{$self->{segments}}, "$fn"; + $self->{curList} = []; + $self->{curSize} = 0; +} + +# get one item from the big list +sub get { + my ($self) = @_; + croak "External sort not finished\n" + unless $self->{finished}; + if ($#{$self->{segments}} >= 0) { + my $item = $self->{readSegments}->first_key(); + my $fh = $self->{readSegments}->extract_first(); + # if we're out of items, return undef + if (!defined($fh)) { return undef; } + my $newItem = readOne($fh); + if (defined($newItem)) { + $self->{readSegments}->key_insert($newItem, $fh); + } + return $item; + } else { + return shift @{$self->{curList}}; + } +} + +# read one item from a file handle +sub readOne { + my ($fh) = @_; + if ($fh->eof()) { + $fh->close(); + return undef; + } + my $item = fd_retrieve($fh) + or croak "couldn't retrieve item: $!\n"; + return $item; +} + +sub DESTROY { + shift->cleanup(); +} + +sub cleanup { + unlink $_ for @{shift->{segments}||[]} +} + +1; diff --git a/Bio/JBrowse/FeatureStream.pm b/Bio/JBrowse/FeatureStream.pm new file mode 100644 index 00000000..6c6dd73a --- /dev/null +++ b/Bio/JBrowse/FeatureStream.pm @@ -0,0 +1,134 @@ +=head1 NAME + +FeatureStream - base class for feature streams +used for handling features inside FlatFileToJson.pm + +=cut + +package Bio::JBrowse::FeatureStream; +use strict; +use warnings; + +use List::MoreUtils 'uniq'; + +sub new { + my $class = shift; + + my $self = bless { + @_, + class_count => 0 + }, $class; + + return $self; +} + +sub flatten_to_feature { + my ( $self, $f ) = @_; + my $class = $self->_get_class( $f ); + + my @f = ( $class->{index}, + @{$f}{ @{$class->{fields}} } + ); + + unless( $self->{no_subfeatures} ) { + for my $subfeature_field (qw( subfeatures derived_features )) { + if ( my $sfi = $class->{field_idx}{ $subfeature_field } ) { + $f[ $sfi+1 ] = [ + map { + $self->flatten_to_feature($_) + } @{$f[$sfi+1]} + ]; + } + } + } + + # use Data::Dump 'dump'; + # print dump($_)."\n" for \@f, $class; + + # convert start to interbase and numify it + $f[1] -= 1; + # numify end + $f[2] += 0; + # convert strand to 1/0/-1/undef if necessary, and numify it + no warnings 'uninitialized'; + $f[3] = { '+' => 1, '-' => -1 }->{$f[3]} || $f[3] || undef; + $f[3] += 0; + return \@f; +} + +my %skip_field = map { $_ => 1 } qw( start end strand ); +sub _get_class { + my ( $self, $f ) = @_; + + my @attrs = keys %$f; + my $attr_fingerprint = join '-', @attrs; + + return $self->{classes}{$attr_fingerprint} ||= do { + my @fields = ( 'start', 'end', 'strand', ( grep !$skip_field{$_}, @attrs ) ); + my $i = 0; + { + index => $self->{class_count}++, # the classes start from 1. so what. + fields => \@fields, + field_idx => { map { $_ => $i++ } @fields }, + # assumes that if a field is an array for one feature, it will be for all of them + array_fields => [ grep ref($f->{$_}) eq 'ARRAY', @attrs ] + } + }; +} + +sub flatten_to_name { + my ( $self, $f ) = @_; + my @nameattrs = grep /^(name|id|alias)\d*$/, keys %$f; + my @namerec = ( + [ grep defined, @{$f}{@nameattrs} ], + $self->{track_label}, + $f->{name} || $f->{id} || $f->{alias}, + $f->{seq_id} || die, + (map $_+0, @{$f}{'start','end'}) + ); + $namerec[4]--; #< to zero-based + return \@namerec; +} +sub arrayReprClasses { + my ( $self ) = @_; + return [ + map { + attributes => [ map ucfirst, @{$_->{fields}} ], + isArrayAttr => { map { ucfirst($_) => 1 } @{$_->{array_fields}} }, + }, + sort { $a->{index} <=> $b->{index} } + values %{ $self->{classes} } + ]; +} + +sub startIndex { 1 } +sub endIndex { 2 } + + +my %must_flatten = + map { $_ => 1 } + qw( name id start end score strand description note ); +# given a hashref like { tagname => [ value1, value2 ], ... } +# flatten it to numbered tagnames like { tagname => value1, tagname2 => value2 } +sub _flatten_multivalues { + my ( $self, $h ) = @_; + my %flattened; + + for my $key ( keys %$h ) { + my $v = $h->{$key}; + if( @$v == 1 ) { + $flattened{ $key } = $v->[0]; + } + elsif( $must_flatten{ lc $key } ) { + for( my $i = 0; $i < @$v; $i++ ) { + $flattened{ $key.($i ? $i+1 : '')} = $v->[$i]; + } + } else { + $flattened{ $key } = $v; + } + } + + return \%flattened; +} + +1; diff --git a/Bio/JBrowse/FeatureStream/BioPerl.pm b/Bio/JBrowse/FeatureStream/BioPerl.pm new file mode 100644 index 00000000..2d8a6698 --- /dev/null +++ b/Bio/JBrowse/FeatureStream/BioPerl.pm @@ -0,0 +1,92 @@ +=head1 NAME + +Script::FlatFileToJson::FeatureStream::BioPerl - feature stream class +for working with BioPerl seqfeature objects + +=cut + +package Bio::JBrowse::FeatureStream::BioPerl; +use strict; +use warnings; +use base 'Bio::JBrowse::FeatureStream'; + +use List::MoreUtils 'uniq'; + +sub next_items { + my ( $self ) = @_; + return map $self->_bp_to_hashref( $_ ), + $self->{stream}->(); +} + +# downconvert a bioperl feature object back to bare-hashref-format +sub _bp_to_hashref { + my ( $self, $f ) = @_; + no warnings 'uninitialized'; + + my %h; + @h{qw{ seq_id start end strand source phase type }} = + ( $f->seq_id, + $f->start, + $f->end, + $f->strand, + $f->source_tag, + {0=>0,1=>1,2=>2}->{$f->phase}, + $f->primary_tag || undef, + ); + for(qw( seq_id start end strand source type )) { + if( $h{$_} eq '.' ) { + delete $h{$_}; + } + } + for ( keys %h ) { + if( ! defined $h{$_} ) { + delete $h{$_}; + } else { + $h{$_} = [ $h{$_} ]; + } + } + my @subfeatures = $f->get_SeqFeatures; + if( @subfeatures ) { + $h{subfeatures} = [[ map $self->_bp_to_hashref($_), @subfeatures ]]; + } + + for my $tag ( $f->get_all_tags ) { + my $lctag = lc $tag; + push @{ $h{ $lctag } ||= [] }, $f->get_tag_values($tag); + } + + for ( keys %h ) { + $h{$_} = [ uniq grep { defined && ($_ ne '.') } @{$h{$_}} ]; + unless( @{$h{$_}} ) { + delete $h{$_}; + } + } + + if( ! $h{name} and defined( my $label = $self->_label( $f ) )) { + $h{name} = [ $label ]; + } + + return $self->_flatten_multivalues( \%h ); +}; + +sub _label { + my ( $self, $f ) = @_; + if( $f->can('display_name') and defined( my $dn = $f->display_name )) { + return $dn + } + elsif( $f->can('get_tag_values') ) { + my $n = eval { ($f->get_tag_values('Name'))[0] }; + return $n if defined $n; + + my $a = eval { ($f->get_tag_values('Alias'))[0] }; + return $a if defined $a; + } + elsif( $f->can('attributes') ) { + return $f->attributes('load_id') if defined $f->attributes('load_id'); + return $f->attributes('Name') if defined $f->attributes('Name'); + return $f->attributes('Alias') if defined $f->attributes('Alias'); + } + return; +} + +1; diff --git a/Bio/JBrowse/FeatureStream/GFF3_LowLevel.pm b/Bio/JBrowse/FeatureStream/GFF3_LowLevel.pm new file mode 100644 index 00000000..6fa897bd --- /dev/null +++ b/Bio/JBrowse/FeatureStream/GFF3_LowLevel.pm @@ -0,0 +1,74 @@ +=head1 NAME + +Script::FlatFileToJson::FeatureStream::GFF3_LowLevel - feature stream +class for working with L features + +=cut + +package Bio::JBrowse::FeatureStream::GFF3_LowLevel; +use strict; +use warnings; + +use base 'Bio::JBrowse::FeatureStream'; +use URI::Escape qw(uri_unescape); + +sub next_items { + my ( $self ) = @_; + while ( my $i = $self->{parser}->next_item ) { + return $self->_to_hashref( $i ) if $i->{child_features}; + } + return; +} + +sub _to_hashref { + my ( $self, $f ) = @_; + # use Data::Dump 'dump'; + # if( ref $f ne 'HASH' ) { + # Carp::confess( dump $f ); + # } + $f = { %$f }; + $f->{score} += 0 if defined $f->{score}; + $f->{phase} += 0 if defined $f->{phase}; + + my $a = delete $f->{attributes}; + my %h; + for my $key ( keys %$f) { + my $lck = lc $key; + my $v = $f->{$key}; + if( defined $v && ( ref($v) ne 'ARRAY' || @$v ) ) { + unshift @{ $h{ $lck } ||= [] }, $v; + } + } + + # rename child_features to subfeatures + if( $h{child_features} ) { + $h{subfeatures} = [ + map { + [ map $self->_to_hashref( $_ ), @$_ ] + } @{delete $h{child_features}} + ]; + } + if( $h{derived_features} ) { + $h{derived_features} = [ + map { + [ map $self->_to_hashref( $_ ), @$_ ] + } @{$h{derived_features}} + ]; + } + + my %skip_attributes = ( Parent => 1 ); + for my $key ( sort keys %{ $a || {} } ) { + my $lck = lc $key; + if( !$skip_attributes{$key} ) { + # push @{ $h{$lck} ||= [] }, @{$a->{$key}}; + push @{ $h{$lck} ||= [] }, uri_unescape("@{$a->{$key}}"); + } + } + + my $flat = $self->_flatten_multivalues( \%h ); + return $flat; +} + + + +1; diff --git a/Bio/JBrowse/FeatureStream/Genbank.pm b/Bio/JBrowse/FeatureStream/Genbank.pm new file mode 100644 index 00000000..0fbe3552 --- /dev/null +++ b/Bio/JBrowse/FeatureStream/Genbank.pm @@ -0,0 +1,140 @@ +=head1 NAME + +Script::FlatFileToJson::FeatureStream::Genbank - feature stream +class for working with Genbank features + +=cut + +package Bio::JBrowse::FeatureStream::Genbank; +use strict; +use warnings; + +use base 'Bio::JBrowse::FeatureStream'; + +use Bio::JBrowse::FeatureStream::Genbank::LocationParser; + +sub next_items { + my ( $self ) = @_; + while ( my $i = $self->{parser}->next_seq ) { + return $self->_aggregate_features_from_gbk_record( $i ); + } + return; +} + +sub _aggregate_features_from_gbk_record { + my ( $self, $record ) = @_; + + # see if this is a region record, and if so, make a note of offset + # so we can add it to coordinates below + my $offset = _getRegionOffset( $record ); + + # get index of top level feature ('mRNA' at current writing) + my $indexTopLevel; + my $count = 0; + foreach my $feat ( @{$record->{FEATURES}} ){ + if ( _isTopLevel( $feat ) ){ + $indexTopLevel = $count; + } + $count++; + } + + return unless defined $indexTopLevel; + + # start at top level, make feature and subfeature for all subsequent features + # this logic assumes that top level feature is above all subfeatures + + # set start/stop + my @locations = sort { $a->{start} <=> $b->{start} } _parseLocation( $record->{FEATURES}->[$indexTopLevel]->{location} || '' ); + + my $f = { %$record, %{$locations[0]} }; + delete $f->{FEATURES}; + my $seq_id = $f->{seq_id} = $f->{VERSION} ? ( $f->{VERSION}[0] =~ /REGION/ ? $f->{VERSION}[2] : $f->{VERSION}[0]) + : $f->{ACCESSION}; + delete $f->{ORIGIN}; + delete $f->{SEQUENCE}; + + $f->{end} = $locations[-1]{end}; + #for my $f ( @features ) { + $f->{start} += $offset + 1; + $f->{end} += $offset; + $f->{strand} = 1 unless defined $f->{strand}; + $f->{type} = $record->{FEATURES}[$indexTopLevel]{name}; + $f->{seq_id} ||= $seq_id; + + %$f = ( %{$record->{FEATURES}[$indexTopLevel]{feature} || {}}, %$f ); # get other attrs + if( $f->{type} eq 'mRNA' ) { + $f->{name} = $record->{FEATURES}[$indexTopLevel]{feature}{gene}; + $f->{description} = $record->{FEATURES}[$indexTopLevel]{feature}{product} || $f->{FEATURES}[$indexTopLevel]{feature}{note}; + } + + # convert FEATURES to subfeatures + $f->{subfeatures} = []; + if ( scalar( @{$record->{FEATURES} || [] }) > $indexTopLevel ) { + for my $i ( $indexTopLevel + 1 .. $#{$record->{FEATURES}} ) { + my $feature = $record->{FEATURES}[$i]; + my @sublocations = _parseLocation( $feature->{location} ); + for my $subloc ( @sublocations ) { + $subloc->{start} += $offset + 1; + $subloc->{end} += $offset; + + my $newFeature = { + %{ $feature->{feature}||{} }, + %$subloc, + type => $feature->{name} + }; + + $newFeature->{seq_id} ||= $seq_id; + + push @{$f->{subfeatures}}, $newFeature; + } + } + } +# } + + return $f; +} + +sub _isTopLevel { + my $feat = shift; + my @topLevelFeatures = qw( mRNA ); # add more as needed? + my $isTopLevel = 0; + foreach my $thisTopFeat ( @topLevelFeatures ){ + if ( $feat->{'name'} =~ m/$thisTopFeat/ ){ + $isTopLevel = 1; + last; + } + } + return $isTopLevel; +} + +sub _parseLocation { + return @{ Bio::JBrowse::FeatureStream::Genbank::LocationParser->parse( $_[0] ) }; +} + +sub _getRegionOffset { + + my $f = shift; + my $offset = 0; + if ( grep {$_ =~ /REGION\:/} @{$f->{'VERSION'}} ){ # this is a region file + # get array item after REGION token + my $count = 0; + my $regionIndexInArray; + foreach my $item ( @{$f->{'VERSION'}} ){ + if ( $item =~ /REGION\:/ ){ + $regionIndexInArray = $count; + last; + } + $count++; + } + if ( defined $regionIndexInArray ){ + my ($start, $end) = split(/\.\./, @{$f->{'VERSION'}}[ $regionIndexInArray + 1]); + if ( defined $start ){ + $start -= 1 if ( $start > 0 ); + $offset = $start; + } + } + } +} + + +1; diff --git a/Bio/JBrowse/FeatureStream/Genbank/LocationParser.pm b/Bio/JBrowse/FeatureStream/Genbank/LocationParser.pm new file mode 100644 index 00000000..002bc547 --- /dev/null +++ b/Bio/JBrowse/FeatureStream/Genbank/LocationParser.pm @@ -0,0 +1,53 @@ +package Bio::JBrowse::FeatureStream::Genbank::LocationParser; +use strict; +use warnings; + +use Parse::RecDescent; + +my $p = Parse::RecDescent->new( <<'EOG' ); + +location_expression: join | complement | order | location | + +complement: 'complement' '(' location_expression ')' + { + $_->{strand} = -($_->{strand}||1) for @{$item[3]}; + $return = [ reverse @{$item[3]} ]; + } + +order: 'order' '(' loc_list ')' + { $return = $item[3] } + +join: 'join' '(' loc_list ')' + { $return = $item[3] } + +loc_list: + { $return = [ map @$_, map @$_, @item[1..$#item] ] } + +location: range | point_range | point + +range: point ".." point + { $return = [{ seq_id => $item[1][0]{seq_id}, start => $item[1][0]{start}, end => $item[3][0]{start} }] } + +point_range: point "." point + { + my $loc = sprintf('%d',($item[1][0]{start}+$item[3][0]{start})/2 ); + $return = [{ seq_id => $item[1][0]{seq_id}, start => $loc, end => $loc }]; + } + +point: local_point | remote_point + +local_point: /[<>]?(\d+)/ + { $return = [{ start => $1, end => $1 }] } + +remote_point: /[<>]?([^\(\),:]+):(\d+)/ + { $return = [{ seq_id => $1, start => $2, end => $2 }] } + + +EOG + +sub parse { + my ( $class, $expr ) = @_; + return $p->location_expression( $expr ); +} + +1; diff --git a/Bio/JBrowse/FeatureStream/Genbank/Parser.pm b/Bio/JBrowse/FeatureStream/Genbank/Parser.pm new file mode 100644 index 00000000..a6b180fc --- /dev/null +++ b/Bio/JBrowse/FeatureStream/Genbank/Parser.pm @@ -0,0 +1,625 @@ +package Bio::JBrowse::FeatureStream::Genbank::Parser; + +use warnings; +use strict; +use Carp qw( croak ); +use File::Spec::Functions; +use Parse::RecDescent; + +my $GENBANK_RECORD_SEPARATOR = "//\n"; + +=pod + +=head1 NAME + +Bio::JBrowse::FeatureStream::Genbank::Parser - fork of kyclark's Bio::GenBankParser + +=cut + +=head1 SYNOPSIS + +This module aims to improve on the BioPerl GenBank parser by using +a grammar-based approach with Parse::RecDescent. + + use Bio::GenBankParser; + + my $parser = Bio::GenBankParser->new(); + + local $/ = "//\n"; + while ( my $rec = <$input> ) { + my $gb_rec = $parser->parse( $rec ); + } + +Or: + + my $parser = Bio::GenBankParser->new( file => $file ); + while ( my $seq = $parser->next_seq ) { + ... + } + +=head1 METHODS + +=cut + +# ---------------------------------------------------------------- +sub new { + +=pod + +=head2 new + + use Bio::GenBankParser; + my $parser = Bio::GenBankParser->new; + +=cut + + my $class = shift; + my %args = ( @_ && ref $_[0] eq 'HASH' ) ? %{ $_[0] } : @_; + my $self = bless \%args, $class; + + if ( $args{'file'} ) { + $self->file( $args{'file'} ); + } + + return $self; +} + +# ---------------------------------------------------------------- +sub DESTROY { + my $self = shift; + + if ( my $fh = $self->{'fh'} ) { + close $fh; + } +} + +# ---------------------------------------------------------------- +sub file { + +=pod + +=head2 file + + $parser->file('/path/to/file'); + +Informs the parser to read sequentially from a file. + +=cut + + my $self = shift; + + if ( my $file = shift ) { + $file = canonpath( $file ); + + if ( -e $file && -s _ && -r _ ) { + open my $fh, '<', $file or croak("Can't read file '$file'; $!\n"); + + $self->{'file'} = $file; + $self->{'fh'} = $fh; + } + else { + croak("Non-existent, empty or unreadable file: '$file'"); + } + } + + return 1; +} + +# ---------------------------------------------------------------- +sub current_record { + +=pod + +=head2 current_record + + my $genbank_record = $parser->current_record; + +Returns the current unparsed GenBank record. + +=cut + + my $self = shift; + + return $self->{'current_record'}; +} + +# ---------------------------------------------------------------- +sub next_seq { + +=pod + +=head2 next_seq + + my $seq = $parser->next_seq; + +Returns the next sequence from the C. + +=cut + + my $self = shift; + + if ( my $fh = $self->{'fh'} ) { + local $/ = $GENBANK_RECORD_SEPARATOR; + + my $rec; + for (;;) { + $rec = <$fh>; + last if !defined $rec || $rec =~ /\S+/; + } + + if ( defined $rec && $rec =~ /\S+/ ) { + # okay, parsing of coordinate info is broken because someone at Genbank decided to split join() coordinates across >1 line. + # so, let's hack this to work by removing the newline inside of join() tokens + $rec =~ s/(^.*join\(.*\,)\n\s+(\d+.*\)$)/${1}${2}/mg; + return $self->parse( $rec ); + } + else { + return undef; + } + } + else { + croak("Can't call 'next_seq' without a 'file' argument"); + } +} + +# ---------------------------------------------------------------- +sub parse { + +=pod + +=head2 parse + + my $rec = $parser->parse( $text ); + print $rec->{'ACCESSION'}; + +Parses a (single) GenBank record into a hash reference. + +=cut + + my $self = shift; + my $text = shift() or croak('No input to parse'); + my $parser = $self->parser or croak('No parser'); + + $self->{'current_record'} = $text; + + return $parser->startrule( $text ); +} + +# ---------------------------------------------------------------- +sub parser { + +=pod + +=head2 parser + +Returns the Parse::RecDescent object. + +=cut + + my $self = shift; + + if ( !defined $self->{'parser'} ) { + my $grammar = $self->grammar or croak('No grammar'); + $self->{'parser'} = Parse::RecDescent->new( $grammar ); + } + + return $self->{'parser'}; +} + +# ---------------------------------------------------------------- +sub grammar { + +=pod + +=head2 grammar + +Returns the Parse::RecDescent grammar for a GenBank record. + +=cut + + my $self = shift; + return <<'END_OF_GRAMMAR'; +{ + my $ref_num = 1; + my %record = (); + my %ATTRIBUTE_PROMOTE = map { $_, 1 } qw[ + mol_type + cultivar + variety + strain + ]; + + $::RD_ERRORS; # report fatal errors +# $::RD_TRACE = 0; +# $::RD_WARN = 0; # Enable warnings. This will warn on unused rules &c. +# $::RD_HINT = 0; # Give out hints to help fix problems. +} + +startrule: section(s) eofile + { + if ( !$record{'ACCESSION'} ) { + $record{'ACCESSION'} = $record{'LOCUS'}->{'genbank_accession'}; + } + + if ( ref $record{'SEQUENCE'} eq 'ARRAY' ) { + $record{'SEQUENCE'} = join('', @{ $record{'SEQUENCE'} }); + } + + $return = { %record }; + %record = (); + } + | + +section: commented_line + | header + | locus + | dbsource + | definition + | accession_line + | project_line + | version_line + | keywords + | source_line + | organism + | reference + | features + | base_count + | contig + | origin + | comment + | record_delimiter + | accession + | primary + | source + | version + | + +header: /.+(?=\nLOCUS)/xms + +locus: /LOCUS/xms locus_name sequence_length molecule_type + genbank_division(?) modification_date + { + $record{'LOCUS'} = { + locus_name => $item{'locus_name'}, + sequence_length => $item{'sequence_length'}, + molecule_type => $item{'molecule_type'}, + genbank_division => $item{'genbank_division(?)'}[0], + modification_date => $item{'modification_date'}, + } + } + +locus_name: /\w+/ + +space: /\s+/ + +sequence_length: /\d+/ /(aa|bp)/ { $return = "$item[1] $item[2]" } + +molecule_type: /\w+/ (/[a-zA-Z]{4,}/)(?) + { + $return = join(' ', map { $_ || () } $item[1], $item[2][0] ) + } + +genbank_division: + /(PRI|CON|ROD|MAM|VRT|INV|PLN|BCT|VRL|PHG|SYN|UNA|EST|PAT|STS|GSS|HTG|HTC|ENV)/ + +modification_date: /\d+-[A-Z]{3}-\d{4}/ + +definition: /DEFINITION/ section_continuing_indented + { + ( $record{'DEFINITION'} = $item[2] ) =~ s/\n\s+/ /g; + } + +source: /SOURCE/ section_continuing_indented + { + ( $record{'SOURCE'} = $item[2] ) =~ s/\n\s+/ /g; + } + +section_continuing_indented: /.*?(?=\n[A-Z]+\s+)/xms + +section_continuing_indented: /.*?(?=\n\/\/)/xms + +accession_line: /ACCESSION/ section_continuing_indented + { + my @accs = split /\s+/, $item[2]; + $record{'ACCESSION'} = shift @accs; + push @{ $record{'VERSION'} }, @accs; + } + +accession: /ACCESSION/ section_continuing_indented + { + my @accs = split /\s+/, $item[2]; + $record{'ACCESSION'} = shift @accs; + if ( exists $item[3] ){ + $record{'REGION'} = $item[3]; + if ( my ($start, $end) = split(/\.\./, $item[3]) ){ + $record{'REGION_START'} = $start; + $record{'REGION_END'} = $end; + } + } + push @{ $record{'VERSION'} }, @accs; + } + +version_line: /VERSION/ /(.+)(?=\n)/ + { + push @{ $record{'VERSION'} }, split /\s+/, $item[2]; + } + +version: /VERSION/ /(.+)(?=\n)/ + { + push @{ $record{'VERSION'} }, split /\s+/, $item[2]; + } + +project_line: /PROJECT/ section_continuing_indented + { + $record{'PROJECT'} = $item[2]; + } + +keywords: /KEYWORDS/ keyword_value + { + $record{'KEYWORDS'} = $item[2]; + } + +keyword_value: section_continuing_indented + { + ( my $str = $item[1] ) =~ s/\.$//; + $return = [ split(/,\s*/, $str ) ]; + } + | PERIOD { $return = [] } + +dbsource: /DBSOURCE/ /\w+/ /[^\n]+/xms + { + push @{ $record{'DBSOURCE'} }, { + $item[2], $item[3] + }; + } + +source_line: /SOURCE/ source_value + { + ( my $src = $item[2] ) =~ s/\.$//; + $src =~ s/\bsp$/sp./; + $record{'SOURCE'} = $src; + } + +source_value: /(.+?)(?=\n\s{0,2}[A-Z]+)/xms { $return = $1 } + +organism: organism_line classification_line + { + $record{'ORGANISM'} = $item[1]; + $record{'CLASSIFICATION'} = $item[2]; + } + +organism_line: /ORGANISM/ organism_value { $return = $item[2] } + +organism_value: /([^\n]+)(?=\n)/xms { $return = $1 } + +classification_line: /([^.]+)[.]/xms { $return = [ split(/;\s*/, $1) ] } + +word: /\w+/ + +reference: /REFERENCE/ NUMBER(?) parenthetical_phrase(?) authors(?) consrtm(?) title journal remark(?) pubmed(?) remark(?) + { + my $num = $item[2][0] || $ref_num++; + my $remark = join(' ', map { $_ || () } $item[8][0], $item[10][0]); + $remark = undef if $remark !~ /\S+/; + + push @{ $record{'REFERENCES'} }, { + number => $num, + authors => $item{'authors(?)'}[0], + title => $item{'title'}, + journal => $item{'journal'}, + pubmed => $item[9][0], + note => $item[3][0], + remark => $remark, + consrtm => $item[5][0], + }; + + } + +parenthetical_phrase: /\( ([^)]+) \)/xms + { + $return = $1; + } + +authors: /AUTHORS/ author_value { $return = $item[2] } + +author_value: /(.+?)(?=\n\s{0,2}[A-Z]+)/xms + { + $return = [ + grep { !/and/ } + map { s/,$//; $_ } + split /\s+/, $1 + ]; + } + +title: /TITLE/ /.*?(?=\n\s{0,2}[A-Z]+)/xms + { ( $return = $item[2] ) =~ s/\n\s+/ /; } + +journal: /JOURNAL/ journal_value + { + $return = $item[2] + } + +journal_value: /(.+)(?=\n\s{3}PUBMED)/xms + { + $return = $1; + $return =~ s/\n\s+/ /g; + } + | /(.+?)(?=\n\s{0,2}[A-Z]+)/xms + { + $return = $1; + $return =~ s/\n\s+/ /g; + } + +pubmed: /PUBMED/ NUMBER + { $return = $item[2] } + +remark: /REMARK/ section_continuing_indented + { $return = $item[2] } + +consrtm: /CONSRTM/ /[^\n]+/xms { $return = $item[2] } + +features: /FEATURES/ section_continuing_indented + { + my ( $location, $cur_feature_name, %cur_features, $cur_key ); + for my $fline ( split(/\n/, $item[2]) ) { + next if $fline =~ m{^\s*Location/Qualifiers}; + next if $fline !~ /\S+/; + + if ( $fline =~ /^\s{21}\/ (\w+?) = (.+)$/xms ) { + my ( $key, $value ) = ( $1, $2 ); + $value =~ s/^"|"$//g; + $cur_key = $key; + $cur_features{ $key } = $value; + + if ( $key eq 'db_xref' && $value =~ /^taxon:(\d+)$/ ) { + $record{'NCBI_TAXON_ID'} = $1; + } + + if ( $ATTRIBUTE_PROMOTE{ $key } ) { + $record{ uc $key } = $value; + } + } + elsif ( $fline =~ /^\s{5}(\S+) \s+ (.+)$/xms ) { + my ( $this_feature_name, $this_location ) = ( $1, $2 ); + + if ( $cur_feature_name ) { + push @{ $record{'FEATURES'} }, { + name => $cur_feature_name, + location => delete $cur_features{location}, + feature => { %cur_features }, + }; + + %cur_features = (); + } + + $cur_key = 'location'; + $cur_features{location} = $this_location; + $cur_feature_name = $this_feature_name; + } + elsif ( $fline =~ /^\s{21}([^"]+)["]?$/ ) { + if ( $cur_key ) { + $cur_features{ $cur_key } .= + $cur_key eq 'translation' + ? $1 + : ' ' . $1; + } + } + } + + push @{ $record{'FEATURES'} }, { + name => $cur_feature_name, + location => delete $cur_features{location}, + feature => { %cur_features }, + }; + } + +base_count: /BASE COUNT/ base_summary(s) + { + for my $sum ( @{ $item[2] } ) { + $record{'BASE_COUNT'}{ $sum->[0] } = $sum->[1]; + } + } + +base_summary: /\d+/ /[a-zA-Z]+/ + { + $return = [ $item[2], $item[1] ]; + } + +origin: /ORIGIN/ origin_value + { + $record{'ORIGIN'} = $item[2] + } + +origin_value: /(.*?)(?=\n\/\/)/xms + { + my $seq = $1; + $record{'SEQUENCE'} = []; + while ( $seq =~ /([actg]+)/gc ) { + push @{ $record{'SEQUENCE'} }, $1; + } + + $return = $seq; + } + +comment: /COMMENT/ comment_value + +comment_value: /(.+?)(?=\n[A-Z]+)/xms + { + $record{'COMMENT'} = $1; + } + +contig: /CONTIG/ section_continuing_indented + { + $record{'CONTIG'} = $item[2]; + } + +commented_line: /#[^\n]+/ + +NUMBER: /\d+/ + +PERIOD: /\./ + +record_delimiter: /\/\/\s*/xms + +primary: /.*/ + +eofile: /^\Z/ + +END_OF_GRAMMAR +} + +# ---------------------------------------------------------------- +=pod + +=head1 AUTHOR + +Ken Youens-Clark Ekclark at cpan.orgE. + +=head1 BUGS + +Please report any bugs or feature requests to C, or through the web interface at +L. +I will be notified, and then you'll automatically be notified of +progress on your bug as I make changes. + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc Bio::GenBankParser + +=over 4 + +=item * RT: CPAN's request tracker + +L + +=item * AnnoCPAN: Annotated CPAN documentation + +L + +=item * CPAN Ratings + +L + +=item * Search CPAN + +L + +=back + +=head1 ACKNOWLEDGEMENTS + +Lincoln Stein, Doreen Ware and everyone at Cold Spring Harbor Lab. + +=head1 COPYRIGHT & LICENSE + +Copyright 2008 Ken Youens-Clark, all rights reserved. + +This program is free software; you can redistribute it and/or modify it +under the same terms as Perl itself. + +=cut + +1; # End of Bio::GenBankParser diff --git a/Bio/JBrowse/HashStore.pm b/Bio/JBrowse/HashStore.pm new file mode 100644 index 00000000..ab18ec77 --- /dev/null +++ b/Bio/JBrowse/HashStore.pm @@ -0,0 +1,333 @@ +=head1 NAME + +Bio::JBrowse::HashStore - on-disk 2-level hash table + +=head1 DESCRIPTION + +Makes an on-disk hash table, designed to be easily read as static files over HTTP. + +Makes a set of JSON-encoded files at paths based on a hash of the key. +For example: + + path/to/dir/29c/c14/fc.json + path/to/dir/23f/5ad/11.json + path/to/dir/711/7c1/29.json + path/to/dir/5ec/b24/6a.json + path/to/dir/4de/9ac/c6.json + path/to/dir/41b/c43/27.json + path/to/dir/28c/d86/e9.json + +Where each file contains a JSON object containing data items like: + + { foo: "bar", ... } + +Where "foo" is the original key, and "bar" is the JSON-encoded data. + +=cut + +package Bio::JBrowse::HashStore; +use strict; +use warnings; + +use Carp; + +use JSON 2; + +use File::Spec (); +use File::Path (); + +use Bio::JBrowse::PartialSorter; + +my $bucket_class = 'Bio::JBrowse::HashStore::Bucket'; + + +=head2 open( dir => "/path/to/dir", hash_bits => 16, sort_mem => 256 * 2**20 ) + +=cut + +sub open { + my $class = shift; + + # source of data: defaults, overridden by open args, overridden by meta.json contents + my $self = bless { @_ }, $class; + + $self->{final_dir} = $self->{dir} or croak "dir option required"; + $self->{dir} = $self->{work_dir} || $self->{final_dir}; + + $self->empty if $self->{empty}; + + $self->{meta} = $self->_read_meta; + + $self->{hash_bits} ||= $self->{meta}{hash_bits} || 16; + $self->{meta}{hash_bits} = $self->{hash_bits}; + $self->{hash_characters} = int( $self->{hash_bits}/4 ); + $self->{file_extension} = '.json'; + + $self->{bucket_cache} = $self->_make_cache( size => 30 ); + $self->{bucket_path_cache_by_key} = $self->_make_cache( size => 30 ); + + return $self; +} + +sub _make_cache { + my ( $self, @args ) = @_; + require Cache::Ref::FIFO; + return Cache::Ref::FIFO->new( @args ); +} + +# write out meta.json file when the store itself is destroyed +sub DESTROY { + my ( $self ) = @_; + + File::Path::mkpath( $self->{dir} ); + { + my $meta_path = $self->_meta_path; + CORE::open my $out, '>', $meta_path or die "$! writing $meta_path"; + $out->print( JSON::to_json( $self->{meta} ) ) + or die "$! writing $meta_path"; + } + + my $final_dir = $self->{final_dir}; + my $work_dir = $self->{dir}; + + # free everything to flush buckets + %$self = (); + + unless( $final_dir eq $work_dir ) { + require File::Copy::Recursive; + File::Copy::Recursive::dircopy( $work_dir, $final_dir ); + } + +} +sub _meta_path { + File::Spec->catfile( shift->{dir}, 'meta.json' ); +} +sub _read_meta { + my ( $self ) = @_; + my $meta_path = $self->_meta_path; + return {} unless -r $meta_path; + CORE::open my $meta, '<', $meta_path or die "$! reading $meta_path"; + local $/; + my $d = eval { JSON->new->relaxed->decode( scalar <$meta> ) }; + warn $@ if $@; + return $d || {}; +} + +=head2 get( $key ) + +=cut + +sub get { + my ( $self, $key ) = @_; + my $bucket = $self->_getBucket( $key ); + return $bucket->{data}{$key}; +} + + +=head2 set( $key, $value ) + +=cut + +sub set { + my ( $self, $key, $value ) = @_; + + my $bucket = $self->_getBucket( $key ); + $bucket->{data}{$key} = $value; + $bucket->{dirty} = 1; + $self->{meta}{last_changed_entry} = $key; + + return $value; +} + +=head2 sort_stream( $data_stream ) + +Given a data stream (sub that returns arrayrefs of [ key, (any amount +of other data) ] when called repeatedly), returns another stream that +emits small objects that can be used to get and set the contents of +the name store at that key ( $entry->get and $entry->set( $value ) ) +and will return the original data you passed (including the key) if +you call $entry->data. + +Using this can greatly speed up bulk operations on the hash store, +because it allows the internal caches of the HashStore to operate at +maximum efficiency. + +This is achieved by doing an external sort of the data items, which +involves writing all of the data items to temporary files and then +reading them back in sorted order. + +=cut + +sub sort_stream { + my ( $self, $in_stream ) = @_; + + my $sorted_stream = Bio::JBrowse::PartialSorter + ->new( + mem => $self->{sort_mem} || 256 * 2**20, + compare => sub($$) { + $_[0][0] cmp $_[1][0] + }, + ) + ->sort( $in_stream ); + + # sorted entries should have nearly perfect cache locality, so use a + # 1-element cache for crc32 computations + my $hash_cache = $self->{tiny_hash_cache} ||= { key => '' }; + return sub { + my $d = $sorted_stream->() + or return; + + my $key = $d->[0]; + my $hash = $hash_cache->{key} eq $key + ? $hash_cache->{hash} + : do { + $hash_cache->{key} = $key; + $hash_cache->{hash} = $self->_hexHash( $key ); + }; + + return Bio::JBrowse::HashStore::Entry->new( + store => $self, + key => $key, + data => $d, + hex_hash => $hash + ); + }; +} + + +=head2 empty + +Clear the store of all contents. Deletes all files and directories +from the store directory. + +=cut + +sub empty { + my ( $self ) = @_; + File::Path::rmtree( $self->{dir} ); + File::Path::mkpath( $self->{dir} ); +} + +########## helper methods ########### + +sub _hexHash { + my ( $self, $key ) = @_; + my $crc = ( $self->{crc} ||= do { require Digest::Crc32; Digest::Crc32->new } ) + ->strcrc32( $key ); + my $hex = lc sprintf( '%08x', $crc ); + $hex = substr( $hex, 8-$self->{hash_characters} ); + return $hex; +} + +sub _hexToPath { + my ( $self, $hex ) = @_; + my @dir = ( $self->{dir}, reverse $hex =~ /(.{1,3})/g ); + my $file = (pop @dir).$self->{file_extension}; + my $dir = File::Spec->catdir(@dir); + return { dir => $dir, fullpath => File::Spec->catfile( $dir, $file ) }; +} + +sub _getBucket { + my ( $self, $key ) = @_; + my $pathinfo = $self->{bucket_path_cache_by_key}->compute( $key, sub { $self->_hexToPath( $self->_hexHash( $key ) ); } ); + return $self->{bucket_cache}->compute( $pathinfo->{fullpath}, sub { $self->_readBucket( $pathinfo ); } ); +} + +sub _readBucket { + my ( $self, $pathinfo ) = @_; + + my $path = $pathinfo->{fullpath}; + my $dir = $pathinfo->{dir}; + + if( -f $path ) { + local $/; + CORE::open my $in, '<', $path or die "$! reading $path"; + return $bucket_class->new( + dir => $dir, + fullpath => $path, + data => eval { JSON::from_json( scalar <$in> ) } || {} + ); + } + else { + return $bucket_class->new( + dir => $dir, + fullpath => $path, + data => {}, + dirty => 1 + ); + } +} + + +######## inner class for on-disk hash buckets ########## + +package Bio::JBrowse::HashStore::Bucket; + +sub new { + my $class = shift; + bless { @_ }, $class; +} + +# when a bucket is deleted, flush it to disk +sub DESTROY { + my ( $self ) = @_; + + return unless $self->{dirty} && %{$self->{data}}; + + File::Path::mkpath( $self->{dir} ) unless -d $self->{dir}; + CORE::open my $out, '>', $self->{fullpath} or die "$! writing $self->{fullpath}"; + $out->print( JSON::to_json( $self->{data} ) ) or die "$! writing to $self->{fullpath}"; +} + +package Bio::JBrowse::HashStore::Entry; + +sub new { + my $class = shift; + bless { @_ }, $class; +} + +sub get { + my ( $self ) = @_; + my $bucket = $self->_getBucket; + return $bucket->{data}{ $self->{key} }; +} + +sub set { + my ( $self, $value ) = @_; + + my $bucket = $self->_getBucket; + $bucket->{data}{ $self->{key} } = $value; + $bucket->{dirty} = 1; + $self->{store}{meta}{last_changed_entry} = $self->{key}; + + return $value; +} + +sub data { + $_[0]->{data}; +} + +sub store { + $_[0]->{store}; +} + +sub _getBucket { + my ( $self ) = @_; + + # use a one-element cache for this _getBucket, because Entrys + # come from sort_stream, and thus should have perfect cache locality + my $tinycache = $self->{store}{tiny_bucket_cache} ||= { hex_hash => '' }; + if( $tinycache->{hex_hash} eq $self->{hex_hash} ) { + return $tinycache->{bucket}; + } + else { + my $store = $self->{store}; + my $pathinfo = $store->_hexToPath( $self->{hex_hash} ); + my $bucket = $store->_readBucket( $pathinfo ); + $tinycache->{hex_hash} = $self->{hex_hash}; + $tinycache->{bucket} = $bucket; + return $bucket; + } +} + +1; diff --git a/Bio/JBrowse/JSON.pm b/Bio/JBrowse/JSON.pm new file mode 100644 index 00000000..7bf1d521 --- /dev/null +++ b/Bio/JBrowse/JSON.pm @@ -0,0 +1,47 @@ +package Bio::JBrowse::JSON; +use strict; + +=head1 NAME + +Bio::JBrowse::JSON - JSON.pm subclass that turns on relaxed parsing by default, throws more informative die messages, and has a C method. + +=cut + +use JSON 2 (); + +our @ISA = ( 'JSON' ); + +sub new { + my $class = shift; + return $class->SUPER::new( @_ )->relaxed +} + +sub decode { + my $self = shift; + my $data; + eval { + $data = $self->SUPER::decode( @_ ); + }; if( $@ ) { + die "Error parsing JSON: $@"; + } + return $data; +} + +sub decode_file { + my ( $self, $file ) = @_; + my $data; + eval { + $data = $self->SUPER::decode(do { + local $/; + open my $f, '<', $file or die $!; + scalar <$f> + }); + }; if( $@ ) { + ( my $error = $@ ) =~ s/\.?\s*$//; + die "$error reading file ".$file."\n"; + } + return $data; +} + +1; + diff --git a/Bio/JBrowse/PartialSorter.pm b/Bio/JBrowse/PartialSorter.pm new file mode 100644 index 00000000..91cd3818 --- /dev/null +++ b/Bio/JBrowse/PartialSorter.pm @@ -0,0 +1,79 @@ +package Bio::JBrowse::PartialSorter; +use strict; +use warnings; + +=head1 NAME + +Bio::JBrowse::PartialSorter - partially sort a stream + +=head1 METHODS + +=head2 new( size => $num_items, mem => $mem_bytes, compare => sub($$) ) + +All items optional. Defaults to string comparison, 256MB of sort +memory. + +=cut + +sub new { + my ( $class, @args ) = @_; + my $self = bless { @args }, $class; + $self->{mem} ||= 256*1024*1024; #256 MB + return $self; +} + +=head2 sort( $stream ) + +Returns another stream, partially sorted with the comparison function. + +=cut + +sub sort { + my ( $self, $in ) = @_; + + my @buffer; + + my $size = $self->{size}; + if( ! $size ) { # if no explicit item size, sum the size of the first 100 items + my $item_size = $self->_estimate_item_size( $in, 100, \@buffer ); + $size = $self->{size} = sprintf('%.0f', $self->{mem} / $item_size ); + $self->_fill_buffer( $in, \@buffer, $size ); + } + + + return sub { + unless( @buffer ) { + $self->_fill_buffer( $in, \@buffer, $size ); + return unless @buffer; # stream must have ended + } + return shift @buffer; + }; +} + +sub _fill_buffer { + my ( $self, $in, $buffer, $size ) = @_; + my $compare = $self->{compare} ||= sub { $a cmp $b }; + while( @$buffer < $size && ( my $d = $in->() ) ) { + push @$buffer, $d; + } + @$buffer = sort $compare @$buffer; +} + +sub _estimate_item_size { + require List::Util; + require Devel::Size; + + my ( $self, $in_stream, $sample_size, $buffer ) = @_; + + while( @$buffer < $sample_size && ( my $d = $in_stream->() ) ) { + push @$buffer, $d; + } + + my $avg_size = List::Util::sum( + map Devel::Size::total_size( $_ ), @$buffer + ) / @$buffer; + + return $avg_size; +} + +1; diff --git a/Bio/WebApollo/Cmd.pm b/Bio/WebApollo/Cmd.pm new file mode 100644 index 00000000..fcab63ee --- /dev/null +++ b/Bio/WebApollo/Cmd.pm @@ -0,0 +1,56 @@ +package Bio::WebApollo::Cmd; +use strict; +use warnings; + +use Getopt::Long (); +use Pod::Usage (); + +=head1 NAME + +Script - base class for a WebApollo command-line script + +=head1 DESCRIPTION + +This wheel is even smaller than the Jbrowse one, and certainly less round. + +=cut + +sub new { + my $class = shift; + my $opts = $class->getopts(@_); + return bless { opt => $opts }, $class; +} + +sub getopts { + my $class = shift; + my $opts = { + $class->option_defaults, + }; + local @ARGV = @_; + Getopt::Long::GetOptions( $opts, $class->option_definitions ); + Pod::Usage::pod2usage( -verbose => 2 ) if $opts->{help}; + return $opts; +} + +sub opt { + if( @_ > 2 ) { + return $_[0]->{opt}{$_[1]} = $_[2]; + } else { + return $_[0]->{opt}{$_[1]} + } +} + +#override me +sub option_defaults { + ( ) +} + +#override me +sub option_definitions { + ( "help|h|?" ) +} + +sub run { +} + +1; diff --git a/Bio/WebApollo/Cmd/NCFormatter.pm b/Bio/WebApollo/Cmd/NCFormatter.pm new file mode 100644 index 00000000..fd43e36c --- /dev/null +++ b/Bio/WebApollo/Cmd/NCFormatter.pm @@ -0,0 +1,96 @@ +package Bio::WebApollo::Cmd::NCFormatter; + +use base 'Bio::WebApollo::Cmd'; + +use GenomeDB; +use ExternalSorter; + +sub _format { + my ( $self, %args ) = @_; + my ( $trackLabel, $trackConfig, $feature_stream, $filter ) = @args{qw{ trackLabel trackConfig featureStream featureFilter }}; + $filter ||= sub { @_ }; + + my $types = $self->opt('type'); + @$types = split /,/, join ',', @$types; + + # The ExternalSorter will get flattened [chrom, [start, end, ...]] + # arrays from the feature_stream + my $sorter = ExternalSorter->new( + do { + my $startIndex = $feature_stream->startIndex; + my $endIndex = $feature_stream->endIndex; + sub ($$) { + $_[0]->[0] cmp $_[1]->[0] + || + $_[0]->[1]->[$startIndex] <=> $_[1]->[1]->[$startIndex] + || + $_[1]->[1]->[$endIndex] <=> $_[0]->[1]->[$endIndex]; + } + }, + $self->opt('sortMem'), + ); + + my %featureCounts; + while ( my @feats = $feature_stream->next_items ) { + + for my $feat ( $filter->( @feats ) ) { + my $chrom = $feat->{seq_id}; + $featureCounts{$chrom} += 1; + + $feat = $self->transform_feature( $feat ); + + my $row = [ $chrom, + $feature_stream->flatten_to_feature( $feat ), + $feature_stream->flatten_to_name( $feat ), + ]; + $sorter->add( $row ); + } + } + $sorter->finish(); + + ################################ + + my $gdb = GenomeDB->new( $self->opt('out') ); + + my $track = $gdb->getTrack( $trackLabel, { %$trackConfig, type => 'FeatureTrack' }, $trackConfig->{key} ) + || $gdb->createFeatureTrack( $trackLabel, + $trackConfig, + $trackConfig->{key}, + ); + + my $curChrom = 'NONE YET'; + my $totalMatches = 0; + while( my $feat = $sorter->get ) { + + use Storable (); + unless( $curChrom eq $feat->[0] ) { + $curChrom = $feat->[0]; + $track->finishLoad; #< does nothing if no load happening + $track->startLoad( $curChrom, + $self->opt('nclChunk'), + Storable::dclone( $feature_stream->arrayReprClasses ), + ); + } + $totalMatches++; + $track->addSorted( $feat->[1] ); + + # load the feature's name record into the track if necessary + if( my $namerec = $feat->[2] ) { + $track->nameHandler->addName( $namerec ); + } + } + + $gdb->writeTrackEntry( $track ); + + # If no features are found, check for mistakes in user input + if( !$totalMatches && @$types ) { + warn "WARNING: No matching features found for @$types\n"; + } +} + +# stub +sub transform_feature { + return $_[1]; +} + +1; diff --git a/Bio/WebApollo/Cmd/VcfToJson.pm b/Bio/WebApollo/Cmd/VcfToJson.pm new file mode 100644 index 00000000..ef49e2cd --- /dev/null +++ b/Bio/WebApollo/Cmd/VcfToJson.pm @@ -0,0 +1,196 @@ +#!/usr/bin/env perl + +=head1 NAME + +Script::VcfToJson - implementation of bin/vcf-to-json.pl + +=head1 DESCRIPTION + +Do C for most of the documentation. + +=cut + +package Bio::WebApollo::Cmd::VcfToJson; + +use strict; +use warnings; + +use base 'Bio::WebApollo::Cmd::NCFormatter'; + +use JSON 2; + +sub option_defaults { + ( type => [], + out => 'data', + cssClass => 'feature', + sortMem => 1024 * 1024 * 512, + ) +} + +sub option_definitions { + ( + "gff=s", + "bed=s", + "bam=s", + "vcf=s", + "out=s", + "trackLabel=s", + "key=s", + "cssClass|className=s", + "autocomplete=s", + "getType", + "getPhase", + "getSubs|getSubfeatures", + "getLabel", + "urltemplate=s", + "menuTemplate=s", + "arrowheadClass=s", + "subfeatureClasses=s", + "clientConfig=s", + "thinType=s", + "thickType=s", + "type=s@", + "nclChunk=i", + "compress", + "sortMem=i", + "help|h|?", + ) +} + +sub run { + my ( $self ) = @_; + + Pod::Usage::pod2usage( "Must provide a --trackLabel parameter." ) unless defined $self->opt('trackLabel'); + unless( defined $self->opt('gff') || defined $self->opt('bed') || defined $self->opt('bam') || defined $self->opt('vcf') ) { + Pod::Usage::pod2usage( "You must supply either a --gff, --bed or --vcf parameter." ) + } + + $self->opt('bam') and die "BAM support has been moved to a separate program: bam-to-json.pl\n"; + + if( ! $self->opt('nclChunk') ) { + # default chunk size is 50KiB + my $nclChunk = 50000; + # $nclChunk is the uncompressed size, so we can make it bigger if + # we're compressing + $nclChunk *= 4 if $self->opt('compress'); + $self->opt( nclChunk => $nclChunk ); + } + + for my $optname ( qw( clientConfig subfeatureClasses ) ) { + if( my $o = $self->opt($optname) ) { + $self->opt( $optname => JSON::from_json( $o )); + } + } + + + my %config = ( + type => $self->opt('getType') || $self->opt('type') ? 1 : 0, + phase => $self->opt('getPhase'), + subfeatures => $self->opt('getSubs'), + style => { + %{ $self->opt('clientConfig') || {} }, + className => $self->opt('cssClass'), + ( $self->opt('urltemplate') ? ( linkTemplate => $self->opt('urltemplate') ) : () ), + ( $self->opt('arrowheadClass') ? ( arrowheadClass => $self->opt('arrowheadClass') ) : () ), + ( $self->opt('subfeatureClasses') ? ( subfeatureClasses => $self->opt('subfeatureClasses') ) : () ), + }, + ( $self->opt('menuTemplate') ? ( menuTemplate => $self->opt('menuTemplate') ) : () ), + key => defined( $self->opt('key') ) ? $self->opt('key') : $self->opt('trackLabel'), + compress => $self->opt('compress'), + ); + + my $feature_stream = + $self->opt('vcf') ? $self->make_vcf_stream : + die "Please specify --vcf\n"; + + # build a filtering subroutine for the features + my $types = $self->opt('type'); + @$types = split /,/, join ',', @$types; + my $filter = $self->make_feature_filter( $types ); + + $self->_format( trackConfig => \%config, + featureStream => $feature_stream, + featureFilter => $filter, + trackLabel => $self->opt('trackLabel') + ); + + return 0; +} + +sub make_vcf_stream { + my $self = shift; + + require Vcf; + require Bio::WebApollo::FeatureStream::VCF_LowLevel; + + my $p = Vcf->new( file => $self->opt('vcf') ); + $p->parse_header; + + return Bio::WebApollo::FeatureStream::VCF_LowLevel->new( + parser => $p, + track_label => $self->opt('trackLabel') + ); +} + +sub make_feature_filter { + my ( $self, $types ) = @_; + + my @filters; + + # add a filter for type:source if --type was specified + if( $types && @$types ) { + my @type_regexes = map { + my $t = $_; + $t .= ":.*" unless $t =~ /:/; + qr/^$t$/ + } @$types; + + push @filters, sub { + my ($f) = @_; + my $type = $f->{type} + or return 0; + my $source = $f->{source}; + my $t_s = "$type:$source"; + for( @type_regexes ) { + return 1 if $t_s =~ $_; + } + return 0; + }; + } + + # if no filtering, just return a pass-through now. + return sub { @_ } unless @filters; + + # make a sub that tells whether a single feature passes + my $pass_feature = sub { + my ($f) = @_; + $_->($f) || return 0 for @filters; + return 1; + }; + + # Apply this filtering rule through the whole feature hierarchy, + # returning features that pass. If a given feature passes, return + # it *and* all of its subfeatures, with no further filtering + # applied to the subfeatures. If a given feature does NOT pass, + # search its subfeatures to see if they do. + return sub { + _find_passing_features( $pass_feature, @_ ); + } +}; + +# given a subref that says whether an individual feature passes, +# return the LIST of features among the whole feature hierarchy that +# pass the filtering rule +sub _find_passing_features { + my $pass_feature = shift; + return map { + my $feature = $_; + $pass_feature->( $feature ) + # if this feature passes, we're done, just return it + ? ( $feature ) + # otherwise, look for passing features in its subfeatures + : _find_passing_features( $pass_feature, @{$feature->{subfeatures}} ); + } @_; +} + +1; diff --git a/Bio/WebApollo/FeatureStream.pm b/Bio/WebApollo/FeatureStream.pm new file mode 100644 index 00000000..54e6c127 --- /dev/null +++ b/Bio/WebApollo/FeatureStream.pm @@ -0,0 +1,122 @@ +=head1 NAME + +FeatureStream - base class for feature streams +used for handling features inside FlatFileToJson.pm + +=cut + +package Bio::WebApollo::FeatureStream; +use strict; +use warnings; + +use List::MoreUtils 'uniq'; + +sub new { + my $class = shift; + + my $self = bless { + @_, + class_count => 0 + }, $class; + + return $self; +} + +sub flatten_to_feature { + my ( $self, $f ) = @_; + my $class = $self->_get_class( $f ); + + my @f = ( $class->{index}, + @{$f}{ @{$class->{fields}} } + ); + + for my $subfeature_field (qw( subfeatures derived_features )) { + if( my $sfi = $class->{field_idx}{ $subfeature_field } ) { + $f[ $sfi+1 ] = [ + map { + $self->flatten_to_feature($_) + } @{$f[$sfi+1]} + ]; + } + } + # use Data::Dump 'dump'; + # print dump($_)."\n" for \@f, $class; + + # convert start to interbase and numify it + $f[1] -= 1; + # numify end + $f[2] += 0; + # convert strand to 1/0/-1/undef if necessary, and numify it + no warnings 'uninitialized'; + $f[3] = { '+' => 1, '-' => -1 }->{$f[3]} || $f[3] || undef; + $f[3] += 0; + return \@f; +} + +my %skip_field = map { $_ => 1 } qw( start end strand ); +sub _get_class { + my ( $self, $f ) = @_; + + my @attrs = keys %$f; + my $attr_fingerprint = join '-', @attrs; + + return $self->{classes}{$attr_fingerprint} ||= do { + my @fields = ( 'start', 'end', 'strand', ( grep !$skip_field{$_}, @attrs ) ); + my $i = 0; + { + index => $self->{class_count}++, # the classes start from 1. so what. + fields => \@fields, + field_idx => { map { $_ => $i++ } @fields }, + # assumes that if a field is an array for one feature, it will be for all of them + array_fields => [ grep ref($f->{$_}) eq 'ARRAY', @attrs ] + } + }; +} + +sub flatten_to_name { + my ( $self, $f ) = @_; + my @nameattrs = grep /^(name|id|alias)\d*$/, keys %$f; + my @namerec = ( + [ grep defined, @{$f}{@nameattrs} ], + $self->{track_label}, + $f->{name}, + $f->{seq_id} || die, + (map $_+0, @{$f}{'start','end'}), + $f->{id} + ); + $namerec[4]--; #< to one-based + return \@namerec; +} +sub arrayReprClasses { + my ( $self ) = @_; + return [ + map { + attributes => [ map ucfirst, @{$_->{fields}} ], + isArrayAttr => { map { ucfirst($_) => 1 } @{$_->{array_fields}} }, + }, + sort { $a->{index} <=> $b->{index} } + values %{ $self->{classes} } + ]; +} + +sub startIndex { 1 } +sub endIndex { 2 } + + +# given a hashref like { tagname => [ value1, value2 ], ... } +# flatten it to numbered tagnames like { tagname => value1, tagname2 => value2 } +sub _flatten_multivalues { + my ( $self, $h ) = @_; + my %flattened; + + for my $key ( keys %$h ) { + my $v = $h->{$key}; + for( my $i = 0; $i < @$v; $i++ ) { + $flattened{ $key.($i ? $i+1 : '')} = $v->[$i]; + } + } + + return \%flattened; +} + +1; diff --git a/Bio/WebApollo/FeatureStream/VCF_LowLevel.pm b/Bio/WebApollo/FeatureStream/VCF_LowLevel.pm new file mode 100644 index 00000000..8fe5ed5b --- /dev/null +++ b/Bio/WebApollo/FeatureStream/VCF_LowLevel.pm @@ -0,0 +1,34 @@ +=head1 NAME + +Script::FlatFileToJson::FeatureStream::GFF3_LowLevel - feature stream +class for working with L features + +=cut + +package Bio::WebApollo::FeatureStream::VCF_LowLevel; +use strict; +use warnings; + +use base 'Bio::WebApollo::FeatureStream'; + +sub next_items { + my ( $self ) = @_; + while ( my $i = $self->{parser}->next_data_hash ) { + return $self->_to_webapollo_feature( $i ); + } + return; +} + +sub _to_webapollo_feature { + my ( $self, $f ) = @_; + $f->{seq_id} = $f->{'CHROM'}; + $f->{'start'} = $f->{'POS'}; + $f->{'end'} = $f->{'POS'}; + $f->{'strand'} = 0; + if ( length( $f->{'REF'} ) == length( $f->{'ALT'}->[0] ) ){ + $f->{'Type'} = 'SNV'; + } + return $f; +} + +1; diff --git a/ExternalSorter.pm b/ExternalSorter.pm new file mode 100644 index 00000000..45788451 --- /dev/null +++ b/ExternalSorter.pm @@ -0,0 +1,190 @@ +=head1 NAME + +ExternalSorter - efficiently sort arrayrefs with a given comparison function + +=head1 SYNOPSIS + + # make a new sorter that sorts by column 4, then column 3 + my $sorter = ExternalSorter->new( + sub ($$) { + $_[0]->[4] <=> $_[1]->[4] + || + $_[1]->[3] <=> $_[0]->[3]; + }, $sortMem); + + for my $arrayref ( @arrayrefs ) { + $sorter->add( $arrayref ); + } + + # finalize sort + $sorter->finish; + + # iterate through the sorted arrayrefs + while( my $arrayref = $sorter->get ) { + + } + +=head1 METHODS + +=cut + + +package ExternalSorter; + +use strict; +use warnings; + +use Carp; +use PerlIO::gzip; +use Storable qw(store_fd fd_retrieve); +use Devel::Size qw(size total_size); +use Heap::Simple; +use File::Temp; + +=head1 new( \&comparison, $ramInBytes, $tmpDir ) + +Make a new sorter using the given comparison function, using at most +$ramInBytes bytes of RAM. Optionally, can also pass $tmpDir, a path +to the temporary directory to use for intermediate files. + +The comparison function must have a ($$) prototype. + +=cut + +sub new { + my ($class, $compare, $ram, $tmpDir) = @_; + my $self = { + tmpDir => $tmpDir, + compare => $compare, + ram => $ram, + segments => [], + curList => [], + curSize => 0, + finished => 0 + }; + bless $self, $class; + return $self; +} + +=head1 add( $item ) + +Add a new item to the sort buffer. + +=cut + +sub add { + my ($self, $item) = @_; + $self->{curSize} += total_size($item); + push @{$self->{curList}}, $item; + if ($self->{curSize} >= $self->{ram}) { + $self->flush(); + } +} + +=head1 finish() + +Call when all items have been added. Finalizes the sort. + +=cut + +sub finish { + my ($self) = @_; + my $compare = $self->{compare}; + if ($#{$self->{segments}} >= 0) { + $self->flush(); + my @unzipFiles = + map { + my $zip; + open $zip, "<:gzip", $_ + or croak "couldn't open $_: $!\n"; + unlink $_ + or croak "couldn't unlink $_: $!\n"; + $zip; + } @{$self->{segments}}; + my $readSegments = + Heap::Simple->new(order => sub {$compare->($_[0], $_[1]) < 0}, + elements => "Any"); + foreach my $fh (@unzipFiles) { + $readSegments->key_insert(readOne($fh), $fh); + } + $self->{readSegments} = $readSegments; + } else { + $self->{curList} = + [sort $compare @{$self->{curList}}]; + } + $self->{finished} = 1; +} + +=head1 flush() + +Write a sorted version of the list to temporary storage. + +=cut + +sub flush { + my ($self) = @_; + my $compare = $self->{compare}; + my @sorted = sort $compare @{$self->{curList}}; + + # each segment must have at least one element + return if ($#sorted < 0); + croak "ExternalSorter is already finished" + if $self->{finished}; + + my $fh = File::Temp->new($self->{tmpDir} ? (DIR => $self->{tmpDir}) : (), + UNLINK => 0, + TEMPLATE => 'sort-XXXXXXXXXX') + or croak "couldn't open temp file: $!\n"; + my $fn = $fh->filename; + $fh->close() + or croak "couldn't close temp file: $!\n"; + open $fh, ">:gzip", $fn + or croak "couldn't reopen $fn: $!\n"; + foreach my $item (@sorted) { + store_fd($item, $fh) + or croak "couldn't write item: $!\n"; + } + $fh->flush() + or croak "couldn't flush segment file: $!\n"; + $fh->close() + or croak "couldn't close $fn: $!\n"; + push @{$self->{segments}}, $fn; + $self->{curList} = []; + $self->{curSize} = 0; +} + +# get one item from the big list +sub get { + my ($self) = @_; + croak "External sort not finished\n" + unless $self->{finished}; + if ($#{$self->{segments}} >= 0) { + my $item = $self->{readSegments}->first_key(); + my $fh = $self->{readSegments}->extract_first(); + # if we're out of items, return undef + if (!defined($fh)) { return undef; } + my $newItem = readOne($fh); + if (defined($newItem)) { + $self->{readSegments}->key_insert($newItem, $fh); + } + return $item; + } else { + return shift @{$self->{curList}}; + } +} + + + +# read one item from a file handle +sub readOne { + my ($fh) = @_; + if ($fh->eof()) { + $fh->close(); + return undef; + } + my $item = fd_retrieve($fh) + or croak "couldn't retrieve item: $!\n"; + return $item; +} + +1; diff --git a/FastaDatabase.pm b/FastaDatabase.pm new file mode 100644 index 00000000..41c13ac1 --- /dev/null +++ b/FastaDatabase.pm @@ -0,0 +1,232 @@ + +=head1 NAME + +FastaDatabase.pm + +=head1 SYNOPSIS + +Lightweight module to wrap a FASTA sequence database with +stripped-down, feature-free versions of BioPerl's +Bio::DB::SeqFeature::Store methods for returning subsequences. + +=head1 EXAMPLES + + use FastaDatabase; + + # read sequences from FASTA file + my $db = FastaDatabase->from_fasta ( "sequences.fasta" ); + + # get sequence IDs (i.e. names) + my @ids = $db->seq_ids; + + # get all sequences + my @seqs = map ($db->segment->($_)->seq->seq, @ids); + + # get a segment + my $seg = $db->segment (-db_id => $refs[0]); + + # print sequence of segment + print $seg->seq->seq; + +=head1 METHODS + +=cut + +package FastaDatabase; + +use strict; +use warnings; + +use File::Temp; + +use Bio::Index::Fasta; + +=head2 from_fasta + + my $db = FastaDatabase->from_fasta ( $filename, ... ); + +Creates a new FastaDatabase object, reading sequences from a FASTA +file or filehandle. Automatically uncompresses fasta files whose +names end with .gz or .gzip. + +=cut + +sub from_fasta { + my ($class, @files ) = @_; + + my $self = { + index_file => File::Temp->new, + }; + + $self->{index} = Bio::Index::Fasta->new( -filename => $self->{index_file}, -write_flag => 1 ); + + # uncompress any files that need it into temp files + $self->{files} = [ map { + ref $_ ? $class->_slurp_to_temp( $_ ) : + /\.gz(ip)?$/ ? $class->_unzip( $_ ) : + $_ + } @files + ]; + + $self->{index}->make_index( map "$_", @{ $self->{files} } ); + + return bless $self, $class; +} +sub _unzip { + my ( $class, $filename ) = @_; + open my $f, '<:gzip', $filename or die "$! reading $filename"; + $class->_slurp_to_temp( $f ); +} +sub _slurp_to_temp { + my ( $class, $fh ) = @_; + my $tempfile = File::Temp->new; + local $_; + print $tempfile $_ while <$fh>; + $tempfile->close; + return $tempfile; +} + +=head2 seq_ids + + Title : seq_ids + Usage : @ids = $db->seq_ids() + Function: Return all sequence IDs contained in database + Returns : list of sequence Ids + Args : none + Status : public + +=cut + +sub seq_ids { + shift->{index}->get_all_primary_ids; +} + +=head2 segment + + Title : segment + Usage : $segment = $db->segment($seq_id [,$start] [,$end]) + Function: restrict the database to a sequence range + Returns : an AutoHash that's similar to a Bio::DB::SeqFeature::Segment + (i.e. it has {name,start,end,seq,length} member variables) + Args : sequence id, start and end ranges (optional) + Status : public + +This is a method for returning subsequences that mimics the syntax of +Bio::DB::SeqFeature::Store. +Specify the ID of a sequence in the database +and optionally a start and endpoint relative to that sequence. The +method will look up the region and return an object that spans it. +The object is not an Bio::DB::SeqFeature::Segment +(as would be returned by Bio::DB::SeqFeature::Store), +but it has similar member variables and can be used +to find the sequence of the sub-region that you identified. + +Note that the 'seq' method of the returned segment object +returns something analogous to a Bio::PrimarySeq object. +To get the actual sequence as a text string, you need to +call 'seq' on this object as well, e.g. + + print + "Sequence $name has the following sequence:\n", + $db->segment($name)->seq->seq, "\n"; + +Example: + + $segment = $db->segment('contig23',1,1000); # first 1000bp of contig23 + print $segment->seq->seq; + + $segment = $db->segment ( -db_id => 'contig23', + -start => 1, + -end => 1000); # alternate syntax + +=cut + +sub segment { + my $self = shift; + my %opt; + if( $_[0] =~ /^-/ ) { # if we have args like -db_id, etc + %opt = @_; + } + else { # if we have positional args + @opt{ '-name', '-start', '-end' } = @_; + } + + my $name = defined $opt{'-name'} ? $opt{'-name'} : $opt{'-db_id'}; + my $start = defined $opt{'-start'} ? $opt{'-start'} : 1; + + my $seq_ref = $self->_fetch( $name ); + my $length = length $$seq_ref; + + my $end = defined $opt{'-end'} && $opt{'-end'} <= $length + ? $opt{'-end'} + : $length; + + my $subseq = substr( $$seq_ref, $start-1, $end-$start+1 ); + + # behold, the awesome redundancy of BioPerl ;-) + return mock->new( %opt, + 'name' => $name, + 'ref' => $name, + 'seq_id' => $name, + 'start' => $start, + 'end' => $end, + 'length' => $length, + 'seq' => mock->new( 'primary_id' => $name, + 'length' => $length, + 'seq' => $subseq + ), + ); +} + +*features = \&segment; + +# cache the last fetch from the index, which will cache the last +# sequence we fetched, and make repeated fetches of the same sequence +# fast. returns a REFERENCE to a sequence string. +sub _fetch { + my ( $self, $name ) = @_; + no warnings 'uninitialized'; + if( $self->{_fetch_cache}{name} eq $name ) { + return $self->{_fetch_cache}{seq}; + } + else { + my $seq = $self->{index}->fetch( $name )->seq; + $self->{_fetch_cache} = { name => $name, seq => \$seq }; + return \$seq; + } +} + +package mock; + +sub new { + my $class = shift; + bless { @_ }, $class +} + +sub AUTOLOAD { + my $self = shift; + + my $method_name = our $AUTOLOAD; + $method_name =~ s/.*:://; # strip off module path + return if $method_name eq "DESTROY"; + + return @_ ? ( $self->{ $method_name } = $_[0] ) + : $self->{ $method_name }; +} + + + +=head1 AUTHOR + +Ian Holmes Eihh@berkeley.eduE + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut + +1; diff --git a/FeatureTrack.pm b/FeatureTrack.pm new file mode 100644 index 00000000..1f67c750 --- /dev/null +++ b/FeatureTrack.pm @@ -0,0 +1,275 @@ +=head1 NAME + +FeatureTrack - a track containing "regular" interval features + +=head1 DESCRIPTION + +WARNING: currently only works for *loading* feature data. Other +operations on the feature data are not supported by this module. + +=head1 METHODS + +=cut + +package FeatureTrack; + +use strict; +use warnings; +use File::Path qw( rmtree ); +use File::Spec; +use List::Util qw( min max sum first ); +use POSIX qw (ceil); + +use IntervalStore; +use JsonFileStorage; +use NameHandler; + +sub new { + my ($class, $trackDirTemplate, $baseUrl, $label, $config, $key, $jsclass) = @_; + + $config->{compress} = $config->{compress} || 0; + $config->{storeClass} = 'JBrowse/Store/SeqFeature/NCList'; + my $self = { + trackDirTemplate => $trackDirTemplate, + label => $label, + key => $key || $label, + trackDataFilename => "trackData" . ($config->{compress} ? + ".jsonz" : ".json"), + config => $config, + jsclass => $jsclass || 'FeatureTrack', + }; + $config->{urlTemplate} = $baseUrl . "/" . $self->{trackDataFilename} + unless defined($config->{urlTemplate}); + bless $self, $class; + + return $self; +} + +sub label { return shift->{label}; } +sub key { return shift->{key}; } +sub type { return shift->{jsclass} } +sub config { return shift->{config}; } + +=head2 startLoad( $refSeqName, $chunkBytes, \@classes ) + +Starts loading for a given refseq. Takes the name of the reference +seq, the number of bytes in a chunk, and an arrayref containing the +L definitions for each feature class. + +Example: + + $featureTrack->startLoad("chr4"); + $featuretrack->addSorted( $_ ) for @sorted_features; + +=cut + +sub startLoad { + my ($self, $refSeq, $chunkBytes, $classes) = @_; + + (my $outDir = $self->{trackDirTemplate}) =~ s/\{refseq\}/$refSeq/g; + rmtree($outDir) if (-d $outDir); + + my $jsonStore = JsonFileStorage->new($outDir, $self->config->{compress}); + $self->_make_nameHandler; + my $intervalStore = $self->{intervalStore} = + IntervalStore->new({store => $jsonStore, + classes => $classes }); + + # add 1 for the comma between features in the JSON arrays + my $measure = sub { return $jsonStore->encodedSize($_[0]) + 1; }; + $intervalStore->startLoad($measure, $chunkBytes); + + $self->{loading} = 1; + + return; +} + +sub _intervalStore { $_[0]->{intervalStore} } + +=head2 addSorted( $feature ) + +Add a feature to this feature track. Features must be passed to this +in sorted order. + +=cut + +sub addSorted { shift->_intervalStore->addSorted( @_ ) } + +=head2 hasFeatures + +Returns true if this track has features in it. + +=cut + +sub hasFeatures { $_[0]->_intervalStore && $_[0]->_intervalStore->hasIntervals } + +=head2 finishLoad() + +Finish loading this track, if it is loading. + +=cut + +sub finishLoad { + my ( $self ) = @_; + + return unless $self->{loading}; + + my $ivalStore = $self->_intervalStore; + $ivalStore->finishLoad; + $self->nameHandler->finish; + + my $trackData = { + featureCount => $ivalStore->count, + intervals => $ivalStore->descriptor, + histograms => $self->writeHistograms($ivalStore), + formatVersion => 1 + }; + + $ivalStore->store->put($self->{trackDataFilename}, $trackData); + + %{ $self->{intervalStore}} = (); + delete $self->{intervalStore}; + + $self->{loading} = 0; + + return; +} + +sub DESTROY { $_[0]->finishLoad } + +=head2 nameHandler + +Return a NameHandler object configured to generate name files for this +track. Not available until startLoad() is called. + +=cut + +sub nameHandler { $_[0]->{nameHandler} } +sub _make_nameHandler { + my ( $self ) = @_; + (my $trackdir = $self->{trackDirTemplate}) =~ s/\{refseq\}/'$_[0]'/eg; + $self->{nameHandler} = NameHandler->new( eval qq|sub { "$trackdir" }| ); +} + + +sub writeHistograms { + my ($self, $ivalStore) = @_; + #this series of numbers is used in JBrowse for zoom level relationships + my @multiples = (1, 2, 5, 10, 20, 50, 100, 200, 500, + 1000, 2000, 5000, 10_000, 20_000, 50_000, + 100_000, 200_000, 500_000, 1_000_000); + my $histChunkSize = 10_000; + + my $attrs = ArrayRepr->new($ivalStore->classes); + my $getStart = $attrs->makeFastGetter("Start"); + my $getEnd = $attrs->makeFastGetter("End"); + + my $jsonStore = $ivalStore->store; + my $refEnd = $ivalStore->lazyNCList->maxEnd || 0; + my $featureCount = $ivalStore->count; + + # $histBinThresh is the approximate the number of bases per + # histogram bin at the zoom level where FeatureTrack.js switches + # to the histogram view by default + my $histBinThresh = $featureCount ? ($refEnd * 2.5) / $featureCount : 999_999_999_999; + my $histBinBases = ( first { $_ > $histBinThresh } @multiples ) || $multiples[-1]; + + # initialize histogram arrays to all zeroes + my @histograms; + for (my $i = 0; $i < @multiples; $i++) { + my $binBases = $histBinBases * $multiples[$i]; + $histograms[$i] = [(0) x ceil($refEnd / $binBases)]; + # somewhat arbitrarily cut off the histograms at 100 bins + last if $binBases * 100 > $refEnd; + } + + my $processFeat = sub { + my ($feature) = @_; + my $curHist; + my $start = max(0, min($getStart->($feature), $refEnd)); + my $end = min($getEnd->($feature), $refEnd); + return if ($end < 0); + + for (my $i = 0; $i <= $#multiples; $i++) { + my $binBases = $histBinBases * $multiples[$i]; + $curHist = $histograms[$i]; + last unless defined($curHist); + + my $firstBin = int($start / $binBases); + my $lastBin = int($end / $binBases); + for (my $bin = $firstBin; $bin <= $lastBin; $bin++) { + $curHist->[$bin] += 1; + } + } + }; + + $ivalStore->overlapCallback($ivalStore->lazyNCList->minStart, + $ivalStore->lazyNCList->maxEnd, + $processFeat); + + # find multiple of base hist bin size that's just over $histBinThresh + my $i; + for ($i = 1; $i <= $#multiples; $i++) { + last if ($histBinBases * $multiples[$i]) > $histBinThresh; + } + + my @histogramMeta; + my @histStats; + for (my $j = $i - 1; $j <= $#multiples; $j += 1) { + my $curHist = $histograms[$j]; + last unless defined($curHist); + my $histBases = $histBinBases * $multiples[$j]; + + my $chunks = chunkArray($curHist, $histChunkSize); + for (my $k = 0; $k <= $#{$chunks}; $k++) { + $jsonStore->put("hist-$histBases-$k" . $jsonStore->ext, + $chunks->[$k]); + } + push @histogramMeta, + { + basesPerBin => $histBases, + arrayParams => { + length => $#{$curHist} + 1, + urlTemplate => "hist-$histBases-{Chunk}" . $jsonStore->ext, + chunkSize => $histChunkSize + } + }; + push @histStats, + { + 'basesPerBin' => $histBases, + 'max' => @$curHist ? max( @$curHist ) : undef, + 'mean' => @$curHist ? ( sum( @$curHist ) / @$curHist ) : undef, + }; + } + + return { meta => \@histogramMeta, + stats => \@histStats }; +} + +sub chunkArray { + my ($bigArray, $chunkSize) = @_; + + my @result; + for (my $start = 0; $start <= $#{$bigArray}; $start += $chunkSize) { + my $lastIndex = $start + $chunkSize; + $lastIndex = $#{$bigArray} if $lastIndex > $#{$bigArray}; + + push @result, [@{$bigArray}[$start..$lastIndex]]; + } + return \@result; +} + +1; + +=head1 AUTHOR + +Mitchell Skinner Ejbrowse@arctur.usE + +Copyright (c) 2007-2011 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..7017b5dd --- /dev/null +++ b/Gemfile @@ -0,0 +1,15 @@ +source "http://rubygems.org" + +gem 'pg', '~> 0.17.0' +gem 'pg_json', '~> 0.1.29' +gem 'sequel', '~> 4.3.0' +gem 'sequel_pg', '~> 1.6.8' +gem 'bcrypt-ruby', '~> 3.1.2' + +gem 'puma', '~> 2.6.0' +gem 'sinatra', '~> 1.4.2' +gem 'omniauth', '~> 1.2.1' +gem 'omniauth-facebook', '1.4.0' + +gem 'minitest', '~> 4.3.3' +gem 'rake', '~> 10.1.0' diff --git a/GenomeDB.pm b/GenomeDB.pm new file mode 100644 index 00000000..e6f33cd9 --- /dev/null +++ b/GenomeDB.pm @@ -0,0 +1,364 @@ +=head1 NAME + +GenomeDB - central "handle" for a directory tree of JBrowse JSON data + +=head1 SYNOPSIS + + my $gdb = GenomeDB->new( '/path/to/data/dir' ); + + my $track = $gdb->getTrack($tableName, $trackConfig, $track->{shortLabel} ); + #returns an object for the track, e.g. a FeatureTrack + + unless( defined $track ) { + $track = $gdb->createFeatureTrack( $trackLabel, + $trackConfig, + $track->{shortLabel} ); + } + +=head1 DESCRIPTION + +Central "handle" on a directory tree of JBrowse JSON data, containing +accessors for accessing the tracks and (soon) reference sequences it +contains. + +=head1 METHODS + +=cut + +package GenomeDB; + +use strict; +use warnings; + +use File::Spec; +use IO::File; +use Storable 'dclone'; + +use Hash::Merge (); + +use JsonFileStorage; + +my $defaultTracklist = { + formatVersion => 1, + tracks => [] + }; + +my $trackListPath = "trackList.json"; +my @trackDirHeirarchy = ("tracks", "{tracklabel}", "{refseq}"); + +=head2 new( '/path/to/data/dir' ) + +Create a new handle for a data dir. + +=cut + +sub new { + my ($class, $dataDir) = @_; + + my $self = { + dataDir => $dataDir, + rootStore => JsonFileStorage->new($dataDir, 0, {pretty => 1}), + trackDirTempl => File::Spec->join($dataDir, @trackDirHeirarchy), + trackUrlTempl => join("/", @trackDirHeirarchy) + }; + bless $self, $class; + + # drop a .htaccess file in the root of the data dir to apply CORS + # requests + { + my $f = File::Spec->catfile($dataDir,'.htaccess'); + open my $ht, '>', $f + or die "$! writing $f"; + $ht->print( $self->CORS_htaccess ); + } + + return $self; +} + +=head2 writeTrackEntry( $track_object ) + +Record an entry for a new track in the data dir. + +=cut + +sub writeTrackEntry { + my ($self, $track) = @_; + + my $setTrackEntry = sub { + my ($trackData) = @_; + unless (defined($trackData)) { + $trackData = $defaultTracklist; + } + # we want to add this track entry to the "tracks" list, + # replacing any existing entry with the same label, + # and preserving the original ordering + my $trackIndex; + my $trackList = $trackData->{tracks}; + foreach my $index (0..$#{$trackList}) { + $trackIndex = $index + if ($trackList->[$index]->{label} eq $track->label); + } + $trackIndex = ($#{$trackList} + 1) unless defined($trackIndex); + + $trackList->[$trackIndex] = { + %{ $track->config || {} }, + type => $track->config->{trackType} || $track->type, + label => $track->label, + key => $track->key, + }; + + return $trackData; + }; + + $self->modifyTrackList( $setTrackEntry ); +} + +=head2 modifyTrackList( sub {} ) + +Modify the trackList.json file with the given subroutine. + +=cut + +sub modifyTrackList { + my ( $self, $sub ) = @_; + $self->{rootStore}->modify($trackListPath, $sub); +} + + +=head2 createFeatureTrack( $label, \%config, $key, $jsclass ) + +Create a new FeatureTrack object in this data dir with the given +label, config, key, and (JavaScript) class. + +$jsclass is optional, and defaults to C. + +=cut + +sub createFeatureTrack { + my $self = shift; + push( @_, 'FeatureTrack' ) if @_ < 4; + $self->_create_track( FeatureTrack => @_ ); +} + +=head2 createImageTrack( $label, \%config, $key, $jsclass ) + +Create a new ImageTrack object in this data dir with the given +label, config, key, and (JavaScript) class. + +$jsclass is optional, and defaults to C. + +=cut + +sub createImageTrack { + my $self = shift; + push( @_, 'ImageTrack' ) if @_ < 4; + $self->_create_track( ImageTrack => @_ ); +} + +sub _create_track { + my ($self, $class, $trackLabel, $config, $key, $jsclass) = @_; + eval "require $class"; die $@ if $@; + (my $baseUrl = $self->{trackUrlTempl}) =~ s/\{tracklabel\}/$trackLabel/g; + return $class->new( $self->trackDir($trackLabel), $baseUrl, + $trackLabel, $config, $key, $jsclass ); +} + +=head2 getTrack( $trackLabel, $config, $key, $jsclass ) + +Get a track object (FeatureTrack or otherwise) from the GenomeDB. If +$config, $key, and/or $jsclass are provided, they are merged into and +override the existing settings for that track. + +=cut + +sub getTrack { + my ($self, $trackLabel, $config, $key, $jsclass ) = @_; + + my $trackList = $self->{rootStore}->get($trackListPath, + $defaultTracklist); + my ( $trackDesc ) = my @selected = + grep { $_->{label} eq $trackLabel } @{$trackList->{tracks}}; + + return unless @selected; + + # this should never happen + die "multiple tracks labeled $trackLabel" if @selected > 1; + + # merge the $config into the trackdesc + if( $config ) { + $trackDesc = { + %$trackDesc, + %$config, + style => { %{$trackDesc->{style}||{}}, %{$config->{style}||{}} }, + }; + } + # merge the $key into the trackdesc + $trackDesc->{key} = $key if defined $key; + # merge the jsclass into the trackdesc + $trackDesc->{type} = $jsclass if defined $jsclass; + + + my $type = $trackDesc->{type}; + $type =~ s/\./::/g; + $type =~ s/[^\w:]//g; + + # make a list of perl packages to try, finding the most specific + # perl track class that matches the type in the JSON file. For + # example, ImageTrack.Wiggle.Frobnicated will try first to require + # ImageTrack::Wiggle::Frobnicated, then ImageTrack::Wiggle, then + # finally ImageTrack. + my @packages_to_try = ( $type ); + while( $type =~ s/::[^:]+$// ) { + push @packages_to_try, $type; + } + for( @packages_to_try ) { + eval "require $_"; + last unless $@; + } + die $@ if $@; + + (my $baseUrl = $self->{trackUrlTempl}) =~ s/\{tracklabel\}/$trackLabel/g; + + return $type->new( $self->trackDir($trackLabel), + $baseUrl, + $trackDesc->{label}, + $trackDesc, + $trackDesc->{key}, + ); +} + +# private method +# Get the data subdirectory for a given track, using its label. +sub trackDir { + my ($self, $trackLabel) = @_; + (my $result = $self->{trackDirTempl}) =~ s/\{tracklabel\}/$trackLabel/g; + return $result; +} + +=head2 refSeqs + +Returns a arrayref of hashrefs defining the reference sequences, as: + + [ { + name => 'ctgB', + seqDir => 'seq/ctgB', + + start => 0 + end => 66, + length => 66, + + seqChunkSize => 20000, + }, + ... + ] + +=cut + +sub refSeqs { + shift->{rootStore}->get( 'seq/refSeqs.json', [] ); +} + + +=head2 trackList + +Return an arrayref of track definition hashrefs similar to: + + [ + { + compress => 0, + feature => ["remark"], + style => { className => "feature2" }, + track => "ExampleFeatures", + urlTemplate => "tracks/ExampleFeatures/{refseq}/trackData.json", + key => "Example Features", + label => "ExampleFeatures", + type => "FeatureTrack", + }, + ... + ] + +=cut + +sub trackList { + shift->{rootStore}->get( 'trackList.json', { tracks => [] } )->{tracks} +} + +=head2 CORS_htaccess + +Static method to return a string to write into a .htaccess file that +will instruct Apache (if AllowOverride is on) to set the proper +"Access-Control-Allow-Origin *" headers on data files to enable +cross-origin data sharing. + +=cut + +sub CORS_htaccess { + my ( $self ) = @_; + + my $class = ref $self || $self; + return < + Header onsuccess set Access-Control-Allow-Origin * + Header onsuccess set Access-Control-Allow-Headers X-Requested-With + +EOA + +} + +=head2 precompression_htaccess( @precompressed_extensions ) + +Static method to return a string to write into a .htaccess file that +will instruct Apache (if AllowOverride is on) to set the proper +"Content-Encoding gzip" headers on precompressed files (.jsonz and +.txtz). + +=cut + +sub precompression_htaccess { + my ( $self, @extensions ) = @_; + + my $re = '('.join('|',@extensions).')$'; + $re =~ s/\./\\./g; + + my $package = ref $self || $self; + return < + mod_gzip_item_exclude "$re" + + + SetEnvIf Request_URI "$re" no-gzip dont-vary + + + + Header onsuccess set Content-Encoding gzip + + +EOA +} + + +1; + +=head1 AUTHOR + +Mitchell Skinner Ejbrowse@arctur.usE + +Copyright (c) 2007-2011 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/IntervalStore.pm b/IntervalStore.pm new file mode 100644 index 00000000..c781b24d --- /dev/null +++ b/IntervalStore.pm @@ -0,0 +1,228 @@ +package IntervalStore; +use strict; +use warnings; +use Carp; +use Storable (); + +use ArrayRepr; +use LazyNCList; + +=head1 NAME + +IntervalStore - manages a set of intervals (genomic features) + +=head1 SYNOPSIS + + my $js = JsonStore->new($pathTempl, $compress); + my $is = IntervalStore->new({ + store => $js, + classes => [ + { + attributes => ["Start", "End", "Strand"], + }, + ], + urlTemplate => "lf-{Chunk}.jsonz", + ); + my $chunkBytes = 80_000; + $is->startLoad($chunkBytes); + $is->addSorted([10, 100, -1]) + $is->addSorted([50, 80, 1]) + $is->addSorted([90, 150, -1]) + $is->finishLoad(); + $is->overlap(60, 85) + + => ([10, 100, -1], [50, 80, 1]) + +=head1 METHODS + +=head2 new + + Title : new + Usage : IntervalStore->new( + store => $js, + classes => {attributes => ["Start", "End", "Strand"]}, + ) + Function: create an IntervalStore + Returns : an IntervalStore object + Args : The IntervalStore constuctor accepts the named parameters: + store: object with put(path, data) method, will be used to output + feature data + classes: describes the feature arrays; will be used to construct + an ArrayRepr + urlTemplate (optional): template for URLs where chunks of feature + data will be stored. This is relative to + the directory with the "trackData.json" file + lazyClass (optional): index in classes->{attributes} array for + the class indicating a lazy feature + nclist (optional): the root of the nclist + count (optional): the number of intervals in this IntervalStore + minStart (optional): the earliest interval start point + maxEnd (optional): the latest interval end point + + If this IntervalStore hasn't been loaded yet, the optional + parameters aren't necessary. But to access a previously-loaded + IntervalStore, the optional parameters *are* needed. + +=cut + +sub new { + my ($class, $args) = @_; + + my $self = { + store => $args->{store}, + classes => Storable::dclone( $args->{classes} ), + lazyClass => $args->{lazyClass}, + urlTemplate => $args->{urlTemplate} || ("lf-{Chunk}" + . $args->{store}->ext), + attrs => ArrayRepr->new($args->{classes}), + nclist => $args->{nclist}, + minStart => $args->{minStart}, + maxEnd => $args->{maxEnd}, + loadedChunks => {} + }; + + if (defined($args->{nclist})) { + # we're already loaded + $self->{lazyNCList} = + LazyNCList->importExisting($self->{attrs}, + $args->{lazyClass}, + $args->{count}, + $args->{minStart}, + $args->{maxEnd}, + sub { $self->_loadChunk( @_ ); }, + $args->{nclist} ); + } + + bless $self, $class; + + return $self; +} + +sub _loadChunk { + my ($self, $chunkId) = @_; + my $chunk = $self->{loadedChunks}->{$chunkId}; + if (defined($chunk)) { + return $chunk; + } else { + (my $path = $self->{urlTemplate}) =~ s/\{Chunk\}/$chunkId/g; + $chunk = $self->{store}->get($path); + # TODO limit the number of chunks that we keep in memory + $self->{loadedChunks}->{$chunkId} = $chunk; + return $chunk; + } +} + +=head2 startLoad( $measure, $chunkBytes ) + +=cut + +sub startLoad { + my ($self, $measure, $chunkBytes) = @_; + + if (defined($self->{nclist})) { + confess "loading into an already-loaded IntervalStore"; + } else { + # add a new class for "fake" features + push @{$self->{classes}}, { + 'attributes' => ['Start', 'End', 'Chunk'], + 'isArrayAttr' => {'Sublist' => 1} + }; + $self->{lazyClass} = $#{$self->{classes}}; + my $makeLazy = sub { + my ($start, $end, $chunkId) = @_; + return [$self->{lazyClass}, $start, $end, $chunkId]; + }; + my $output = sub { + my ($toStore, $chunkId) = @_; + (my $path = $self->{urlTemplate}) =~ s/\{Chunk\}/$chunkId/g; + $self->{store}->put($path, $toStore); + }; + $self->{attrs} = ArrayRepr->new($self->{classes}); + $self->{lazyNCList} = + LazyNCList->new($self->{attrs}, + $self->{lazyClass}, + $makeLazy, + sub { $self->_loadChunk( @_); }, + $measure, + $output, + $chunkBytes); + } +} + +=head2 addSorted( \@feature ) + +=cut + +sub addSorted { + my ($self, $feat) = @_; + $self->{lazyNCList}->addSorted($feat); +} + +=head2 finishLoad() + +=cut + +sub finishLoad { + my ($self) = @_; + $self->{lazyNCList}->finish(); + $self->{nclist} = $self->lazyNCList->topLevelList(); +} + +=head2 overlapCallback( $from, $to, \&func ) + +Calls the given function once for each of the intervals that overlap +the given interval if C<<$from <= $to>>, iterates left-to-right, otherwise +iterates right-to-left. + +=cut + +sub overlapCallback { + my ($self, $start, $end, $cb) = @_; + $self->lazyNCList->overlapCallback($start, $end, $cb); +} + + +sub lazyNCList { shift->{lazyNCList} } +sub count { shift->{lazyNCList}->count } +sub hasIntervals { shift->count > 0 } +sub store { shift->{store} } +sub classes { shift->{classes} } + +=head2 descriptor + + Title : descriptor + Usage : IntervalStore->descriptor() + Returns : a hash containing the data needed to re-construct this + IntervalStore, including the root of the NCList plus some + metadata and configuration. + The return value can be passed to the constructor later. + +=cut + +sub descriptor { + my ($self) = @_; + return { + classes => $self->{classes}, + lazyClass => $self->{lazyClass}, + nclist => $self->{nclist}, + urlTemplate => $self->{urlTemplate}, + count => $self->count, + minStart => $self->lazyNCList->minStart, + maxEnd => $self->lazyNCList->maxEnd + }; +} + +1; + +=head1 AUTHOR + +Mitchell Skinner Ejbrowse@arctur.usE + +Copyright (c) 2007-2011 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/JBlibs.pm b/JBlibs.pm new file mode 100644 index 00000000..2875dd1b --- /dev/null +++ b/JBlibs.pm @@ -0,0 +1,22 @@ +=head1 NAME + +JBlibs - when included, sets JBrowse Perl module paths + +=cut + +package JBlibs; + +use Carp::Heavy; #< work around some types of broken perl installations + +#find the jbrowse root dir +use File::Basename 'dirname'; +my $dir = dirname($INC{'JBlibs.pm'}) or die; +my $extlib = "$dir/.extlib"; + +require lib; + +if( -e $extlib ) { + lib->import( "$extlib/lib/perl5" ); +} + +1; diff --git a/JsonFileStorage.pm b/JsonFileStorage.pm new file mode 100644 index 00000000..59eacdc3 --- /dev/null +++ b/JsonFileStorage.pm @@ -0,0 +1,204 @@ +=head1 NAME + +JsonFileStorage - manage a directory structure of .json or .jsonz files + +=head1 SYNOPSIS + + my $storage = JsonFileStorage->new( $outDir, $self->config->{compress} ); + $storage->put( 'relative/path/to/file.jsonz', \%data ); + my $data = $storage->get( 'relative/path/to/file.jsonz' ); + + $storage->modify( 'relative/path/to/file.jsonz', + sub { + my $json_data = shift; + # do something with the data + return $json_data; + }) + +=head1 METHODS + +=cut + +package JsonFileStorage; + +use strict; +use warnings; +use File::Spec; +use File::Path qw( mkpath ); +use JSON 2; +use IO::File; +use Fcntl ":flock"; +use PerlIO::gzip; + +use constant DEFAULT_MAX_JSON_DEPTH => 2048; + +=head2 new( $outDir, $compress, \%opts ) + +Constructor. Takes the directory to work with, boolean flag of +whether to compress the results, and an optional hashref of other +options as: + + # TODO: document options hashref + +=cut + +sub new { + my ($class, $outDir, $compress, $opts) = @_; + + # create JSON object + my $json = JSON->new->relaxed->max_depth( DEFAULT_MAX_JSON_DEPTH ); + # set opts + if (defined($opts) and ref($opts) eq 'HASH') { + for my $method (keys %$opts) { + $json->$method( $opts->{$method} ); + } + } + + my $self = { + outDir => $outDir, + ext => $compress ? ".jsonz" : ".json", + compress => $compress, + json => $json + }; + bless $self, $class; + + mkpath( $outDir ) unless (-d $outDir); + + return $self; +} + +sub _write_htaccess { + my ( $self ) = @_; + if( $self->{compress} && ! $self->{htaccess_written} ) { + require IO::File; + require GenomeDB; + my $hn = File::Spec->catfile( $self->{outDir}, '.htaccess' ); + open my $h, '>', $hn or die "$! writing $hn"; + $h->print( GenomeDB->precompression_htaccess( '.jsonz', '.txtz', '.txt.gz' )); + $self->{htaccess_written} = 1; + } +} + +=head2 fullPath( 'path/to/file.json' ) + +Get the full path to the given filename in the output directory. Just +calls File::Spec->join with the C<<$outDir>> that was set at +construction. + +=cut + +sub fullPath { + my ($self, $path) = @_; + return File::Spec->join($self->{outDir}, $path); +} + +=head2 ext + +Accessor for the file extension currently in use for the files in this +storage directory. Usually either '.json' or '.jsonz'. + +=cut + +sub ext { + return shift->{ext}; +} + +=head2 encodedSize + +=cut + +sub encodedSize { + my ($self, $obj) = @_; + return length($self->{json}->encode($obj)); +} + +=head2 put + +=cut + +sub put { + my ($self, $path, $toWrite) = @_; + + $self->_write_htaccess; + + my $file = $self->fullPath($path); + my $fh = new IO::File $file, O_WRONLY | O_CREAT + or die "couldn't open $file: $!"; + flock $fh, LOCK_EX; + $fh->seek(0, SEEK_SET); + $fh->truncate(0); + if ($self->{compress}) { + binmode($fh, ":gzip") + or die "couldn't set binmode: $!"; + } + $fh->print($self->{json}->encode($toWrite)) + or die "couldn't write to $file: $!"; + $fh->close() + or die "couldn't close $file: $!"; +} + +=head2 get + +=cut + +sub get { + my ($self, $path, $default) = @_; + + my $file = $self->fullPath($path); + if (-s $file) { + my $OLDSEP = $/; + my $fh = new IO::File $file, O_RDONLY + or die "couldn't open $file: $!"; + binmode($fh, ":gzip") if $self->{compress}; + flock $fh, LOCK_SH; + undef $/; + eval { + $default = $self->{json}->decode(<$fh>) + }; if( $@ ) { + die "Error parsing JSON file $file: $@\n"; + } + $default or die "couldn't read from $file: $!"; + $fh->close() + or die "couldn't close $file: $!"; + $/ = $OLDSEP; + } + return $default; +} + +=head2 modify + +=cut + +sub modify { + my ($self, $path, $callback) = @_; + + $self->_write_htaccess; + + my $file = $self->fullPath($path); + my ($data, $assign); + my $fh = new IO::File $file, O_RDWR | O_CREAT + or die "couldn't open $file: $!"; + flock $fh, LOCK_EX; + # if the file is non-empty, + if (($fh->stat())[7] > 0) { + # get data + my $jsonString = join("", $fh->getlines()); + if ( length( $jsonString ) > 0 ) { + eval { + $data = $self->{json}->decode($jsonString); + }; if( $@ ) { + die "Error parsing JSON file $file: $@\n"; + } + } + # prepare file for re-writing + $fh->seek(0, SEEK_SET); + $fh->truncate(0); + } + # modify data, write back + $fh->print($self->{json}->encode($callback->($data))) + or die "couldn't write to $file: $!"; + $fh->close() + or die "couldn't close $file: $!"; +} + +1; diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..0a8010d4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Queen Mary, University of London + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LazyNCList.pm b/LazyNCList.pm new file mode 100644 index 00000000..b9f34d16 --- /dev/null +++ b/LazyNCList.pm @@ -0,0 +1,314 @@ +package LazyNCList; + +use strict; +use warnings; +use Carp; +use List::Util qw(max); + +use NCList; + +=head2 new + + Title : new + Usage : LazyNCList->new($attrs, $lazyClass, $makeLazy, + $measure, $output, $sizeThresh + Function: create an LazyNCList + Returns : an LazyNCList object + Args : $attrs is a reference to an ArrayRepr instance + $lazyClass is the class number to be used for 'lazy' + NCLists, which are references to sub-lists, + $makeLazy is a reference to a sub taking the arguments + (start, end, ID), which returns a "lazy feature" with the + given attributes + $loadChunk is a subroutine that takes a chunk ID number and returns the contents of that chunk ( + $measure is a reference to a sub that takes a feature to be + output, and returns the number of bytes that feature will + take up in the output + $output is a reference to a sub that, given a chunk ID and some data, + will output that data under that chunk ID + $sizeThresh is the target chunk size + +=cut + +sub new { + my ($class, $attrs, $lazyClass, $makeLazy, $loadChunk, + $measure, $output, $sizeThresh) = @_; + + my $self = { attrs => $attrs, + start => $attrs->makeFastGetter("Start"), + end => $attrs->makeFastGetter("End"), + setSublist => $attrs->makeSetter("Sublist"), + lazyClass => $lazyClass, + makeLazy => $makeLazy, + loadChunk => $loadChunk, + measure => $measure, + output => $output, + sizeThresh => $sizeThresh, + count => 0, + minStart => undef, + maxEnd => undef, + chunkNum => 1, + chunkSizes => [], + partialStack => [] }; + bless $self, $class; + + $self->addNewLevel(); + + return $self; +} + +sub importExisting { + my ($class, $attrs, $lazyClass, $count, $minStart, + $maxEnd, $loadChunk, $topLevelList) = @_; + + my $self = { attrs => $attrs, + lazyClass => $lazyClass, + start => $attrs->makeFastGetter("Start"), + end => $attrs->makeFastGetter("End"), + count => $count, + minStart => $minStart, + maxEnd => $maxEnd, + loadChunk => $loadChunk, + topLevelList => $topLevelList }; + bless $self, $class; + + $self->addNewLevel(); + + return $self; +} + +=head2 addSorted + + Title : addSorted + Usage : $ncl->addSorted($feat) + Function: Adds a single feature to the set of features in this LazyNCList; + features passed to this method are accumulated into "chunks"; + once a chunk grows to sizeThresh, the chunk is output. + The features given to addSorted must be sorted by the NCList sort. + Returns : nothing meaningful + Args : $feat is the feature to be added; + +=cut + +sub addSorted { + my ($self, $feat) = @_; + + $self->{count} += 1; + my $lastAdded = $self->{lastAdded}; + my $start = $self->{start}->( $feat ); + my $end = $self->{end}->( $feat ); + + if (defined($lastAdded)) { + my $lastStart = $self->{start}->($lastAdded); + my $lastEnd = $self->{end}->($lastAdded); + # check that the input is sorted + $lastStart <= $start + or die "input not sorted: got start $lastStart before $start"; + + die "input not sorted: got $lastStart..$lastEnd before $start..$end" + if $lastStart == $start && $lastEnd < $end; + } else { + # LazyNCList requires sorted input, so the start of the first feat + # is the minStart + $self->{minStart} = $start; + } + + $self->{lastAdded} = $feat; + + my $chunkSizes = $self->{chunkSizes}; + my $partialStack = $self->{partialStack}; + + for (my $level = 0; $level <= $#$partialStack; $level++) { + # due to NCList nesting, among other things, it's hard to be exactly + # precise about the size of the JSON serialization, but this will get + # us pretty close. + my $featSize = $self->{measure}->($feat); + my $proposedChunkSize = $chunkSizes->[$level] + $featSize; + #print STDERR "chunksize at $level is now " . $chunkSizes->[$level] . "; (next chunk is " . $self->{chunkNum} . ")\n"; + + # If this partial chunk is full, + if ( $proposedChunkSize > $self->{sizeThresh} && @{$partialStack->[$level]} ){ + # then we're finished with the current "partial" chunk (i.e., + # it's now a "complete" chunk rather than a partial one), so + # create a new NCList to hold all the features in this chunk. + my $lazyFeat = $self->finishChunk( $partialStack->[$level] ); + + # start a new partial chunk with the current feature + $partialStack->[$level] = [$feat]; + $chunkSizes->[$level] = $featSize; + + # and propagate $lazyFeat up to the next level + $feat = $lazyFeat; + + # if we're already at the highest level, + if ($level == $#{$self->{partialStack}}) { + # then we need to make a new level to have somewhere to put + # the new lazy feat + $self->addNewLevel(); + } + } else { + # add the current feature the partial chunk at this level + push @{$partialStack->[$level]}, $feat; + $chunkSizes->[$level] = $proposedChunkSize; + last; + } + } +} + +sub addNewLevel { + my ($self) = @_; + push @{$self->{partialStack}}, []; + push @{$self->{chunkSizes}}, 0; +} + +sub finishChunk { + my ($self, $featList) = @_; + my $newNcl = NCList->new($self->{start}, + $self->{end}, + $self->{setSublist}, + $featList); + my $chunkId = $self->{chunkNum}; + $self->{chunkNum} += 1; + $self->{output}->($newNcl->nestedList, $chunkId); + + $self->{maxEnd} = $newNcl->maxEnd unless defined($self->{maxEnd}); + $self->{maxEnd} = max($self->{maxEnd}, $newNcl->maxEnd); + + # return the lazy ("fake") feature representing this chunk + return $self->{makeLazy}->($newNcl->minStart, $newNcl->maxEnd, $chunkId); +} + +=head2 finish + + Title : finish + Usage : $ncl->finish() + Function: Once all features have been added (through addSorted), + call "finish" to flush all of the partial chunks. + After calling finish, you can access the "topLevelList" property. + Returns : nothing + +=cut + +sub finish { + my ($self) = @_; + my $level; + + for ($level = 0; $level < $#{$self->{partialStack}}; $level++) { + my $lazyFeat = $self->finishChunk($self->{partialStack}->[$level]); + + # pass $lazyFeat up to the next higher level. + # (the loop ends one level before the highest level, so there + # will always be at least one higher level) + push @{$self->{partialStack}->[$level + 1]}, $lazyFeat; + } + + # make sure there's a top-level NCL + $level = $#{$self->{partialStack}}; + my $newNcl = NCList->new($self->{start}, + $self->{end}, + $self->{setSublist}, + $self->{partialStack}->[$level]); + $self->{maxEnd} = max( grep defined, $self->{maxEnd}, $newNcl->maxEnd ); + #print STDERR "top level NCL has " . scalar(@{$self->{partialStack}->[$level]}) . " features\n"; + $self->{topLevelList} = $newNcl->nestedList; +} + +sub binarySearch { + my ($self, $arr, $item, $getter) = @_; + + my $low = -1; + my $high = $#{$arr} + 1; + my $mid; + + while ($high - $low > 1) { + $mid = int(($low + $high) / 2); + if ($getter->($arr->[$mid]) > $item) { + $high = $mid; + } else { + $low = $mid; + } + } + + # if we're iterating rightward, return the high index; + # if leftward, the low index + if ($getter == $self->{end}) { return $high } else { return $low }; +}; + +sub iterHelper { + my ($self, $arr, $from, $to, $fun, $inc, + $searchGet, $testGet, $path) = @_; + my $len = $#{$arr} + 1; + my $i = $self->binarySearch($arr, $from, $searchGet); + my $getChunk = $self->{attrs}->makeGetter("Chunk"); + my $getSublist = $self->{attrs}->makeGetter("Sublist"); + + while (($i < $len) + && ($i >= 0) + && (($inc * $testGet->($arr->[$i])) < ($inc * $to)) ) { + + if ($arr->[$i][0] == $self->{lazyClass}) { + my $chunkNum = $getChunk->($arr->[$i]); + my $chunk = $self->{loadChunk}->($chunkNum); + $self->iterHelper($chunk, $from, $to, $fun, $inc, + $searchGet, $testGet, [$chunkNum]); + } else { + $fun->($arr->[$i], [@$path, $i]); + } + + my $sublist = $getSublist->($arr->[$i]); + if (defined($sublist)) { + $self->iterHelper($sublist, $from, $to, $fun, $inc, + $searchGet, $testGet, [@$path, $i]); + } + $i += $inc; + } +} + +=head2 overlapCallback( $from, $to, \&func ) + +Calls the given function once for each of the intervals that overlap +the given interval if C<<$from <= $to>>, iterates left-to-right, otherwise +iterates right-to-left. + +=cut + +sub overlapCallback { + my ($self, $from, $to, $fun) = @_; + + croak "LazyNCList not loaded" unless defined($self->{topLevelList}); + + return unless $self->count; + + # inc: iterate leftward or rightward + my $inc = ($from > $to) ? -1 : 1; + # searchGet: search on start or end + my $searchGet = ($from > $to) ? $self->{start} : $self->{end}; + # testGet: test on start or end + my $testGet = ($from > $to) ? $self->{end} : $self->{start}; + # treats the root chunk as number 0 + $self->iterHelper($self->{topLevelList}, $from, $to, $fun, + $inc, $searchGet, $testGet, [0]); +} + +sub count { return shift->{count}; } + +sub maxEnd { return shift->{maxEnd}; } + +sub minStart { return shift->{minStart}; } + +sub topLevelList { return shift->{topLevelList}; } + +1; + +=head1 AUTHOR + +Mitchell Skinner Ejbrowse@arctur.usE + +Copyright (c) 2007-2011 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/LazyPatricia.pm b/LazyPatricia.pm new file mode 100644 index 00000000..a50c3243 --- /dev/null +++ b/LazyPatricia.pm @@ -0,0 +1,207 @@ +=head1 NAME + +LazyPatricia - a lazy PATRICIA tree + +=head1 SYNOPSIS + + my $trie = LazyPatricia::create({abc=>0, abcd=>1, abce=>2,abfoo=>3}); + use JSON 2; + print JSON::to_json($trie, {pretty=>1}); + +=head1 DESCRIPTION + +This class is a map where the keys are strings. The map supports fast +queries by key string prefix ("show me all the values for keys that +start with "abc"). It also supports lazily loading subtrees. + +Each edge is labeled with a substring of a key string. + +Each node in the tree has one or more children, each of which +represents a potential completion of the string formed by +concatenating all of the edge strings from that node up to the root. + +Nodes also have zero or one data items. + +Leaves have zero or one data items. + +Each loaded node is an array: + +Element 0 is the edge string; element 1 is the data item, or undefined +if there is none; any further elements are the child nodes, sorted +lexicographically by their edge string + +Each lazy node is just the edge string for the edge leading to the +lazy node. when the lazy node is loaded, the string gets replaced +with a loaded node array; lazy nodes and loaded nodes can be +distinguished by: + + "string" == typeof loaded_node[0] + "number" == typeof lazy_node[0] + +e.g., for the mappings: + + abc => 0 + abcd => 1 + abce => "baz" + abfoo => [3, 4] + abbar (subtree to be loaded lazily) + +the structure is: + + [, , ["ab", , + "bar", + ["c", 0, ["d", 1], + ["e", "baz"]], + ["foo", [3, 4]] + ] + ] + +The main goals for this structure were to minimize the JSON size on +the wire (so, no type tags in the JSON to distinguish loaded nodes, +lazy nodes, and leaves) while supporting lazy loading and reasonably +fast lookups. + +=cut + +package LazyPatricia; + +use strict; +use warnings; + +# the code below assumes that EDGESTRING is 0 and that +# SUBLIST is the highest-numbered of these constants +use constant EDGESTRING => 0; +use constant VALUE => 1; +use constant SUBLIST => 2; + +use Devel::Size qw( total_size ); + +=head2 create( \%mappings ) + +takes: a hash reference containing the mappings to put into the trie + +returns: trie structure described above + +=cut + +sub create { + my ($mappings) = @_; + my $tree = []; + $tree->[EDGESTRING]=""; + + my @keys = sort keys %$mappings; + + my @path = ($tree); + my $curNode; + # create one-char-per-node trie + foreach my $key (@keys) { + for (my $i = 1; $i <= length($key); $i++) { + if ($i < scalar(@path)) { + # if this key shares a prefix up to $i with previous keys, + # go to next $i + next if substr($key, $i - 1, 1) eq $path[$i][EDGESTRING]; + # if we get here, we know that this key differs from + # previous keys at $i, so we chop everything from $i + # onward from @path + @path = @path[0..($i - 1)]; + } + + # now we add new elements onto @path for the current key + $curNode = [substr($key, $i - 1, 1)]; + if (scalar(@{$path[-1]}) <= SUBLIST) { + $path[-1][SUBLIST] = $curNode; # first sublist for this prefix + } else { + push @{$path[-1]}, $curNode; # add to existing sublists + # since the keys are sorted, this means that the sublists + # will also be sorted + } + push @path, $curNode; + } + $path[length($key)][VALUE] = $mappings->{$key}; + } + + # Merge single-child nodes to make PATRICIA trie. + # This might not be the fastest way to make a PATRICIA trie, + # but at the moment it seems like the simplest. + for (my $i = SUBLIST; $i < scalar(@$tree); $i++) { + mergeNodes($tree->[$i]); + } + + #bless $tree, $class; + return $tree; +} + +sub mergeNodes { + my $parent = shift; + # if the parent has no children, return + return if (SUBLIST >= scalar(@$parent)); + # if the parent has exactly one child and no value + if (((SUBLIST + 1) == scalar(@$parent)) && (!defined $parent->[VALUE])) { + # merge the child with the parent + $parent->[EDGESTRING] .= $parent->[SUBLIST]->[EDGESTRING]; + my @mergeList = @{$parent->[SUBLIST]}[1..$#{$parent->[SUBLIST]}]; + splice @$parent, 1, scalar(@$parent) - 1, @mergeList; + mergeNodes($parent); + } else { + # try to merge sub nodes + for (my $i = SUBLIST; $i < scalar(@$parent); $i++) { + mergeNodes($parent->[$i]); + } + } +} + +sub partition { + my ($parent, $prefix, $threshold, $callback) = @_; + + # $total is the number of data items in the subtree rooted at $parent + # $thisChunk is $total minus the number of data items that have been + # split out into separate lazy-load sub-chunks. + my $total = 0; + my $thisChunk = 0; + if( defined $parent->[VALUE] ) { + my $vsize = total_size( $parent->[VALUE] ); + $total += $vsize; + $thisChunk += $vsize; + } + for (my $i = SUBLIST; $i < scalar(@$parent); $i++) { + if( defined $parent->[$i]->[VALUE] ) { + my $vsize = total_size( $parent->[$i]->[VALUE] ); + $total += $vsize; + $thisChunk += $vsize; + } + if (defined $parent->[$i]->[SUBLIST]) { + my ($subTotal, $subPartial) = + partition($parent->[$i], + $prefix . $parent->[$i][EDGESTRING], + $threshold, + $callback); + $total += $subTotal; + $thisChunk += $subPartial; + } + } + if (($thisChunk > $threshold) && ($prefix ne "")) { + $callback->($parent, $prefix, $thisChunk, $total); + $thisChunk = 0; + + # prune subtree from its parent + $parent->[1] = $parent->[0]; + $parent->[0] = int($total); + $#{$parent} = 1; + } + return ($total, $thisChunk); +} + +1; + +=head1 AUTHOR + +Mitchell Skinner Emitch_skinner@berkeley.eduE + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 00000000..96c1d049 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,106 @@ + +use strict; +use warnings; + +use ExtUtils::MakeMaker 6.30; + +my %WriteMakefileArgs = ( + "ABSTRACT" => "A modern web-based genome browser.", + "AUTHOR" => "Robert Buels ", + "BUILD_REQUIRES" => { + "Test::More" => 0, + "Test::Warn" => 0, + "Capture::Tiny" => 0, + "DBD::SQLite" => 0, + }, + "CONFIGURE_REQUIRES" => { + "ExtUtils::MakeMaker" => "6.30" + }, + "DISTNAME" => "JBrowse", + "EXE_FILES" => [ + "bin/ucsc-to-json.pl", + "bin/remove-track.pl", + "bin/generate-names.pl", + "bin/prepare-refseqs.pl", + "bin/wig-to-json.pl", + "bin/biodb-to-json.pl", + "bin/draw-basepair-track.pl", + "bin/flatfile-to-json.pl", + "bin/bam-to-json.pl", + ], + "LICENSE" => "perl", + "NAME" => "JBrowse", + "PREREQ_PM" => { + "Bio::Annotation::SimpleValue" => 0, + "Bio::FeatureIO" => 0, + "Bio::GFF3::LowLevel::Parser" => "1.4", + "Bio::Index::Fasta" => 0, + "Bio::OntologyIO" => 0, + "Bio::Root::Version" => "1.006000", + "Bio::SeqFeature::Annotated" => 0, + "Bio::SeqFeature::Lite" => 0, #< for Bio::DB::BAM + "Carp" => 0, + "Cache::Ref::FIFO" => 0, + "Cwd" => 0, + "DBI" => 0, + "Data::Dumper" => 0, + "Devel::Size" => 0, + "Digest::Crc32" => 0, + "Exporter" => 0, + "Fcntl" => 0, + "File::Basename" => 0, + "File::Copy::Recursive" => 0, + "File::Path" => 2, + "File::Spec" => 0, + "File::Spec::Functions" => 0, + "File::Temp" => 0, + "FindBin" => 0, + "Getopt::Long" => 0, + "Hash::Merge" => 0, + "Heap::Simple" => 0, + "Heap::Simple::XS" => 0, + "IO::File" => 0, + "JSON" => 2, + "JSON::XS" => 0, + "List::Util" => 0, + "POSIX" => 0, + "PerlIO::gzip" => 0, + "Pod::Usage" => 0, + "Parse::RecDescent" => 0, + "Scalar::Util" => 0, + "Storable" => 0, + "URI::Escape" => 0, + "base" => 0, + "constant" => 0, + "local::lib" => 0, + "strict" => 0, + "vars" => 0, + "warnings" => 0 + }, + "VERSION" => "dev", + "test" => { + "TESTS" => "t/*.t" + } +); + + +unless ( eval { ExtUtils::MakeMaker->VERSION(6.56) } ) { + my $br = delete $WriteMakefileArgs{BUILD_REQUIRES}; + my $pp = $WriteMakefileArgs{PREREQ_PM}; + for my $mod ( keys %$br ) { + if ( exists $pp->{$mod} ) { + $pp->{$mod} = $br->{$mod} if $br->{$mod} > $pp->{$mod}; + } + else { + $pp->{$mod} = $br->{$mod}; + } + } +} + +delete $WriteMakefileArgs{CONFIGURE_REQUIRES} + unless eval { ExtUtils::MakeMaker->VERSION(6.52) }; + +WriteMakefile(%WriteMakefileArgs); + + + diff --git a/NCLSorter.pm b/NCLSorter.pm new file mode 100644 index 00000000..7d0d3750 --- /dev/null +++ b/NCLSorter.pm @@ -0,0 +1,100 @@ +=head1 NAME + +NCLSorter - efficiently convert a stream of start-position-sorted features into a stream of NCL-sorted features + +=head1 SYNOPSIS + + my $sorter = NCLSorter->new( + $startIndex, $endIndex, + sub { $track->addFeature( $_[0] ), + ); + + while( my $feature = $conventional_stream->() ) { + $sorter->addSorted( $feature ); + } + +=head1 DESCRIPTION + +Takes a stream of features (represented by arrays) sorted by start +position, and outputs a stream of features sorted by the Nested +Containment List sorting algorithm. + +=head1 METHODS + +=cut + +package NCLSorter; +use strict; +use warnings; +use Carp; + +=head2 new( $startIndex, $endIndex, \&output ) + +Make a new NCLSorter which will repeatedly call the &output subroutine +with features. $startIndex and $endIndex are the numerical index of +the start and end coordinate of the input feature arrayref. + +=cut + +sub new { + # consumer: callback that receives the output sorted features + # start: index of the feature start position in the feature arrays + # end: index of the feature end position in the feature arrays + my ( $class, $start, $end, $consumer ) = @_; + my $self = { + consumer => $consumer, + pending => [], + start => $start, + end => $end + }; + bless $self, $class; +} + +=head2 addSorted( \@single_feature ) + +Add a feature arrayref. May or may not trigger an output. + +=cut + +sub addSorted { + my ($self, $toAdd) = @_; + + my $pending = $self->{pending}; + my $start = $self->{start}; + my $end = $self->{end}; + if ($#$pending >= 0) { + # if the new feature has a later start position, + if ($pending->[-1]->[$start] < $toAdd->[$start]) { + # then we're past all of the pending features, and we can flush them + $self->flush(); + } elsif ($pending->[-1]->[$start] > $toAdd->[$start]) { + croak "input not sorted: got " . $pending->[-1]->[$start] + . " .. " . $pending->[-1]->[$end] . " before " + . $toAdd->[$start] . " .. " . $toAdd->[$end]; + } + } + push @$pending, $toAdd; +} + +=head2 flush() + +Flush any pending features in the sort buffer to the output. Should +be called after the last feature has been added. + +=cut + +sub flush { + my ($self) = @_; + + my $consumer = $self->{consumer}; + my $pending = $self->{pending}; + my $end = $self->{end}; + + my @sorted = sort { $b->[$end] <=> $a->[$end] } @$pending; + foreach my $feat (@sorted) { + $consumer->($feat); + } + $#$pending = -1; +} + +1; diff --git a/NCList.pm b/NCList.pm new file mode 100644 index 00000000..6318a8f7 --- /dev/null +++ b/NCList.pm @@ -0,0 +1,129 @@ +#After +#Alekseyenko, A., and Lee, C. (2007). +#Nested Containment List (NCList): A new algorithm for accelerating +# interval query of genome alignment and interval databases. +#Bioinformatics, doi:10.1093/bioinformatics/btl647 +#http://bioinformatics.oxfordjournals.org/cgi/content/abstract/btl647v1 + +package NCList; + +use strict; +use warnings; +use List::Util qw(max); + +=head2 new + + Title : new + Usage : NCList->new($start, $end, $setSublist, $featList) + Function: create an NCList + Returns : an NCList object + Args : $featList is a reference to an array of arrays; + each of the inner arrays represents an interval. + $start is a reference to a sub that, given an inner array from + $featList, returns the start position of the interval + represented by that inner array. + $end is a reference to a sub that, given an inner array from + $featList, returns the end position of the interval + represented by that inner array. + $setSublist is a reference to a sub that, given an inner array from + $featList and a sublist reference, sets the "Sublist" attribute + on the array to the sublist. + +=cut + +sub new { + my ($class, $start, $end, $setSublist, $featList) = @_; + + my @features = sort { + if ($start->($a) != $start->($b)) { + $start->($a) - $start->($b); + } else { + $end->($b) - $end->($a); + } + } @$featList; + + #@sublistStack is a list of all the currently relevant sublists + #(one for each level of nesting) + my @sublistStack; + #$curlist is the currently active sublist + my $curList = []; + + my $self = { 'topList' => $curList, + 'setSublist' => $setSublist, + 'count' => scalar( @features ), + 'minStart' => ( @features ? $start->($features[0]) : undef ), + }; + bless $self, $class; + + push @$curList, $features[0] if @features; + + my $maxEnd = @features ? $end->($features[0]) : undef; + + my $topSublist; + for (my $i = 1; $i < @features; $i++) { + $maxEnd = max( $maxEnd, $end->( $features[$i] )); + #if this interval is contained in the previous interval, + if ($end->($features[$i]) < $end->($features[$i - 1])) { + #create a new sublist starting with this interval + push @sublistStack, $curList; + $curList = [$features[$i]]; + $setSublist->($features[$i - 1], $curList); + } else { + #find the right sublist for this interval + while (1) { + #if we're at the top level list, + if ($#sublistStack < 0) { + #just add the current feature + push @$curList, $features[$i]; + last; + } else { + $topSublist = $sublistStack[$#sublistStack]; + #if the last interval in the top sublist ends + #after the end of the current interval, + if ($end->($topSublist->[$#{$topSublist}]) + > $end->($features[$i]) ) { + #then curList is the first (deepest) sublist + #that the current feature fits into, and + #we add the current feature to curList + push @$curList, $features[$i]; + last; + } else { + #move on to the next shallower sublist + $curList = pop @sublistStack; + } + } + } + } + } + + $self->{maxEnd} = $maxEnd; + + return $self; +} + +sub maxEnd { + return shift->{maxEnd}; +} + +sub minStart { + return shift->{minStart}; +} + +sub nestedList { + return shift->{topList}; +} + +1; + +=head1 AUTHOR + +Mitchell Skinner Emitch_skinner@berkeley.eduE + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +=cut diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 00000000..54720fb2 --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,9 @@ +Parts of Afra may be considered a derivative work of JBrowse. JBrowse is +dual-licensed: GNU Lesser Public License (LGPL) 2.1 or above, Artistic License +(AL) 2.0. We choose to use JBrowse under AL 2.0 and the use of Apache Public +License (APL) for Afra complies with section 4.c.ii of AL. Thus all of Afra, +including the derivative work of JBrowse that forms a part of Afra, is covered +by APL. + +A list of changes made to the original JBrowse source code will be made +available by the author(s) on request or on completion of the project. diff --git a/NameHandler.pm b/NameHandler.pm new file mode 100644 index 00000000..a3ab5152 --- /dev/null +++ b/NameHandler.pm @@ -0,0 +1,113 @@ +=head1 NAME + +NameHandler - create indices of feature names + +=head1 SYNOPSIS + + # instantiate with a callback that gives the directory to use for a + # given reference sequence + my $nameHandler = NameHandler->new( + sub { "$trackDir/" . $_[0] . "/" . $track->{"track"}; }; + ); + + for my $name ( @feature_names ) { + $nameHandler->addName( $name ); + } + + # write out the finished names index + $nameHandler->finish; + +=head1 METHODS + +=cut + +package NameHandler; + +use strict; +use warnings; + +use Carp; +use File::Path; +use IO::File; + +use JSON 2; + +# index of the refseq name in the name array +# TODO: find a central place to put this knowledge +our $chromIndex = 3; + +my $nameFile = "names.txt"; + +=head1 new( \&directory_callback ) + +Make a new NameHandler. Takes a subroutine reference that should take +a reference sequence name as an argument and return the path to the +directory that should contain the name index we generate. + +=cut + +sub new { + my ($class, $trackDirForChrom) = @_; + + my $self = { + trackDirForChrom => $trackDirForChrom, + nameFiles => {} + }; + + bless $self, $class; + return $self; +} + +=head1 addName( \@name_record ) + +Name record (an arrayref) to add to the names index. + +=cut + +sub addName { + my ($self, $nameArr) = @_; + + my $chrom = $nameArr->[$chromIndex]; + + unless (defined($chrom)) { + carp "chrom not defined in " . JSON::to_json($nameArr) . "\n"; + } + + my $nameFile = $self->{nameFiles}->{$chrom} ||= $self->_newChrom($chrom); + $nameFile->print( JSON::to_json( $nameArr, {pretty => 0} ), "\n" ) + or die "couldn't write to file for $chrom: $!"; +} + + +# Given the name of the reference sequence, opens and returns a filehandle to the +# proper name index file. Makes a new directory to hold the file if +# necessary. +sub _newChrom { + my ($self, $chrom) = @_; + + my $chromDir = $self->{trackDirForChrom}->($chrom); + mkpath( $chromDir ) unless -e $chromDir; + + my $namefile = "$chromDir/$nameFile"; + + my $fh = IO::File->new( $namefile, '>' ) or die "$! writing $namefile"; + return $fh; +} + +=head1 finish + +Finalize and flush to disk any currently open name index. + +=cut + +sub finish { + my ($self) = @_; + foreach my $chrom (keys %{$self->{nameFiles}}) { + my $fh = $self->{nameFiles}->{$chrom}; + if( $fh && $fh->opened ) { + $fh->close or die "$! closing names file for ref seq $chrom"; + } + } +} + +sub DESTROY { shift->finish } diff --git a/README.mkd b/README.mkd new file mode 100644 index 00000000..85ca79a1 --- /dev/null +++ b/README.mkd @@ -0,0 +1,17 @@ +## crowdsourcing platform for gene annotation + +### ... is under construction + +![at work](http://www.highdefdiscnews.com/screenshots/a_bugs_life_1.png) + +### ... development notes + +https://github.com/yeban/afra/wiki + +--- + +

+Photo courtesy: http://www.highdefdiscnews.com/ +
+Afra is copyright (©) 2013 Queen Mary, University of London. All rights reserved. +

diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..0f6552a0 --- /dev/null +++ b/Rakefile @@ -0,0 +1,131 @@ +desc 'Install dependencies.' +task 'install' do + puts 'installing gems ...' + system("gem install --file Gemfile") + + puts + puts 'installing perl modules ...' + %x{ +done_message () { + if [ $? == 0 ]; then + echo " done." + if [ "x$1" != "x" ]; then + echo $1; + fi + else + echo " failed." $2 + fi +} + +# log information about this system +( + echo '============== System information ===='; + set -x; + lsb_release -a; + uname -a; + sw_vers; + system_profiler; + grep MemTotal /proc/meminfo; + echo; echo; +); + +echo -n "Installing Perl prerequisites ..." +if ! ( perl -MExtUtils::MakeMaker -e 1 >/dev/null 2>&1); then + echo; + echo "WARNING: Your Perl installation does not seem to include a complete set of core modules. Attempting to cope with this, but if installation fails please make sure that at least ExtUtils::MakeMaker is installed. For most users, the best way to do this is to use your system's package manager: apt, yum, fink, homebrew, or similar."; +fi; +( set -x; + bin/cpanm -v --notest -l $PWD/.extlib/ --installdeps . < /dev/null; + bin/cpanm -v --notest -l $PWD/.extlib/ --installdeps . < /dev/null; + set -e; + bin/cpanm -v --notest -l $PWD/.extlib/ --installdeps . < /dev/null; +); +done_message "" "As a first troubleshooting step, make sure development libraries and header files for Zlib are installed and try again."; + } + + puts + puts 'installing npm packages ...' + system('npm install') + + puts 'installing bower packages ...' + system("npm run-script bower") + + puts + puts 'AMDfying jquery.ui ...' + system("npm run-script amdify-jquery") +end + +desc 'Migrate database.' +task 'db:migrate', [:version] do |t, args| + require_relative 'app' + version = Integer(args[:version]) rescue nil + App.migrate version: version +end + +desc 'Reset database.' +task 'db:reinit' do + require_relative 'app' + migration_first = 3 + migration_last = App.current_migration + App.migrate version: migration_first + App.migrate version: migration_last +end + +desc 'Configure.' +task 'configure' do + require_relative 'app' + App.init_config + App.load_models + + unless Setting['session_secret'] + begin + require 'securerandom' + Setting.create(key: 'session_secret', value: SecureRandom.hex(64)) + rescue LoadError, NotImplementedError + # SecureRandom raises a NotImplementedError if no random device is available + set :session_secret, "%064x" % Kernel.rand(2**256-1) + Setting.create(key: 'session_secret', value: "%064x" % Kernel.rand(2**256-1)) + end + end + + print 'Facebook App ID: ' + fb_app_id = STDIN.gets.chomp + Setting.create(key: 'facebook_app_id', value: fb_app_id) + + print 'Facebook App secret: ' + fb_app_secret = STDIN.gets.chomp + Setting.create(key: 'facebook_app_secret', value: fb_app_secret) +end + +desc 'Create user.' +task 'user:new' do + require_relative 'app' + App.init_config + App.load_models + + print 'Name: ' + name = STDIN.gets.chomp + print 'Email: ' + email = STDIN.gets.chomp + print 'Pasword: ' + password = STDIN.gets.chomp + User.create(name: name, email: email, password: password) +end + +desc 'Create mock users.' +task 'mock:users' do + require_relative 'app' + App.init_config + App.load_models + + User.create(name: 'Mario', email: 'mario@toadstool.com', password: 'mario') + User.create(name: 'Luigi', email: 'luigi@toadstool.com', password: 'luigi') + User.create(name: 'Yoshi', email: 'yoshi@toadstool.com', password: 'yoshi') +end + +desc 'Test auto-check' +task 'auto-check' do + +end + +task default: [:install, :'db:migrate', :configure] diff --git a/Vcf.pm b/Vcf.pm new file mode 100644 index 00000000..1ce0a908 --- /dev/null +++ b/Vcf.pm @@ -0,0 +1,3304 @@ +package Vcf; + +our $VERSION = 'r731'; + +# http://vcftools.sourceforge.net/specs.html +# http://www.1000genomes.org/wiki/Analysis/Variant%20Call%20Format/vcf-variant-call-format-version-41 +# http://www.1000genomes.org/wiki/doku.php?id=1000_genomes:analysis:variant_call_format +# http://www.1000genomes.org/wiki/doku.php?id=1000_genomes:analysis:vcf4.0 +# http://www.1000genomes.org/wiki/doku.php?id=1000_genomes:analysis:vcf_4.0_sv +# http://www.1000genomes.org/wiki/doku.php?id=1000_genomes:analysis:vcf3.3 +# http://www.1000genomes.org/wiki/doku.php?id=1000_genomes:analysis:vcfv3.2 +# +# Authors: petr.danecek@sanger +# for VCF v3.2, v3.3, v4.0, v4.1 +# + +=head1 NAME + +Vcf.pm. Module for validation, parsing and creating VCF files. + Supported versions: 3.2, 3.3, 4.0, 4.1 + +=head1 SYNOPSIS + +From the command line: + perl -MVcf -e validate example.vcf + perl -I/path/to/the/module/ -MVcf -e validate_v32 example.vcf + +From a script: + use Vcf; + + my $vcf = Vcf->new(file=>'example.vcf.gz',region=>'1:1000-2000'); + $vcf->parse_header(); + + # Do some simple parsing. Most thorough but slowest way how to get the data. + while (my $x=$vcf->next_data_hash()) + { + for my $gt (keys %{$$x{gtypes}}) + { + my ($al1,$sep,$al2) = $vcf->parse_alleles($x,$gt); + print "\t$gt: $al1$sep$al2\n"; + } + print "\n"; + } + + # This will split the fields and print a list of CHR:POS + while (my $x=$vcf->next_data_array()) + { + print "$$x[0]:$$x[1]\n"; + } + + # This will return the lines as they were read, including the newline at the end + while (my $x=$vcf->next_line()) + { + print $x; + } + + # Only the columns NA00001, NA00002 and NA00003 will be printed. + my @columns = qw(NA00001 NA00002 NA00003); + print $vcf->format_header(\@columns); + while (my $x=$vcf->next_data_array()) + { + # this will recalculate AC and AN counts, unless $vcf->recalc_ac_an was set to 0 + print $vcf->format_line($x,\@columns); + } + + $vcf->close(); + +=cut + + +use strict; +use warnings; +use Carp; +use Exporter; +use Data::Dumper; +use POSIX ":sys_wait_h"; + +use vars qw/@ISA @EXPORT/; +@ISA = qw/Exporter/; +@EXPORT = qw/validate validate_v32/; + +=head2 validate + + About : Validates the VCF file. + Usage : perl -MVcf -e validate example.vcf.gz # (from the command line) + validate('example.vcf.gz'); # (from a script) + validate(\*STDIN); + Args : File name or file handle. When no argument given, the first command line + argument is interpreted as the file name. + +=cut + +sub validate +{ + my ($fh) = @_; + + if ( !$fh && @ARGV ) { $fh = $ARGV[0]; } + + my $vcf; + if ( $fh ) { $vcf = fileno($fh) ? Vcf->new(fh=>$fh) : Vcf->new(file=>$fh); } + else { $vcf = Vcf->new(fh=>\*STDIN); } + + $vcf->run_validation(); +} + + +=head2 validate_v32 + + About : Same as validate, but assumes v3.2 VCF version. + Usage : perl -MVcf -e validate_v32 example.vcf.gz # (from the command line) + Args : File name or file handle. When no argument given, the first command line + argument is interpreted as the file name. + +=cut + +sub validate_v32 +{ + my ($fh) = @_; + + if ( !$fh && @ARGV && -e $ARGV[0] ) { $fh = $ARGV[0]; } + + my %params = ( version=>'3.2' ); + + my $vcf; + if ( $fh ) { $vcf = fileno($fh) ? Vcf->new(%params, fh=>$fh) : Vcf->new(%params, file=>$fh); } + else { $vcf = Vcf->new(%params, fh=>\*STDIN); } + + $vcf->run_validation(); +} + + +=head2 new + + About : Creates new VCF reader/writer. + Usage : my $vcf = Vcf->new(file=>'my.vcf', version=>'3.2'); + Args : + fh .. Open file handle. If neither file nor fh is given, open in write mode. + file .. The file name. If neither file nor fh is given, open in write mode. + region .. Optional region to parse (requires tabix indexed VCF file) + silent .. Unless set to 0, warning messages may be printed. + strict .. Unless set to 0, the reader will die when the file violates the specification. + version .. If not given, '4.0' is assumed. The header information overrides this setting. + +=cut + +sub new +{ + my ($class,@args) = @_; + my $self = {@args}; + bless $self, ref($class) || $class; + + $$self{silent} = 0 unless exists($$self{silent}); + $$self{strict} = 0 unless exists($$self{strict}); + $$self{buffer} = []; # buffer stores the lines in the reverse order + $$self{columns} = undef; # column names + $$self{mandatory} = ['CHROM','POS','ID','REF','ALT','QUAL','FILTER','INFO'] unless exists($$self{mandatory}); + $$self{reserved}{cols} = {CHROM=>1,POS=>1,ID=>1,REF=>1,ALT=>1,QUAL=>1,FILTER=>1,INFO=>1,FORMAT=>1} unless exists($$self{reserved_cols}); + $$self{recalc_ac_an} = 1; + $$self{has_header} = 0; + $$self{default_version} = '4.1'; + $$self{versions} = [ qw(Vcf3_2 Vcf3_3 Vcf4_0 Vcf4_1) ]; + if ( !exists($$self{max_line_len}) && exists($ENV{MAX_VCF_LINE_LEN}) ) { $$self{max_line_len} = $ENV{MAX_VCF_LINE_LEN} } + $$self{fix_v40_AGtags} = $ENV{DONT_FIX_VCF40_AG_TAGS} ? 0 : 1; + my %open_args = (); + if ( exists($$self{region}) ) + { + $open_args{region}=$$self{region}; + if ( !exists($$self{print_header}) ) { $$self{print_header}=1; } + } + if ( exists($$self{print_header}) ) { $open_args{print_header}=$$self{print_header}; } + return $self->_open(%open_args); +} + +sub throw +{ + my ($self,@msg) = @_; + confess @msg,"\n"; +} + +sub warn +{ + my ($self,@msg) = @_; + if ( $$self{silent} ) { return; } + if ( $$self{strict} ) { $self->throw(@msg); } + warn @msg; +} + +sub _open +{ + my ($self,%args) = @_; + + if ( !exists($$self{fh}) && !exists($$self{file}) ) + { + # Write mode, the version must be supplied by the user + return $self->_set_version(exists($$self{version}) ? $$self{version} : $$self{default_version}); + } + + # Open the file unless filehandle is provided + if ( !exists($$self{fh}) ) + { + if ( !defined $$self{file} ) { $self->throw("Undefined value passed to Vcf->new(file=>undef)."); } + my $cmd = "<$$self{file}"; + + my $tabix_args = ''; + if ( exists($args{print_header}) && $args{print_header} ) { $tabix_args .= ' -h '; } + $tabix_args .= qq['$$self{file}']; + if ( exists($args{region}) && defined($args{region}) ) { $tabix_args .= qq[ '$args{region}']; } + + if ( -e $$self{file} && $$self{file}=~/\.gz/i ) + { + if ( exists($args{region}) && defined($args{region}) ) + { + $cmd = "tabix $tabix_args |"; + } + else { $cmd = "gunzip -c '$$self{file}' |"; } + $$self{check_exit_status} = 1; + } + elsif ( $$self{file}=~m{^(?:http|ftp)://} ) + { + if ( !exists($args{region}) ) { $tabix_args .= ' .'; } + $cmd = "tabix $tabix_args |"; + $$self{check_exit_status} = 1; + } + open($$self{fh},$cmd) or $self->throw("$cmd: $!"); + } + + # Set the correct VCF version, but only when called for the first time + my $vcf = $self; + if ( !$$self{_version_set} ) + { + my $first_line = $self->next_line(); + $vcf = $self->_set_version($first_line); + $self->_unread_line($first_line); + } + return $vcf; +} + + + +=head2 open + + About : (Re)Open file. No need to call this explicitly unless reading from a different + region is requested. + Usage : $vcf->open(); # Read from the start + $vcf->open(region=>'1:12345-92345'); + Args : region .. Supported only for tabix indexed files + +=cut + +sub open +{ + my ($self,%args) = @_; + $self->close(); + $self->_open(%args); +} + + +=head2 close + + About : Close the filehandle + Usage : $vcf->close(); + Args : none + +=cut + +sub close +{ + my ($self) = @_; + if ( !$$self{fh} ) { return; } + close($$self{fh}); + delete($$self{fh}); +} + + +=head2 next_line + + About : Reads next VCF line. + Usage : my $vcf = Vcf->new(); + my $x = $vcf->next_line(); + Args : none + +=cut + +sub next_line +{ + my ($self) = @_; + if ( @{$$self{buffer}} ) { return shift(@{$$self{buffer}}); } + + my $line; + if ( !exists($$self{max_line_len}) ) + { + $line = readline($$self{fh}); + } + else + { + while (1) + { + $line = readline($$self{fh}); + if ( !defined $line ) { last; } + + my $len = length($line); + if ( $len>$$self{max_line_len} && !($line=~/^#/) ) + { + if ( !($line=~/^([^\t]+)\t([^\t]+)/) ) { $self->throw("Could not parse the line: $line"); } + $self->warn("The VCF line too long, ignoring: $1 $2 .. len=$len\n"); + next; + } + last; + } + } + if ( !defined $line && $$self{check_exit_status} ) + { + my $pid = waitpid(-1, WNOHANG); + if ( $pid!=0 && $pid!=-1 && $? !=0 ) + { + $self->throw("Error reading VCF file.\n"); + } + } + return $line; +} + +sub _unread_line +{ + my ($self,$line) = @_; + unshift @{$$self{buffer}}, $line; + return; +} + + +=head2 next_data_array + + About : Reads next VCF line and splits it into an array. The last element is chomped. + Usage : my $vcf = Vcf->new(); + $vcf->parse_header(); + my $x = $vcf->next_data_array(); + Args : Optional line to parse + +=cut + +sub next_data_array +{ + my ($self,$line) = @_; + if ( !$line ) { $line = $self->next_line(); } + if ( !$line ) { return undef; } + if ( ref($line) eq 'ARRAY' ) { return $line; } + my @items = split(/\t/,$line); + chomp($items[-1]); + return \@items; +} + + +=head2 set_samples + + About : Parsing big VCF files with many sample columns is slow, not parsing unwanted samples may speed things a bit. + Usage : my $vcf = Vcf->new(); + $vcf->set_samples(include=>['NA0001']); # Exclude all but this sample. When the array is empty, all samples will be excluded. + $vcf->set_samples(exclude=>['NA0003']); # Include only this sample. When the array is empty, all samples will be included. + my $x = $vcf->next_data_hash(); + Args : Optional line to parse + +=cut + +sub set_samples +{ + my ($self,%args) = @_; + + if ( exists($args{include}) ) + { + for (my $i=0; $i<@{$$self{columns}}; $i++) { $$self{samples_to_parse}[$i] = 0; } + for my $sample (@{$args{include}}) + { + if ( !exists($$self{has_column}{$sample}) ) { $self->throw("The sample not present in the VCF file: [$sample]\n"); } + my $idx = $$self{has_column}{$sample} - 1; + $$self{samples_to_parse}[$idx] = 1; + } + } + + if ( exists($args{exclude}) ) + { + for (my $i=0; $i<@{$$self{columns}}; $i++) { $$self{samples_to_parse}[$i] = 1; } + for my $sample (@{$args{exclude}}) + { + if ( !exists($$self{has_column}{$sample}) ) { $self->throw("The sample not present in the VCF file: [$sample]\n"); } + my $idx = $$self{has_column}{$sample} - 1; + $$self{samples_to_parse}[$idx] = 0; + } + } +} + + +sub _set_version +{ + my ($self,$version_line) = @_; + + if ( $$self{_version_set} ) { return $self; } + $$self{_version_set} = 1; + + $$self{version} = $$self{default_version}; + if ( $version_line ) + { + if ( $version_line=~/^(\d+(?:\.\d+)?)$/ ) + { + $$self{version} = $1; + undef $version_line; + } + elsif ( !($version_line=~/^##fileformat=/i) or !($version_line=~/(\d+(?:\.\d+)?)\s*$/i) ) + { + $self->warn("Could not parse the fileformat version string [$version_line], assuming VCFv$$self{default_version}\n"); + undef $version_line; + } + else + { + $$self{version} = $1; + } + } + + my $reader; + if ( $$self{version} eq '3.2' ) { $reader=Vcf3_2->new(%$self); } + elsif ( $$self{version} eq '3.3' ) { $reader=Vcf3_3->new(%$self); } + elsif ( $$self{version} eq '4.0' ) { $reader=Vcf4_0->new(%$self); } + elsif ( $$self{version} eq '4.1' ) { $reader=Vcf4_1->new(%$self); } + else + { + $self->warn(qq[The version "$$self{version}" not supported, assuming VCFv$$self{default_version}\n]); + $$self{version} = '4.1'; + $reader = Vcf4_1->new(%$self); + } + + $self = $reader; + # When changing version, change also the fileformat header line + if ( exists($$self{header_lines}) && exists($$self{header_lines}[0]{key}) && $$self{header_lines}[0]{key} eq 'fileformat' ) + { + shift(@{$$self{header_lines}}); + } + + return $self; +} + + +#--------------------------------------- + +package VcfReader; +use base qw(Vcf); +use strict; +use warnings; +use Carp; +use Data::Dumper; + +sub new +{ + my ($class,@args) = @_; + my $self = {@args}; + bless $self, ref($class) || $class; + return $self; +} + + +=head2 next_data_hash + + About : Reads next VCF line and splits it into a hash. This is the slowest way to obtain the data. + Usage : my $vcf = Vcf->new(); + $vcf->parse_header(); + my $x = $vcf->next_data_hash(); + + # Or having a VCF data line $line + my $x = $vcf->next_data_hash($line); + + Args : Optional line to parse. + +=cut + +sub next_data_hash +{ + my ($self,$line) = @_; + if ( !$line ) { $line = $self->next_line(); } + if ( !$line ) { return undef; } + my @items; + if ( ref($line) eq 'ARRAY' ) { @items = @$line; } + else { @items = split(/\t/,$line); } + chomp($items[-1]); + + my $cols = $$self{columns}; + if ( !$cols ) + { + $self->_fake_column_names(scalar @items - 9); + $cols = $$self{columns}; + } + + # Check the number of columns + if ( scalar @items != scalar @$cols ) + { + if ( $line=~/^\s*$/ ) { $self->throw("Sorry, empty lines not allowed.\n"); } + my $c = substr($line,0,1); + if ( $c eq '#' ) + { + if ( !$$self{header_parsed} ) { $self->throw("FIXME: parse_header must be called before next_data_hash.\n"); } + else { $self->throw("Multiple header blocks (^#) not allowed.\n"); } + } + + if ( $items[-1] eq '' ) + { + my $nremoved = 0; + while ( $items[-1] eq '' ) { pop(@items); $nremoved++; } + if ( $nremoved && !$$self{trailing_tabs_warned} ) + { + $self->warn("Broken VCF: empty columns (trailing TABs) starting at $items[0]:$items[1].\n"); + $$self{trailing_tabs_warned} = 1; + } + } + if ( scalar @items != scalar @$cols ) + { + my @test = split(/\s+/,$line); + if ( scalar @test == scalar @$cols ) { $self->warn("(Were spaces used instead of tabs?)\n\n"); } + else { $self->throw(sprintf "Wrong number of fields%s; expected %d, got %d. The offending line was:\n[%s]\n\n", + exists($$self{file}) ? "in $$self{file}" : '', scalar @$cols, scalar @items, join("\t",@items)); } + + @items = @test; + } + } + my %out; + + # Mandatory fields + $out{CHROM} = $items[0]; + $out{POS} = $items[1]; + $out{ID} = $items[2]; + $out{REF} = $items[3]; + $out{ALT} = [ split(/,/,$items[4]) ]; + $out{QUAL} = $items[5]; + $out{FILTER} = [ split(/;/,$items[6]) ]; + + # INFO, e.g. NS=58;DP=258;AF=0.786;DB;H2 + if ( defined $items[7] ) + { + my %hash; + for my $info (split(/;/,$items[7])) + { + my ($key,$val) = split(/=/,$info); + if ( !defined $key ) + { + $self->warn("Broken VCF file, empty INFO field at $items[0]:$items[1]\n"); + next; + } + if ( defined $val ) + { + $hash{$key} = $val; + } + elsif ( exists($$self{header}{INFO}{$key}) ) + { + $hash{$key} = $$self{header}{INFO}{$key}{default}; + } + else + { + $hash{$key} = undef; + } + } + $out{INFO} = \%hash; + } + + # The FORMAT field may not be present. GT:GQ:DP:HQ + my $format; + if ( $$cols[8] || $items[8] ) + { + $format = $out{FORMAT} = [ split(/:/,$items[8]) ]; + if ( (!$$format[0] || $$format[0] ne 'GT') && !$$self{ignore_missing_GT} ) { $self->warn("Expected GT as the first genotype field at $items[0]:$items[1]\n"); } + } + + # Genotype fields + my %gtypes; + my $check_nformat = $$self{drop_trailings} ? 0 : 1; + for (my $icol=9; $icol<@items; $icol++) + { + if ( $items[$icol] eq '' ) { $self->warn("Empty column $$cols[$icol] at $items[0]:$items[1]\n"); next; } + if ( exists($$self{samples_to_parse}) && !$$self{samples_to_parse}[$icol] ) { next; } + + my @fields = split(/:/, $items[$icol]); + if ( $check_nformat && @fields != @$format ) + { + $self->warn("Different number of fields in the format and the column $$cols[$icol] at $items[0]:$items[1] (" + .scalar @fields." vs ".scalar @$format.": [",join(',',@fields),"] vs [",join(',',@$format),"])\n"); + } + my %hash; + for (my $ifield=0; $ifield<@fields; $ifield++) + { + $hash{$$format[$ifield]} = $fields[$ifield]; + } + $gtypes{$$cols[$icol]} = \%hash; + } + $out{gtypes} = \%gtypes; + + return \%out; +} + + +=head2 parse_header + + About : Reads (and stores) the VCF header. + Usage : my $vcf = Vcf->new(); $vcf->parse_header(); + Args : silent .. do not warn about duplicate header lines + +=cut + +sub parse_header +{ + my ($self,%args) = @_; + + # First come the header lines prefixed by ## + while ($self->_next_header_line(%args)) { ; } + + # Now comes the column names line prefixed by # + $self->_read_column_names(); + + $$self{header_parsed} = 1; +} + + +=head2 _next_header_line + + About : Stores the header lines and meta information, such as fields types, etc. + Args : silent .. do not warn about duplicate column names + +=cut + +sub _next_header_line +{ + my ($self,%args) = @_; + my $line = $self->next_line(); + if ( !defined $line ) { return undef; } + if ( substr($line,0,2) ne '##' ) + { + $self->_unread_line($line); + return undef; + } + + my $rec = $self->parse_header_line($line); + if ( $rec ) { $self->add_header_line($rec,%args); } + + return $rec; +} + +=head2 get_header_line + + Usage : $vcf->get_header_line(key=>'INFO', ID=>'AC') + $vcf->get_header_line(key=>'FILTER', ID=>'q10') + $vcf->get_header_line(key=>'reference') + $vcf->get_header_line(key=>'contig',ID=>'20') + Args : Header line filter as in the example above + Returns : List ref of header line hashes matching the filter + +=cut + +sub get_header_line +{ + my ($self,%filter) = @_; + + my $key = $filter{key}; + delete($filter{key}); + + my $id = $filter{ID}; + + my @out; + while (my ($hline_key,$hline_hash) = each %{$$self{header}}) + { + if ( $key ne $hline_key ) { next; } + + if ( defined $id ) + { + if ( !exists($$hline_hash{$id}) ) { next; } + $hline_hash = $$hline_hash{$id}; + } + + my $match = 1; + while (my ($fkey,$fval) = each %filter) + { + if ( !exists($$hline_hash{$fkey}) or $$hline_hash{$fkey} ne $fval ) + { + $match=0; + last; + } + } + if ( $match ) { push @out,$hline_hash } + } + return \@out; +} + + +=head2 add_header_line + + Usage : $vcf->add_header_line({key=>'INFO', ID=>'AC',Number=>-1,Type=>'Integer',Description=>'Allele count in genotypes'}) + $vcf->add_header_line({key=>'reference',value=>'1000GenomesPilot-NCBI36'}) + Args : Header line hash as in the example above + Hash with additional parameters [optional] + silent .. do not warn about existing header keys + append .. append timestamp to the name of the new one + Returns : + +=cut + +sub add_header_line +{ + my ($self,$rec,%args) = @_; + + if ( !%args ) { $args{silent}=0; } + + my $key = $$rec{key}; + if ( !$key ) { $self->throw("Missing key: ",Dumper($rec)); } + + if ( exists($$rec{Type}) ) + { + if ( !exists($$rec{default}) ) + { + my $type = $$rec{Type}; + if ( exists($$self{defaults}{$type}) ) { $$rec{default}=$$self{defaults}{$type}; } + else { $$rec{default}=$$self{defaults}{default}; } + } + if ( !exists($$rec{handler}) ) + { + my $type = $$rec{Type}; + if ( !exists($$self{handlers}{$type}) ) + { + $self->warn("Unknown type [$type]\n"); + $type = 'String'; + $$rec{Type} = $type; + } + if ( exists($$self{handlers}{$type}) ) { $$rec{handler}=$$self{handlers}{$type}; } + else { $self->throw("Unknown type [$type].\n"); } + } + } + + if ( exists($$rec{ID}) ) + { + my $id = $$rec{ID}; + if ( exists($$self{header}{$key}{$id}) ) { $self->remove_header_line(%$rec); } + $$self{header}{$key}{$id} = $rec; + push @{$$self{header_lines}}, $rec; + return; + } + + if ( $args{append} ) + { + my @tm = gmtime(time); + $key = sprintf "%s_%d%.2d%.2d", $key,$tm[5]+1900,$tm[4],$tm[3]; + my $i = 1; + while ( exists($$self{header}{$key.'.'.$i}) ) { $i++; } + $key = $key.'.'.$i; + $$rec{key} = $key; + } + + if ( $self->_header_line_exists($key,$rec) ) { $self->remove_header_line(%$rec); } + + push @{$$self{header}{$key}}, $rec; + if ( $$rec{key} eq 'fileformat' ) + { + unshift @{$$self{header_lines}}, $rec; + } + else + { + push @{$$self{header_lines}}, $rec; + } +} + +sub _header_line_exists +{ + my ($self,$key,$rec) = @_; + if ( !exists($$self{header}{$key}) ) { return 0; } + if ( $key eq 'fileformat' ) { return 1; } + for my $hrec (@{$$self{header}{$key}}) + { + my $differ = 0; + for my $item (keys %$rec) + { + if ( !exists($$hrec{$item}) ) { $differ=1; last; } + if ( $$hrec{$item} ne $$rec{$item} ) { $differ=1; last; } + } + if ( !$differ ) { return $hrec; } + } + return 0; +} + +=head2 remove_header_line + + Usage : $vcf->remove_header_line(key=>'INFO', ID=>'AC') + Args : + Returns : + +=cut + +sub remove_header_line +{ + my ($self,%args) = @_; + my $key = $args{key}; + for (my $i=0; $i<@{$$self{header_lines}}; $i++) + { + my $line = $$self{header_lines}[$i]; + if ( $$line{key} ne $key ) { next; } + if ( exists($args{ID}) ) + { + if ( $args{ID} ne $$line{ID} ) { next; } + delete($$self{header}{$key}{$args{ID}}); + splice(@{$$self{header_lines}},$i,1); + } + else + { + my $to_be_removed = $self->_header_line_exists($key,\%args); + if ( !$to_be_removed ) { next; } + for (my $j=0; $j<@{$$self{header}{$key}}; $j++) + { + if ( $$self{header}{$key}[$j] eq $to_be_removed ) { splice(@{$$self{header}{$key}},$j,1); last; } + } + splice(@{$$self{header_lines}},$i,1); + } + } +} + + +=head2 parse_header_line + + Usage : $vcf->parse_header_line(q[##reference=1000GenomesPilot-NCBI36]) + $vcf->parse_header_line(q[##INFO=NS,1,Integer,"Number of Samples With Data"]) + Args : + Returns : + +=cut + +sub parse_header_line +{ + my ($self,$line) = @_; + + chomp($line); + $line =~ s/^##//; + + if ( !($line=~/^([^=]+)=/) ) { return { key=>$line, value=>'' }; } + my $key = $1; + my $value = $'; + + my $desc; + if ( $value=~/,\s*\"([^\"]+)\"\s*$/ ) { $desc=$1; $value=$`; } + + if ( !$desc ) { return { key=>$key, value=>$value }; } + + if ( $key eq 'INFO' or $key eq 'FORMAT' ) + { + my ($id,$number,$type,@rest) = split(/,\s*/,$value); + if ( !$type or scalar @rest ) { $self->throw("Could not parse the header line: $line\n"); } + return { key=>$key, ID=>$id, Number=>$number, Type=>$type, Description=>$desc }; + } + if ( $key eq 'FILTER' ) + { + my ($id,@rest) = split(/,\s*/,$value); + if ( !$id or scalar @rest ) { $self->throw("Could not parse the header line: $line\n"); } + return { key=>$key, ID=>$id, Description=>$desc }; + } + $self->throw("Could not parse the header line: $line\n"); +} + +=head2 _read_column_names + + About : Stores the column names as array $$self{columns} and hash $$self{has_column}{COL_NAME}=index. + The indexes go from 1. + Usage : $vcf->_read_column_names(); + Args : none + +=cut + +sub _read_column_names +{ + my ($self) = @_; + my $line = $self->next_line(); + if ( !defined $line or substr($line,0,1) ne '#' ) { $self->throw("Broken VCF header, no column names?"); } + $$self{column_line} = $line; + + my @cols = split(/\t/, substr($line,1)); + chomp($cols[-1]); + + my $nremoved = 0; + for (my $i=0; $i<@cols; $i++) + { + if ( !($cols[$i]=~/^\s*$/) ) { next; } + $self->warn(sprintf "Empty fields in the header line, the column %d is empty, removing.\n",$i+1+$nremoved); + $nremoved++; + splice(@cols,$i,1); + } + + my $ncols = scalar @cols; + if ( $ncols == 1 ) + { + # If there is only one name, it can be space-separated instead of tab separated + @cols = split(/\s+/, $cols[0]); + $ncols = scalar @cols; + chomp($line); + if ( $ncols <= 1 ) { $self->warn("Could not parse the column names. [$line]\n"); return; } + $self->warn("The column names not tab-separated? [$line]\n"); + } + + my $fields = $$self{mandatory}; + my $nfields = scalar @$fields; + + # Check the names of the mandatory columns + if ( $ncols < $nfields ) + { + chomp($line); + $self->warn("Missing some of the mandatory column names.\n\tGot: $line\n\tExpected: #", join("\t",@{$$self{mandatory}}),"\n"); + return; + } + + for (my $i=0; $i<$ncols; $i++) + { + if ( $cols[$i]=~/^\s+/ or $cols[$i]=~/\s+$/ ) + { + $self->warn("The column name contains leading/trailing spaces, removing: '$cols[$i]'\n"); + $cols[$i] =~ s/^\s+//; + $cols[$i] =~ s/\s+$//; + } + if ( $i<$nfields && $cols[$i] ne $$fields[$i] ) + { + $self->warn("Expected mandatory column [$$fields[$i]], got [$cols[$i]]\n"); + $cols[$i] = $$fields[$i]; + } + $$self{has_column}{$cols[$i]} = $i+1; + } + $$self{columns} = \@cols; + return; +} + + +=head2 _fake_column_names + + About : When no header is present, fake column names as the default mandatory ones + numbers + Args : The number of genotype columns; 0 if no genotypes but FORMAT present; <0 if FORMAT and genotypes not present + +=cut + +sub _fake_column_names +{ + my ($self,$ncols) = @_; + + $$self{columns} = [ @{$$self{mandatory}} ]; + if ( $ncols>=0 ) { push @{$$self{columns}}, 'FORMAT'; } + for (my $i=1; $i<=$ncols; $i++) { push @{$$self{columns}}, $i; } +} + + +=head2 format_header + + About : Returns the header. + Usage : print $vcf->format_header(); + Args : The columns to include on output [optional] + +=cut + +sub format_header +{ + my ($self,$columns) = @_; + + my $out = ''; + for my $line (@{$$self{header_lines}}) { $out .= $self->format_header_line($line); } + + # This is required when using the API for writing new VCF files and the caller does not add the line explicitly + if ( !exists($$self{header_lines}[0]{key}) or $$self{header_lines}[0]{key} ne 'fileformat' ) + { + $out = "##fileformat=VCFv$$self{version}\n" .$out; + } + if ( !$$self{columns} ) { return $out; } + + my @out_cols; + if ( $columns ) + { + @out_cols = @{$$self{columns}}[0..8]; + for my $col (@$columns) + { + if ( exists($$self{has_column}{$col}) ) { push @out_cols, $col; } + } + } + else + { + @out_cols = @{$$self{columns}}; + } + $out .= "#". join("\t", @out_cols). "\n"; + + return $out; +} + + +=head2 format_line + + About : Returns the header. + Usage : $x = $vcf->next_data_hash(); print $vcf->format_line($x); + $x = $vcf->next_data_array(); print $vcf->format_line($x); + Args 1 : The columns or hash in the format returned by next_data_hash or next_data_array. + 2 : The columns to include [optional] + +=cut + +sub format_line +{ + my ($self,$record,$columns) = @_; + + if ( ref($record) eq 'HASH' ) { return $self->_format_line_hash($record,$columns); } + if ( ref($record) eq 'ARRAY' ) { return join("\t",@$record)."\n"; } + $self->throw("FIXME: todo .. " .ref($record). "\n"); +} + + +=head2 recalc_ac_an + + About : Control if the AC and AN values should be updated. + Usage : $vcf->recalc_ac_an(1); $x = $vcf->next_data_hash(); print $vcf->format_line($x); + Args 1 : 0 .. never recalculate + 1 .. recalculate if present + 2 .. recalculate if present and add if missing + +=cut + +sub recalc_ac_an +{ + my ($self,$value) = @_; + if ( $value eq '0' || $value eq '1' || $value eq '2' ) { $$self{recalc_ac_an} = $value; } + return; +} + +=head2 get_tag_index + + Usage : my $idx = $vcf->get_tag_index('GT:PL:DP:SP:GQ','PL',':'); + Arg 1 : Field + 2 : The tag to find + 3 : Tag separator + Returns : Index of the tag or -1 when not found + +=cut + +sub get_tag_index +{ + my ($self,$field,$tag,$sep) = @_; + if ( !defined $field ) { return -1; } + my $idx = 0; + my $prev_isep = 0; + my $isep = 0; + while (1) + { + $isep = index($field,':',$prev_isep); + if ( $isep==-1 ) + { + if ( substr($field,$prev_isep) eq $tag ) { return $idx; } + else { return -1; } + } + if ( substr($field,$prev_isep,$isep-$prev_isep) eq $tag ) { return $idx; } + $prev_isep = $isep+1; + $idx++; + } +} + +=head2 remove_field + + Usage : my $field = $vcf->remove_field('GT:PL:DP:SP:GQ',1,':'); # returns 'GT:DP:SP:GQ' + Arg 1 : Field + 2 : The index of the field to remove + 3 : Field separator + Returns : Modified string + +=cut + +sub remove_field +{ + my ($self,$string,$idx,$sep) = @_; + my $isep = -1; + my $prev_isep = 0; + my $itag = 0; + while ($itag!=$idx) + { + $isep = index($string,$sep,$prev_isep); + if ( $isep==-1 ) { $self->throw("The index out of range: $string:$isep .. $idx"); } + $prev_isep = $isep+1; + $itag++; + } + my $out; + if ( $isep>=0 ) { $out = substr($string,0,$isep); } + my $ito=index($string,$sep,$isep+1); + if ( $ito!=-1 ) + { + if ( $isep>=0 ) { $out .= ':' } + $out .= substr($string,$ito+1); + } + if ( !defined $out ) { return '.'; } + return $out; +} + +=head2 replace_field + + Usage : my $col = $vcf->replace_field('GT:PL:DP:SP:GQ','XX',1,':'); # returns 'GT:XX:DP:SP:GQ' + Arg 1 : Field + 2 : The index of the field to replace + 3 : Replacement + 4 : Field separator + Returns : Modified string + +=cut + +sub replace_field +{ + my ($self,$string,$repl,$idx,$sep) = @_; + my $isep = -1; + my $prev_isep = 0; + my $itag = 0; + while ($itag!=$idx) + { + $isep = index($string,$sep,$prev_isep); + if ( $isep==-1 ) { $self->throw("The index out of range: $string:$isep .. $idx"); } + $prev_isep = $isep+1; + $itag++; + } + my $out; + if ( $isep>=0 ) { $out = substr($string,0,$isep+1); } + my $ito = index($string,$sep,$isep+1); + if ( $ito==-1 ) + { + $out .= $repl; + } + else + { + $out .= $repl; + $out .= ':'; + $out .= substr($string,$ito+1); + } + if ( !defined $out ) { return '.'; } + return $out; +} + +=head2 get_info_field + + Usage : my $line = $vcf->next_line; + my @items = split(/\t/,$line); + $af = $vcf->get_info_field('DP=14;AF=0.5;DB','AF'); # returns 0.5 + $af = $vcf->get_info_field('DP=14;AF=0.5;DB','DB'); # returns 1 + $af = $vcf->get_info_field('DP=14;AF=0.5;DB','XY'); # returns undef + Arg 1 : The VCF line broken into an array + 2 : The tag to retrieve + Returns : undef when tag is not present, the tag value if present, or 1 if flag is present + +=cut + +sub get_info_field +{ + my ($self,$info,$tag) = @_; + + my $info_len = length($info); + my $tag_len = length($tag); + my $idx = 0; + while (1) + { + $idx = index($info,$tag,$idx); + if ( $idx==-1 ) { return undef; } + if ( $idx!=0 && substr($info,$idx-1,1) ne ';' ) { $idx += $tag_len; next; } + if ( $tag_len+$idx >= $info_len ) { return 1; } + + my $follows = substr($info,$idx+$tag_len,1); + if ( $follows eq ';' ) { return 1; } + + $idx += $tag_len; + if ( $follows ne '=' ) { next; } + + $idx++; + my $to = index($info,';',$idx); + return $to==-1 ? substr($info,$idx) : substr($info,$idx,$to-$idx); + } +} + +=head2 get_field + + Usage : my $line = $vcf->next_line; + my @items = split(/\t/,$line); + my $idx = $vcf->get_tag_index($$line[8],'PL',':'); + my $pl = $vcf->get_field($$line[9],$idx) unless $idx==-1; + Arg 1 : The VCF line broken into an array + 2 : The index of the field to retrieve + 3 : The delimiter [Default is ':'] + Returns : The tag value + +=cut + +sub get_field +{ + my ($self,$col,$idx,$delim) = @_; + + if ( !defined $delim ) { $delim=':'; } + my $isep = 0; + my $prev_isep = 0; + my $itag = 0; + while (1) + { + $isep = index($col,$delim,$prev_isep); + if ( $itag==$idx ) { last; } + if ( $isep==-1 ) { $self->throw("The index out of range: $col:$isep .. $idx"); } + $prev_isep = $isep+1; + $itag++; + } + return $isep<0 ? substr($col,$prev_isep) : substr($col,$prev_isep,$isep-$prev_isep); +} + +=head2 get_sample_field + + Usage : my $line = $vcf->next_line; + my @items = split(/\t/,$line); + my $idx = $vcf->get_tag_index($$line[8],'PL',':'); + my $pls = $vcf->get_sample_field(\@items,$idx) unless $idx==-1; + Arg 1 : The VCF line broken into an array + 2 : The index of the field to retrieve + Returns : Array of values + +=cut + +sub get_sample_field +{ + my ($self,$cols,$idx) = @_; + my @out; + my $n = @$cols; + for (my $icol=9; $icol<$n; $icol++) + { + my $col = $$cols[$icol]; + my $isep = 0; + my $prev_isep = 0; + my $itag = 0; + while (1) + { + $isep = index($col,':',$prev_isep); + if ( $itag==$idx ) { last; } + if ( $isep==-1 ) { $self->throw("The index out of range: $col:$isep .. $idx"); } + $prev_isep = $isep+1; + $itag++; + } + my $val = $isep<0 ? substr($col,$prev_isep) : substr($col,$prev_isep,$isep-$prev_isep); + push @out,$val; + } + return \@out; +} + + +=head2 split_mandatory + + About : Faster alternative to regexs, extract the mandatory columns + Usage : my $line=$vcf->next_line; my @cols = $vcf->split_mandatory($line); + Arg : + Returns : Pointer to the array of values + +=cut + +sub split_mandatory +{ + my ($self,$line) = @_; + my @out; + my $prev = 0; + for (my $i=0; $i<7; $i++) + { + my $isep = index($line,"\t",$prev); + if ( $isep==-1 ) { $self->throw("Could not parse the mandatory columns: $line"); } + push @out, substr($line,$prev,$isep-$prev); + $prev = $isep+1; + } + my $isep = index($line,"\t",$prev); + if ( $isep!=-1 ) + { + push @out, substr($line,$prev,$isep-$prev-1); + } + else + { + push @out, substr($line,$prev); + } + return \@out; +} + + +=head2 split_gt + + About : Faster alternative to regexs, diploid GT assumed + Usage : my ($a1,$a2,$a3) = $vcf->split_gt('0/0/1'); # returns (0,0,1) + Arg : Diploid genotype to split into alleles + Returns : Array of values + +=cut + +sub split_gt +{ + my ($self,$gt) = @_; + my @als; + my $iprev = 0; + while (1) + { + my $isep = index($gt,'/',$iprev); + my $jsep = index($gt,'|',$iprev); + if ( $isep<0 or ($jsep>=0 && $jsep<$isep) ) { $isep = $jsep; } + push @als, $isep<0 ? substr($gt,$iprev) : substr($gt,$iprev,$isep-$iprev); + if ( $isep<0 ) { return (@als); } + $iprev = $isep+1; + } + return (@als); +} + + +=head2 decode_genotype + + About : Faster alternative to regexs + Usage : my $gt = $vcf->decode_genotype('G',['A','C'],'0/0'); # returns 'G/G' + Arg 1 : Ref allele + 2 : Alt alleles + 3 : The genotype to decode + Returns : Decoded GT string + +=cut + +sub decode_genotype +{ + my ($self,$ref,$alt,$gt) = @_; + my $isep = 0; + my $out; + while (1) + { + my $i = index($gt,'/',$isep); + my $j = index($gt,'|',$isep); + if ( $i==-1 && $j==-1 ) + { + my $idx = substr($gt,$isep); + if ( $idx eq '.' ) + { + $out .= $idx; + } + else + { + if ( $idx>@$alt ) { $self->throw("The genotype index $idx in $gt is out of bounds: ", join(',',@$alt)); } + $out .= $idx==0 ? $ref : $$alt[$idx-1]; + } + return $out; + } + if ( $i!=-1 && $j!=-1 && $i>$j ) { $i=$j; } + elsif ( $i==-1 ) { $i=$j } + + my $idx = substr($gt,$isep,$i-$isep); + if ( $idx eq '.' ) + { + $out .= $idx; + } + else + { + if ( $idx>@$alt ) { $self->throw("The genotype index $idx in $gt out of bounds: ", join(',',@$alt)); } + $out .= $idx==0 ? $ref : $$alt[$idx-1]; + } + $out .= substr($gt,$i,1); + $isep = $i+1; + } +} + + +sub _format_line_hash +{ + my ($self,$record,$columns) = @_; + + if ( !$$self{columns} ) + { + my $ngtypes = scalar keys %{$$record{gtypes}}; + if ( !$ngtypes && !exists($$record{FORMAT}) ) { $ngtypes--; } + $self->_fake_column_names($ngtypes); + } + my $cols = $$self{columns}; + + # CHROM POS ID REF + my $out; + $out .= $$record{CHROM} . "\t"; + $out .= $$record{POS} . "\t"; + $out .= (defined $$record{ID} ? $$record{ID} : '.') . "\t"; + $out .= $$record{REF} . "\t"; + + # ALT + $out .= join(',',@{$$record{ALT}} ? @{$$record{ALT}} : '.'); + + # QUAL + $out .= "\t". $$record{QUAL}; + + # FILTER + $out .= "\t". join(';',$$record{FILTER} ? @{$$record{FILTER}} : '.'); + + # Collect the gtypes of interest + my $gtypes; + if ( $columns ) + { + # Select only those gtypes keys with a corresponding key in columns. + for my $col (@$columns) { $$gtypes{$col} = $$record{gtypes}{$col}; } + } + else + { + $gtypes = $$record{gtypes}; + } + + # INFO + # .. calculate NS, AN and AC, but only if recalc_ac_an is set + my $needs_an_ac = $$self{recalc_ac_an}==2 ? 1 : 0; + my @info; + while (my ($key,$value) = each %{$$record{INFO}}) + { + if ( $$self{recalc_ac_an}>0 ) + { + if ( $key eq 'AN' ) { $needs_an_ac=1; next; } + if ( $key eq 'AC' ) { $needs_an_ac=1; next; } + } + if ( defined $value ) + { + push @info, "$key=$value"; + } + elsif ( $key ne '.' ) + { + push @info, $key; + } + } + if ( $needs_an_ac ) + { + my $nalt = scalar @{$$record{ALT}}; + if ( $nalt==1 && $$record{ALT}[0] eq '.' ) { $nalt=0; } + my ($an,$ac) = $self->calc_an_ac($gtypes,$nalt); + push @info, "AN=$an","AC=$ac"; + } + if ( !@info ) { push @info, '.'; } + $out .= "\t". join(';', sort @info); + + # FORMAT, the column is not required, it may not be present when there are no genotypes + if ( exists($$cols[8]) && defined $$record{FORMAT} ) + { + $out .= "\t". join(':',@{$$record{FORMAT}}); + } + + # Genotypes: output all columns or only a selection? + my @col_names = $columns ? @$columns : @$cols[9..@$cols-1]; + my $nformat = defined $$record{FORMAT} ? @{$$record{FORMAT}} : 0; + for my $col (@col_names) + { + my $gt = $$gtypes{$col}; + my $can_drop = $$self{drop_trailings}; + my @gtype; + for (my $i=$nformat-1; $i>=0; $i--) + { + my $field = $$record{FORMAT}[$i]; + if ( $i==0 ) { $can_drop=0; } + + if ( exists($$gt{$field}) ) + { + $can_drop = 0; + if ( ref($$gt{$field}) eq 'HASH' ) + { + # Special treatment for Number=[AG] tags + unshift @gtype, $self->format_AGtag($record,$$gt{$field},$field); + } + else + { + unshift @gtype,$$gt{$field}; + } + } + elsif ( $can_drop ) { next; } + elsif ( exists($$self{header}{FORMAT}{$field}{default}) ) { unshift @gtype,$$self{header}{FORMAT}{$field}{default}; $can_drop=0; } + else { $self->throw(qq[No value for the field "$field" and no default available, column "$col" at $$record{CHROM}:$$record{POS}.\n]); } + } + $out .= "\t" . join(':',@gtype); + } + + $out .= "\n"; + return $out; +} + +sub calc_an_ac +{ + my ($self,$gtypes,$nalleles) = @_; + my $sep_re = $$self{regex_gtsep}; + my ($an,%ac_counts); + if ( defined $nalleles ) + { + for (my $i=1; $i<=$nalleles; $i++) { $ac_counts{$i}=0; } + } + $an = 0; + for my $gt (keys %$gtypes) + { + my $value = $$gtypes{$gt}{GT}; + if ( !defined $value ) { next; } # GT may not be present + my ($al1,$al2) = split($sep_re,$value); + if ( defined($al1) && $al1 ne '.' ) + { + $an++; + if ( $al1 ne '0' ) { $ac_counts{$al1}++; } + } + if ( defined($al2) && $al2 ne '.' ) + { + $an++; + if ( $al2 ne '0' ) { $ac_counts{$al2}++; } + } + } + my @ac; + for my $ac ( sort { $a <=> $b } keys %ac_counts) { push @ac, $ac_counts{$ac}; } + if ( !@ac ) { @ac = ('0'); } + return ($an,join(',',@ac),\@ac); +} + +sub _validate_alt_field +{ + my ($self,$values,$ref) = @_; + + for (my $i=0; $i<@$values; $i++) + { + for (my $j=0; $j<$i; $j++) + { + if ( $$values[$i] eq $$values[$j] ) { return "The alleles not unique: $$values[$i]"; } + } + if ( $$values[$i] eq $ref ) { return "REF allele listed in the ALT field??"; } + } + return undef; +} + +=head2 validate_alt_field + + Usage : my $x = $vcf->next_data_hash(); $vcf->validate_alt_field($$x{ALT}); + Args : The ALT arrayref + Returns : Error message in case of an error. + +=cut + +sub validate_alt_field +{ + my ($self,$values,$ref) = @_; + + if ( @$values == 1 && $$values[0] eq '.' ) { return undef; } + + my $ret = $self->_validate_alt_field($values,$ref); + if ( $ret ) { return $ret; } + + my @err; + for my $item (@$values) + { + if ( $item=~/^[ACTGN]$/ ) { next; } + elsif ( $item=~/^I[ACTGN]+$/ ) { next; } + elsif ( $item=~/^D\d+$/ ) { next; } + + push @err, $item; + } + if ( !@err ) { return undef; } + return 'Could not parse the allele(s) [' .join(',',@err). ']'; +} + +=head2 event_type + + Usage : my $x = $vcf->next_data_hash(); + my ($alleles,$seps,$is_phased,$is_empty) = $vcf->parse_haplotype($x,'NA00001'); + for my $allele (@$alleles) + { + my ($type,$len,$ht) = $vcf->event_type($x,$allele); + } + or + my ($type,$len,$ht) = $vcf->event_type($ref,$al); + Args : VCF data line parsed by next_data_hash or the reference allele + : Allele + Returns : 's' for SNP and number of SNPs in the record + 'i' for indel and a positive (resp. negative) number for the length of insertion (resp. deletion) + 'r' identical to the reference, length 0 + 'o' for other (complex events) and the number of affected bases + 'b' breakend + 'u' unknown + +=cut + +sub event_type +{ + my ($self,$rec,$allele) = @_; + + my $ref = $rec; + if ( ref($rec) eq 'HASH' ) + { + if ( exists($$rec{_cached_events}{$allele}) ) { return (@{$$rec{_cached_events}{$allele}}); } + $ref = $$rec{REF}; + } + + my ($type,$len,$ht); + if ( $allele eq $ref or $allele eq '.' ) { $len=0; $type='r'; $ht=$ref; } + elsif ( $allele=~/^[ACGT]$/ ) { $len=1; $type='s'; $ht=$allele; } + elsif ( $allele=~/^I/ ) { $len=length($allele)-1; $type='i'; $ht=$'; } + elsif ( $allele=~/^D(\d+)/ ) { $len=-$1; $type='i'; $ht=''; } + else + { + my $chr = ref($rec) eq 'HASH' ? $$rec{CHROM} : 'undef'; + my $pos = ref($rec) eq 'HASH' ? $$rec{POS} : 'undef'; + $self->throw("Eh?: $chr:$pos .. $ref $allele\n"); + } + + if ( ref($rec) eq 'HASH' ) + { + $$rec{_cached_events}{$allele} = [$type,$len,$ht]; + } + return ($type,$len,$ht); +} + +=head2 has_AGtags + + About : Checks the header for the presence of tags with variable number of fields (Number=A or Number=G, such as GL) + Usage : $vcf->parse_header(); my $agtags = $vcf->has_AGtags(); + Args : None + Returns : Hash {fmtA=>[tags],fmtG=>[tags],infoA=>[tags],infoG=>[tags]} or undef if none is present + +=cut + +sub has_AGtags +{ + my ($self) = @_; + my $out; + if ( exists($$self{header}{FORMAT}) ) + { + for my $tag (keys %{$$self{header}{FORMAT}}) + { + if ( $$self{header}{FORMAT}{$tag}{Number} eq 'A' ) { push @{$$out{fmtA}},$tag; } + if ( $$self{header}{FORMAT}{$tag}{Number} eq 'G' ) { push @{$$out{fmtG}},$tag; } + } + } + if ( exists($$self{header}{INFO}) ) + { + for my $tag (keys %{$$self{header}{INFO}}) + { + if ( $$self{header}{INFO}{$tag}{Number} eq 'A' ) { push @{$$out{infoA}},$tag; } + if ( $$self{header}{INFO}{$tag}{Number} eq 'G' ) { push @{$$out{infoG}},$tag; } + } + } + if ( defined $out ) + { + for my $key qw(fmtA fmtG infoA infoG) { if ( !exists($$out{$key}) ) { $$out{$key}=[] } } + } + return $out; +} + +=head2 parse_AGtags + + About : Breaks tags with variable number of fields (that is where Number is set to 'A' or 'G', such as GL) into hashes + Usage : my $x = $vcf->next_data_hash(); my $values = $vcf->parse_AGtags($x); + Args : VCF data line parsed by next_data_hash + : Mapping between ALT representations based on different REFs [optional] + : New REF [optional] + Returns : Hash {Allele=>Value} + +=cut + +sub parse_AGtags +{ + my ($self,$rec,$ref_alt_map,$new_ref) = @_; + + if ( !exists($$rec{gtypes}) ) { return; } + + my (@atags,@gtags); + for my $fmt (@{$$rec{FORMAT}}) + { + # These have been listed explicitly for proper merging of v4.0 VCFs + if ( $$self{fix_v40_AGtags} ) + { + if ( $fmt eq 'GL' or $fmt eq 'PL' ) { push @gtags,$fmt; next; } + if ( $fmt eq 'AC' or $fmt eq 'AF' ) { push @atags,$fmt; next; } + } + if ( !exists($$self{header}{FORMAT}{$fmt}) ) { next; } + if ( $$self{header}{FORMAT}{$fmt}{Number} eq 'A' ) { push @atags,$fmt; next; } + if ( $$self{header}{FORMAT}{$fmt}{Number} eq 'G' ) { push @gtags,$fmt; next; } + } + my $missing = $$self{defaults}{default}; + if ( @atags ) + { + # Parse Number=A tags + my $alts; + if ( defined $ref_alt_map ) + { + $alts = []; + for my $alt (@{$$rec{ALT}}) + { + if ( !exists($$ref_alt_map{$new_ref}{$alt}) ) { $self->throw("FIXME: $new_ref $alt...?\n"); } + push @$alts, $$ref_alt_map{$new_ref}{$alt}; + } + } + else + { + $alts = $$rec{ALT}; + } + for my $tag (@atags) + { + for my $sample (values %{$$rec{gtypes}}) + { + if ( !exists($$sample{$tag}) or $$sample{$tag} eq $missing ) { next; } + my @values = split(/,/,$$sample{$tag}); + $$sample{$tag} = {}; + for (my $i=0; $i<@values; $i++) + { + $$sample{$tag}{$$alts[$i]} = $values[$i]; + } + } + } + } + if ( @gtags ) + { + # Parse Number=G tags + my @alleles; + if ( defined $ref_alt_map ) + { + push @alleles, $new_ref; + for my $alt (@{$$rec{ALT}}) + { + if ( !exists($$ref_alt_map{$new_ref}{$alt}) ) { $self->throw("FIXME: [$new_ref] [$alt]...?\n", Dumper($ref_alt_map,$rec)); } + push @alleles, $$ref_alt_map{$new_ref}{$alt}; + } + } + else + { + @alleles = ($$rec{REF},@{$$rec{ALT}}); + } + my @gtypes; + for (my $i=0; $i<@alleles; $i++) + { + for (my $j=0; $j<=$i; $j++) + { + push @gtypes, $alleles[$i].'/'.$alleles[$j]; + } + } + for my $tag (@gtags) + { + for my $sample (values %{$$rec{gtypes}}) + { + if ( !exists($$sample{$tag}) or $$sample{$tag} eq $missing ) { next; } + my @values = split(/,/,$$sample{$tag}); + $$sample{$tag} = {}; + for (my $i=0; $i<@values; $i++) + { + $$sample{$tag}{$gtypes[$i]} = $values[$i]; + } + } + } + } +} + +=head2 format_AGtag + + About : Format tag with variable number of fields (that is where Number is set to 'A' or 'G', such as GL) + Usage : + Args : + : + : + Returns : + +=cut + +sub format_AGtag +{ + my ($self,$record,$tag_data,$tag) = @_; + + # The FORMAT field is checked only once and the results are cached. + if ( !exists($$record{_atags}) ) + { + $$record{_atags} = {}; + + # Check if there are any A,G tags + for my $fmt (@{$$record{FORMAT}}) + { + # These have been listed explicitly for proper merging of v4.0 VCFs + if ( $$self{fix_v40_AGtags} ) + { + if ( $fmt eq 'GL' or $fmt eq 'PL' ) { $$record{_gtags}{$fmt}=1; next; } + if ( $fmt eq 'AC' or $fmt eq 'AF' ) { $$record{_atags}{$fmt}=1; next; } + } + if ( !exists($$self{header}{FORMAT}{$fmt}) ) { next; } + if ( $$self{header}{FORMAT}{$fmt}{Number} eq 'A' ) { $$record{_atags}{$fmt}=1; next; } + if ( $$self{header}{FORMAT}{$fmt}{Number} eq 'G' ) { $$record{_gtags}{$fmt}=1; next; } + } + } + + my @out; + if ( exists($$record{_atags}{$tag}) ) + { + for my $alt (@{$$record{ALT}}) + { + push @out, exists($$tag_data{$alt}) ? $$tag_data{$alt} : $$self{defaults}{default}; + } + } + + if ( exists($$record{_gtags}{$tag}) ) + { + my $gtypes = $$record{_gtypes}; + my $gtypes2 = $$record{_gtypes2}; + if ( !defined $gtypes ) + { + $gtypes = []; + $gtypes2 = []; + + my @alleles = ( $$record{REF}, @{$$record{ALT}} ); + for (my $i=0; $i<@alleles; $i++) + { + for (my $j=0; $j<=$i; $j++) + { + push @$gtypes, $alleles[$i].'/'.$alleles[$j]; + push @$gtypes2, $alleles[$j].'/'.$alleles[$i]; + } + } + + $$record{_gtypes} = $gtypes; + $$record{_gtypes2} = $gtypes2; + } + + for (my $i=0; $i<@$gtypes; $i++) + { + my $gt = $$gtypes[$i]; + if ( !exists($$tag_data{$gt}) ) { $gt = $$gtypes2[$i]; } + push @out, exists($$tag_data{$gt}) ? $$tag_data{$gt} : $$self{defaults}{default}; + } + } + + return join(',',@out); +} + +=head2 parse_alleles + + About : Deprecated, use parse_haplotype instead. + Usage : my $x = $vcf->next_data_hash(); my ($al1,$sep,$al2) = $vcf->parse_alleles($x,'NA00001'); + Args : VCF data line parsed by next_data_hash + : The genotype column name + Returns : Alleles and the separator. If only one allele is present, $sep and $al2 will be an empty string. + +=cut + +sub parse_alleles +{ + my ($self,$rec,$column) = @_; + if ( !exists($$rec{gtypes}) || !exists($$rec{gtypes}{$column}) ) { $self->throw("The column not present: '$column'\n"); } + + my $gtype = $$rec{gtypes}{$column}{GT}; + if ( !($gtype=~$$self{regex_gt}) ) { $self->throw("Could not parse gtype string [$gtype] [$$rec{CHROM}:$$rec{POS}]\n"); } + my $al1 = $1; + my $sep = $2; + my $al2 = $3; + + if ( !$al1 ) { $al1 = $$rec{REF}; } + elsif ( $al1 ne '.' ) + { + if ( !($al1=~/^\d+$/) ) { $self->throw("Uh, what is this? [$al1] $$rec{CHROM}:$$rec{POS}\n"); } + $al1 = $$rec{ALT}[$al1-1]; + } + + if ( !defined $al2 or $al2 eq '' ) + { + $sep = ''; + $al2 = ''; + } + else + { + if ( !$al2 ) { $al2 = $$rec{REF}; } + elsif ( $al2 ne '.' ) { $al2 = $$rec{ALT}[$al2-1]; } + } + return ($al1,$sep,$al2); +} + +=head2 parse_haplotype + + About : Similar to parse_alleles, supports also multiploid VCFs. + Usage : my $x = $vcf->next_data_hash(); my ($alleles,$seps,$is_phased,$is_empty) = $vcf->parse_haplotype($x,'NA00001'); + Args : VCF data line parsed by next_data_hash + : The genotype column name + Returns : Two array refs and two boolean flags: List of alleles, list of separators, and is_phased/empty flags. The values + can be cashed and must be therefore considered read only! + +=cut + +sub parse_haplotype +{ + my ($self,$rec,$column) = @_; + if ( !exists($$rec{gtypes}{$column}) ) { $self->throw("The column not present: '$column'\n"); } + if ( !exists($$rec{gtypes}{$column}{GT}) ) { return (['.'],[],0,1); } + + my $gtype = $$rec{gtypes}{$column}{GT}; + if ( exists($$rec{_cached_haplotypes}{$gtype}) ) { return (@{$$rec{_cached_haplotypes}{$gtype}}); } + + my @alleles = (); + my @seps = (); + my $is_phased = 0; + my $is_empty = 1; + + my $buf = $gtype; + while ($buf ne '') + { + if ( !($buf=~m{^(\.|\d+)([|/]?)}) ) { $self->throw("Could not parse gtype string [$gtype] .. $$rec{CHROM}:$$rec{POS} $column\n"); } + $buf = $'; + + if ( $1 eq '.' ) { push @alleles,'.'; } + else + { + $is_empty = 0; + if ( $1 eq '0' ) { push @alleles,$$rec{REF}; } + elsif ( exists($$rec{ALT}[$1-1]) ) { push @alleles,$$rec{ALT}[$1-1]; } + else { $self->throw(qq[The haplotype indexes in "$gtype" do not match the ALT column .. $$rec{CHROM}:$$rec{POS} $column\n]); } + } + if ( $2 ) + { + if ( $2 eq '|' ) { $is_phased=1; } + push @seps,$2; + } + } + $$rec{_cached_haplotypes}{$gtype} = [\@alleles,\@seps,$is_phased,$is_empty]; + return (@{$$rec{_cached_haplotypes}{$gtype}}); +} + +=head2 format_haplotype + + Usage : my ($alleles,$seps,$is_phased,$is_empty) = $vcf->parse_haplotype($x,'NA00001'); print $vcf->format_haplotype($alleles,$seps); + +=cut + +sub format_haplotype +{ + my ($self,$alleles,$seps) = @_; + if ( @$alleles != @$seps+1 ) { $self->throw(sprintf("Uh: %d vs %d\n",scalar @$alleles,scalar @$seps),Dumper($alleles,$seps)); } + my $out = $$alleles[0]; + for (my $i=1; $i<@$alleles; $i++) + { + $out .= $$seps[$i-1]; + $out .= $$alleles[$i]; + } + return $out; +} + + +=head2 format_genotype_strings + + Usage : my $x = { REF=>'A', gtypes=>{'NA00001'=>{'GT'=>'A/C'}}, FORMAT=>['GT'], CHROM=>1, POS=>1, FILTER=>['.'], QUAL=>-1 }; + $vcf->format_genotype_strings($x); + print $vcf->format_line($x); + Args 1 : VCF data line in the format as if parsed by next_data_hash with alleles written as letters. + 2 : Optionally, a subset of columns can be supplied. See also format_line. + Returns : Modifies the ALT array and the genotypes so that ref alleles become 0 and non-ref alleles + numbers starting from 1. If the key $$vcf{trim_redundant_ALTs} is set, ALT alleles not appearing + in any of the sample column will be removed. + +=cut + +sub format_genotype_strings +{ + my ($self,$rec,$columns) = @_; + + if ( !exists($$rec{gtypes}) ) { return; } + + my $ref = $$rec{REF}; + my $nalts = 0; + my %alts = (); + + if ( !$columns ) { $columns = [keys %{$$rec{gtypes}}]; } + + for my $key (@$columns) + { + my $gtype = $$rec{gtypes}{$key}{GT}; + my $buf = $gtype; + my $out = ''; + while ($buf ne '') + { + $buf=~m{^([^/|]+)([/|]?)}; + $buf = $'; + + my $al = $1; + my $sep = $2; + if ( $al eq $ref or $al eq '0' or $al eq '*' ) { $al=0; } + else + { + if ( $al=~/^\d+$/ ) + { + if ( !exists($$rec{ALT}[$al-1]) ) { $self->throw("Broken ALT, index $al out of bounds\n"); } + $al = $$rec{ALT}[$al-1]; + } + + if ( exists($alts{$al}) ) { $al = $alts{$al} } + elsif ( $al ne '.' ) + { + $alts{$al} = ++$nalts; + $al = $nalts; + } + + } + $out .= $al; + if ( $sep ) { $out .= $sep; } + } + $$rec{gtypes}{$key}{GT} = $out; + } + if ( !$$self{trim_redundant_ALTs} && exists($$rec{ALT}) && @{$$rec{ALT}} ) + { + for my $alt (@{$$rec{ALT}}) + { + if ( !exists($alts{$alt}) ) { $alts{$alt} = ++$nalts; } + } + } + $$rec{ALT} = [ sort { $alts{$a}<=>$alts{$b} } keys %alts ]; +} + +sub fill_ref_alt_mapping +{ + my ($self,$map) = @_; + + my $new_ref; + for my $ref (keys %$map) + { + $new_ref = $ref; + if ( $ref ne $new_ref ) { $self->throw("The reference prefixes do not agree: $ref vs $new_ref\n"); } + for my $alt (keys %{$$map{$ref}}) + { + $$map{$ref}{$alt} = $alt; + } + } + $$map{$new_ref}{$new_ref} = $new_ref; + return $new_ref; +} + + +=head2 format_header_line + + Usage : $vcf->format_header_line({key=>'INFO', ID=>'AC',Number=>-1,Type=>'Integer',Description=>'Allele count in genotypes'}) + Args : + Returns : + +=cut + +sub format_header_line +{ + my ($self,$rec) = @_; + my $line = "##$$rec{key}"; + $line .= "=$$rec{value}" unless !exists($$rec{value}); + $line .= "=$$rec{ID}" unless !exists($$rec{ID}); + $line .= ",$$rec{Number}" unless !exists($$rec{Number}); + $line .= ",$$rec{Type}" unless !exists($$rec{Type}); + $line .= qq[,"$$rec{Description}"] unless !exists($$rec{Description}); + $line .= "\n"; + return $line; +} + +=head2 remove_columns + + Usage : my $rec=$vcf->next_data_hash(); $vcf->remove_columns($rec,remove=>['NA001','NA0002']); + Args : VCF hash pointer + : list of columns to remove or a lookup hash with column names to keep (remove=>[] or keep=>{}) + Returns : + +=cut + +sub remove_columns +{ + my ($self,$rec,%args) = @_; + if ( ref($rec) ne 'HASH' ) { $self->throw("TODO: rec for array"); } + if ( exists($args{keep}) ) + { + for my $col (keys %{$$rec{gtypes}}) + { + if ( !exists($args{keep}{$col}) ) { delete($$rec{gtypes}{$col}); } + } + } + if ( exists($args{remove}) ) + { + for my $col (@{$args{remove}}) + { + if ( exists($$rec{gtypes}{$col}) ) { delete($$rec{gtypes}{$col}); } + } + } +} + +=head2 add_columns + + Usage : $vcf->add_columns('NA001','NA0002'); + Args : + Returns : + +=cut + +sub add_columns +{ + my ($self,@columns) = @_; + if ( !$$self{columns} ) + { + # The columns should be initialized de novo. Figure out if the @columns contain also the mandatory + # columns and if FORMAT should be present (it can be absent when there is no genotype column present). + my $has_other = 0; + for my $col (@columns) + { + if ( !exists($$self{reserved}{cols}{$col}) ) { $has_other=1; last; } + } + + $$self{columns} = [ @{$$self{mandatory}} ]; + if ( $has_other ) { push @{$$self{columns}},'FORMAT'; } + + for my $col (@{$$self{columns}}) { $$self{has_column}{$col}=1; } + } + my $ncols = @{$$self{columns}}; + for my $col (@columns) + { + if ( $$self{has_column}{$col} ) { next; } + $ncols++; + push @{$$self{columns}}, $col; + } +} + +=head2 add_format_field + + Usage : $x=$vcf->next_data_hash(); $vcf->add_format_field($x,'FOO'); $$x{gtypes}{NA0001}{FOO}='Bar'; print $vcf->format_line($x); + Args : The record obtained by next_data_hash + : The field name + Returns : + +=cut + +sub add_format_field +{ + my ($self,$rec,$field) = @_; + + if ( !$$rec{FORMAT} ) { $$rec{FORMAT}=[]; } + + for my $key (@{$$rec{FORMAT}}) + { + if ( $key eq $field ) { return; } # already there + } + push @{$$rec{FORMAT}}, $field; +} + + +=head2 remove_format_field + + Usage : $x=$vcf->next_data_hash(); $vcf->remove_format_field($x,'FOO'); print $vcf->format_line($x); + Args : The record obtained by next_data_hash + : The field name + Returns : + +=cut + +sub remove_format_field +{ + my ($self,$rec,$field) = @_; + + if ( !$$rec{FORMAT} ) { $$rec{FORMAT}=[]; } + + my $i = 0; + for my $key (@{$$rec{FORMAT}}) + { + if ( $key eq $field ) { splice @{$$rec{FORMAT}},$i,1; } + $i++; + } +} + + +=head2 add_info_field + + Usage : $x=$vcf->next_data_array(); $$x[7]=$vcf->add_info_field($$x[7],'FOO'=>'value','BAR'=>undef,'BAZ'=>''); print join("\t",@$x)."\n"; + Args : The record obtained by next_data_array + : The INFO field name and value pairs. If value is undef and the key is present in $$x[7], + it will be removed. To add fields without a value, use empty string ''. + Returns : The formatted INFO. + +=cut + +sub add_info_field +{ + my ($self,$info,%fields) = @_; + + my @out = (); + + # First handle the existing values, keep everything unless in %fields + for my $field (split(/;/,$info)) + { + my ($key,$value) = split(/=/,$field); + if ( $key eq '.' ) { next; } + if ( !exists($fields{$key}) ) { push @out,$field; next; } + } + + # Now add the new values and remove the unwanted ones + while (my ($key,$value)=each %fields) + { + if ( !defined($value) ) { next; } # this one should be removed + if ( $value eq '' ) { push @out,$key; } # this one is of the form HM2 in contrast to DP=3 + else { push @out,"$key=$value"; } # this is the standard key=value pair + } + if ( !@out ) { push @out,'.'; } + return join(';',@out); +} + + +=head2 add_filter + + Usage : $x=$vcf->next_data_array(); $$x[6]=$vcf->add_filter($$x[6],'SnpCluster'=>1,'q10'=>0); print join("\t",@$x)."\n"; + Args : The record obtained by next_data_array or next_data_hash + : The key-value pairs for filter to be added. If value is 1, the filter will be added. If 0, the filter will be removed. + Returns : The formatted filter field. + +=cut + +sub add_filter +{ + my ($self,$filter,%filters) = @_; + + my @out = (); + my @filters = ref($filter) eq 'ARRAY' ? @$filter : split(/;/,$filter); + + # First handle the existing filters, keep everything unless in %filters + for my $key (@filters) + { + if ( $key eq '.' or $key eq 'PASS' ) { next; } + if ( !exists($filters{$key}) ) { push @out,$key; next; } + } + + # Now add the new filters and remove the unwanted ones + while (my ($key,$value)=each %filters) + { + if ( !$value ) { next; } # this one should be removed + push @out,$key; # this one should be added + } + if ( !@out ) { push @out,'PASS'; } + return ref($filter) eq 'ARRAY' ? return \@out : join(';',@out); +} + + +=head2 validate_filter_field + + Usage : my $x = $vcf->next_data_hash(); $vcf->validate_filter_field($$x{FILTER}); + Args : The FILTER arrayref + Returns : Error message in case of an error. + +=cut + +sub validate_filter_field +{ + my ($self,$values) = @_; + + if ( @$values == 1 && $$values[0] eq '.' ) { return undef; } + + my @errs; + my @missing; + for my $item (@$values) + { + if ( $item eq $$self{filter_passed} ) { next; } + if ( $item=~/,/ ) { push @errs,"Expected semicolon as a separator."; } + if ( exists($$self{reserved}{FILTER}{$item}) ) { return qq[The filter name "$item" cannot be used, it is a reserved word.]; } + if ( exists($$self{header}{FILTER}{$item}) ) { next; } + push @missing, $item; + $self->add_header_line({key=>'FILTER',ID=>$item,Description=>'No description'}); + } + if ( !@errs && !@missing ) { return undef; } + if ( $$self{version}<3.3 ) { return undef; } + return join(',',@errs) .' '. 'The filter(s) [' . join(',',@missing) . '] not listed in the header.'; +} + + +sub _add_unknown_field +{ + my ($self,$field,$key,$nargs) = @_; + $self->add_header_line({key=>$field,ID=>$key,Number=>$nargs,Type=>'String',Description=>'No description'}); +} + +=head2 validate_header + + About : Version specific header validation code. + Usage : my $vcf = Vcf->new(); $vcf->parse_header(); $vcf->validate_header(); + Args : + +=cut + +sub validate_header +{ + my ($self) = @_; +} + +=head2 validate_line + + About : Version specific line validation code. + Usage : my $vcf = Vcf->new(); $vcf->parse_header(); $x = $vcf->next_data_hash; $vcf->validate_line($x); + Args : + +=cut + +sub validate_line +{ + my ($self,$x) = @_; + + # Is the ID composed of alphanumeric chars + if ( !($$x{ID}=~/^[\w;\.]+$/) ) { $self->warn("Expected alphanumeric ID at $$x{CHROM}:$$x{POS}, but got [$$x{ID}]\n"); } +} + +=head2 validate_info_field + + Usage : my $x = $vcf->next_data_hash(); $vcf->validate_info_field($$x{INFO},$$x{ALT}); + Args : The INFO hashref + Returns : Error message in case of an error. + +=cut + +sub validate_info_field +{ + my ($self,$values,$alts) = @_; + + if ( !defined $values ) { return 'Empty INFO field.'; } + + # First handle the empty INFO field (.) + if ( scalar keys %$values == 1 && exists($$values{'.'}) ) { return undef; } + + # Expected numbers + my $ng = -1; + my $na = -1; + if ( $$self{version}>4.0 ) + { + if ( $$alts[0] eq '.' ) { $ng=1; $na=1; } + else + { + $na = @$alts; + $ng = (1+$na+1)*($na+1)/2; + } + } + + my @errs; + while (my ($key,$value) = each %$values) + { + if ( !exists($$self{header}{INFO}{$key}) ) + { + push @errs, "INFO tag [$key] not listed in the header" unless $$self{version}<3.3; + my $nargs = defined $value ? -1 : 0; + $self->_add_unknown_field('INFO',$key,$nargs); + next; + } + my $type = $$self{header}{INFO}{$key}; + + my @vals = defined $value ? split(/,/, $value) : (); + if ( $$type{Number} eq 'G' ) + { + if ( $ng != @vals && !(@vals==1 && $vals[0] eq '.') ) { push @errs, "INFO tag [$key=$value] expected different number of values (expected $ng, found ".scalar @vals.")"; } + } + elsif ( $$type{Number} eq 'A' ) + { + if ( $na != @vals && !(@vals==1 && $vals[0] eq '.') ) { push @errs, "INFO tag [$key=$value] expected different number of values (expected $na, found ".scalar @vals.")"; } + } + elsif ( $$type{Number}==0 ) + { + if ( defined($value) ) { push @errs, "INFO tag [$key] did not expect any parameters, got [$value]"; } + next; + } + elsif ( $$type{Number}!=-1 && @vals!=$$type{Number} ) + { + push @errs, "INFO tag [$key=$value] expected different number of values ($$type{Number})"; + } + if ( !$$type{handler} ) { next; } + for my $val (@vals) + { + my $err = &{$$type{handler}}($self,$val,$$type{default}); + if ( $err ) { push @errs, $err; } + } + } + if ( !@errs ) { return undef; } + return join(',',@errs); +} + +=head2 validate_gtype_field + + Usage : my $x = $vcf->next_data_hash(); $vcf->validate_gtype_field($$x{gtypes}{NA00001},$$x{ALT},$$x{FORMAT}); + Args : The genotype data hashref + The ALT arrayref + Returns : Error message in case of an error. + +=cut + +sub validate_gtype_field +{ + my ($self,$data,$alts,$format) = @_; + + # Expected numbers + my $ng = -1; + my $na = -1; + if ( $$self{version}>4.0 ) + { + if ( $$alts[0] eq '.' ) { $ng=1; $na=1; } + else + { + $na = @$alts; + $ng = (1+$na+1)*($na+1)/2; + } + } + + my @errs; + while (my ($key,$value) = each %$data) + { + if ( !exists($$self{header}{FORMAT}{$key}) ) + { + push @errs, "FORMAT tag [$key] not listed in the header" unless $$self{version}<3.3; + $self->_add_unknown_field('FORMAT',$key,-1); + next; + } + my $type = $$self{header}{FORMAT}{$key}; + + my @vals = split(/,/, $value); + if ( $$type{Number} eq 'G' ) + { + if ( $ng != @vals && !(@vals==1 && $vals[0] eq '.') ) { push @errs, "FORMAT tag [$key] expected different number of values (expected $ng, found ".scalar @vals.")"; } + } + elsif ( $$type{Number} eq 'A' ) + { + if ( $na != @vals && !(@vals==1 && $vals[0] eq '.') ) { push @errs, "FORMAT tag [$key] expected different number of values (expected $na, found ".scalar @vals.")"; } + } + elsif ( $$type{Number}!=-1 && @vals!=$$type{Number} ) + { + push @errs, "FORMAT tag [$key] expected different number of values ($$type{Number})"; + } + if ( !$$type{handler} ) { next; } + for my $val (@vals) + { + my $err = &{$$type{handler}}($self,$val,$$type{default}); + if ( $err ) { push @errs, $err; } + } + } + if ( !exists($$data{GT}) ) { push @errs, "The mandatory tag GT not present." unless $$self{ignore_missing_GT}; } + else + { + my $buf = $$data{GT}; + while ($buf ne '') + { + my $al = $buf; + if ( $buf=~$$self{regex_gtsep} ) + { + $al = $`; + $buf = $'; + if ( $buf eq '' ) { push @errs, "Unable to parse the GT field [$$data{GT}]."; last; } + } + else + { + $buf = ''; + } + + if ( !defined $al ) { push @errs, "Unable to parse the GT field [$$data{GT}]."; last; } + if ( $al eq '.' ) { next; } + if ( $al eq '0' ) { next; } + if ( !($al=~/^[0-9]+$/) ) { push @errs, "Unable to parse the GT field [$$data{GT}], expected integer."; last; } + if ( !exists($$alts[$al-1]) ) { push @errs, "Bad ALT value in the GT field, the index [$al] out of bounds [$$data{GT}]."; last; } + } + } + if ( !@errs ) { return undef; } + return join(',',@errs); +} + + +sub validate_ref_field +{ + my ($self,$ref) = @_; + if ( !($ref=~/^[ACGTN]$/) ) { return "Expected one of A,C,G,T,N, got [$ref]\n"; } + return undef; +} + +sub validate_int +{ + my ($self,$value,$default) = @_; + + if ( defined($default) && $value eq $default ) { return undef; } + if ( $value =~ /^-?\d+$/ ) { return undef; } + return "Could not validate the int [$value]"; +} + +sub validate_float +{ + my ($self,$value,$default) = @_; + if ( defined($default) && $value eq $default ) { return undef; } + if ( $value =~ /^-?\d+(?:\.\d*)$/ ) { return undef; } + if ( $value =~ /^-?\d*(?:\.\d+)$/ ) { return undef; } + if ( $value =~ /^-?\d+$/ ) { return undef; } + if ( $value =~ /^-?\d*(?:\.?\d+)(?:[Ee][-+]?\d+)?$/ ) { return undef; } + return "Could not validate the float [$value]"; +} + +sub validate_char +{ + my ($self,$value,$default) = @_; + + if ( defined($default) && $value eq $default ) { return undef; } + if ( length($value)==1) { return undef; } + return "Could not validate the char value [$value]"; +} + + +=head2 run_validation + + About : Validates the VCF file. + Usage : my $vcf = Vcf->new(file=>'file.vcf'); $vcf->run_validation('example.vcf.gz'); + Args : File name or file handle. + +=cut + +sub run_validation +{ + my ($self) = @_; + + $self->parse_header(); + $self->validate_header(); + + if ( !exists($$self{header}) ) { $self->warn(qq[The header not present.\n]); } + elsif ( !exists($$self{header}{fileformat}) ) + { + $self->warn(qq[The "fileformat" field not present in the header, assuming VCFv$$self{version}\n]); + } + elsif ( $$self{header_lines}[0]{key} ne 'fileformat' ) + { + $self->warn(qq[The "fileformat" not the first line in the header\n]); + } + if ( !exists($$self{columns}) ) { $self->warn("No column descriptions found.\n"); } + + my $default_qual = $$self{defaults}{QUAL}; + my $warn_sorted=1; + my $warn_duplicates = exists($$self{warn_duplicates}) ? $$self{warn_duplicates} : 1; + my ($prev_chrm,$prev_pos); + while (my $line=$self->next_data_array()) + { + for (my $i=0; $i<@$line; $i++) + { + if (!defined($$line[$i]) or $$line[$i] eq '' ) + { + my $colname = $i<@{$$self{columns}} ? $$self{columns}[$i] : $i+1; + $self->warn("The column $colname is empty at $$line[0]:$$line[1].\n"); + } + } + + my $x = $self->next_data_hash($line); + $self->validate_line($x); + + # Is the position numeric? + if ( !($$x{POS}=~/^\d+$/) ) { $self->warn("Expected integer for the position at $$x{CHROM}:$$x{POS}\n"); } + + if ( $warn_duplicates ) + { + if ( $prev_chrm && $prev_chrm eq $$x{CHROM} && $prev_pos eq $$x{POS} ) + { + $self->warn("Warning: Duplicate entries, for example $$x{CHROM}:$$x{POS}\n"); + $warn_duplicates = 0; + } + } + + # Is the file sorted? + if ( $warn_sorted ) + { + if ( $prev_chrm && $prev_chrm eq $$x{CHROM} && $prev_pos > $$x{POS} ) + { + $self->warn("Warning: The file is not sorted, for example $$x{CHROM}:$$x{POS} comes after $prev_chrm:$prev_pos\n"); + $warn_sorted = 0; + } + $prev_chrm = $$x{CHROM}; + $prev_pos = $$x{POS}; + } + + # The reference base: one of A,C,G,T,N, non-empty. + my $err = $self->validate_ref_field($$x{REF}); + if ( $err ) { $self->warn("$$x{CHROM}:$$x{POS} .. $err\n"); } + + # The ALT field (alternate non-reference base) + $err = $self->validate_alt_field($$x{ALT},$$x{REF}); + if ( $err ) { $self->warn("$$x{CHROM}:$$x{POS} .. $err\n"); } + + # The QUAL field + my $ret = $self->validate_float($$x{QUAL},$default_qual); + if ( $ret ) { $self->warn("QUAL field at $$x{CHROM}:$$x{POS} .. $ret\n"); } + elsif ( $$x{QUAL}=~/^-?\d+$/ && $$x{QUAL}<-1 ) { $self->warn("QUAL field at $$x{CHROM}:$$x{POS} is negative .. $$x{QUAL}\n"); } + + # The FILTER field + $err = $self->validate_filter_field($$x{FILTER}); + if ( $err ) { $self->warn("FILTER field at $$x{CHROM}:$$x{POS} .. $err\n"); } + + # The INFO field + $err = $self->validate_info_field($$x{INFO},$$x{ALT}); + if ( $err ) { $self->warn("INFO field at $$x{CHROM}:$$x{POS} .. $err\n"); } + + while (my ($gt,$data) = each %{$$x{gtypes}}) + { + $err = $self->validate_gtype_field($data,$$x{ALT},$$x{FORMAT}); + if ( $err ) { $self->warn("column $gt at $$x{CHROM}:$$x{POS} .. $err\n"); } + } + + if ( scalar keys %{$$x{gtypes}} && (exists($$x{INFO}{AN}) || exists($$x{INFO}{AC})) ) + { + my $nalt = scalar @{$$x{ALT}}; + if ( $nalt==1 && $$x{ALT}[0] eq '.' ) { $nalt=0; } + my ($an,$ac) = $self->calc_an_ac($$x{gtypes},$nalt); # Allow alleles in ALT which are absent in samples + if ( exists($$x{INFO}{AN}) && $an ne $$x{INFO}{AN} ) + { + $self->warn("$$x{CHROM}:$$x{POS} .. AN is $$x{INFO}{AN}, should be $an\n"); + } + if ( exists($$x{INFO}{AC}) && $ac ne $$x{INFO}{AC} ) + { + $self->warn("$$x{CHROM}:$$x{POS} .. AC is $$x{INFO}{AC}, should be $ac\n"); + } + } + } +} + + +=head2 get_chromosomes + + About : Get list of chromosomes from the VCF file. Must be bgzipped and tabix indexed. + Usage : my $vcf = Vcf->new(); $vcf->get_chromosomes(); + Args : none + +=cut + +sub get_chromosomes +{ + my ($self) = @_; + if ( !$$self{file} ) { $self->throw(qq[The parameter "file" not set.\n]); } + my (@out) = `tabix -l '$$self{file}'`; + if ( $? ) + { + my @has_tabix = `which tabix`; + if ( !@has_tabix ) { $self->throw(qq[The command "tabix" not found, please add it to your PATH\n]); } + $self->throw(qq[The command "tabix -l $$self{file}" exited with an error. Is the file tabix indexed?\n]); + } + for (my $i=0; $i<@out; $i++) { chomp($out[$i]); } + return \@out; +} + + +=head2 get_samples + + About : Get list of samples. + Usage : my $vcf = Vcf->new(); $vcf->parse_header(); my (@samples) = $vcf->get_samples(); + Args : none + +=cut + +sub get_samples +{ + my ($self) = @_; + my $n = @{$$self{columns}} - 1; + return (@{$$self{columns}}[9..$n]); +} + + +#------------------------------------------------ +# Version 3.2 specific functions + +package Vcf3_2; +use base qw(VcfReader); + +sub new +{ + my ($class,@args) = @_; + my $self = $class->SUPER::new(@args); + bless $self, ref($class) || $class; + + $$self{_defaults} = + { + version => '3.2', + drop_trailings => 1, + filter_passed => 0, + + defaults => + { + QUAL => '-1', + default => '.', + Flag => undef, + GT => '.', + }, + + handlers => + { + Integer => \&VcfReader::validate_int, + Float => \&VcfReader::validate_float, + Character => \&VcfReader::validate_char, + String => undef, + Flag => undef, + }, + + regex_snp => qr/^[ACGTN]$/i, + regex_ins => qr/^I[ACGTN]+$/, + regex_del => qr/^D\d+$/, + regex_gtsep => qr{[\\|/]}, + regex_gt => qr{^(\.|\d+)([\\|/]?)(\.?|\d*)$}, + regex_gt2 => qr{^(\.|[0-9ACGTNIDacgtn]+)([\\|/]?)}, + }; + + for my $key (keys %{$$self{_defaults}}) + { + $$self{$key}=$$self{_defaults}{$key}; + } + + + return $self; +} + + +#------------------------------------------------ +# Version 3.3 specific functions + +package Vcf3_3; +use base qw(VcfReader); + +sub new +{ + my ($class,@args) = @_; + my $self = $class->SUPER::new(@args); + bless $self, ref($class) || $class; + + $$self{_defaults} = + { + version => '3.3', + drop_trailings => 0, + filter_passed => 0, + + defaults => + { + QUAL => '-1', + Integer => '-1', + Float => '-1', + Character => '.', + String => '.', + Flag => undef, + GT => './.', + default => '.', + }, + + handlers => + { + Integer => \&VcfReader::validate_int, + Float => \&VcfReader::validate_float, + Character => \&VcfReader::validate_char, + String => undef, + Flag => undef, + }, + + regex_snp => qr/^[ACGTN]$/i, + regex_ins => qr/^I[ACGTN]+$/, + regex_del => qr/^D\d+$/, + regex_gtsep => qr{[\\|/]}, + regex_gt => qr{^(\.|\d+)([\\|/]?)(\.?|\d*)$}, + regex_gt2 => qr{^(\.|[0-9ACGTNIDacgtn]+)([\\|/]?)}, # . 0/1 0|1 A/A A|A D4/IACGT + }; + + for my $key (keys %{$$self{_defaults}}) + { + $$self{$key}=$$self{_defaults}{$key}; + } + + return $self; +} + + +#------------------------------------------------ +# Version 4.0 specific functions + +=head1 VCFv4.0 + +VCFv4.0 specific functions + +=cut + +package Vcf4_0; +use base qw(VcfReader); + +sub new +{ + my ($class,@args) = @_; + my $self = $class->SUPER::new(@args); + bless $self, ref($class) || $class; + + $$self{_defaults} = + { + version => '4.0', + drop_trailings => 1, + filter_passed => 'PASS', + + defaults => + { + QUAL => '.', + Flag => undef, + GT => '.', + default => '.', + }, + reserved => + { + FILTER => { 0=>1 }, + }, + + handlers => + { + Integer => \&VcfReader::validate_int, + Float => \&VcfReader::validate_float, + Character => \&VcfReader::validate_char, + String => undef, + Flag => undef, + }, + + regex_snp => qr/^[ACGTN]$|^<[\w:.]+>$/i, + regex_ins => qr/^[ACGTN]+$/, + regex_del => qr/^[ACGTN]+$/, + regex_gtsep => qr{[|/]}, # | / + regex_gt => qr{^(\.|\d+)([|/]?)(\.?|\d*)$}, # . ./. 0/1 0|1 + regex_gt2 => qr{^(\.|[0-9ACGTNacgtn]+|<[\w:.]+>)([|/]?)}, # . ./. 0/1 0|1 A/A A|A 0| + }; + + for my $key (keys %{$$self{_defaults}}) + { + $$self{$key}=$$self{_defaults}{$key}; + } + + return $self; +} + +sub Vcf4_0::format_header_line +{ + my ($self,$rec) = @_; + + my %tmp_rec = ( %$rec ); + if ( exists($tmp_rec{Number}) && $tmp_rec{Number} eq '-1' ) { $tmp_rec{Number} = '.' } + my $value; + if ( exists($tmp_rec{ID}) or $tmp_rec{key} eq 'PEDIGREE' ) + { + my %has = ( key=>1, handler=>1, default=>1 ); # Internal keys not to be output + my @items; + for my $key (qw(ID Number Type Description), sort keys %tmp_rec) + { + if ( !exists($tmp_rec{$key}) or $has{$key} ) { next; } + my $quote = ($key eq 'Description' or $tmp_rec{$key}=~/\s/) ? '"' : ''; + push @items, "$key=$quote$tmp_rec{$key}$quote"; + $has{$key}=1; + } + $value = '<' .join(',',@items). '>'; + } + else { $value = $tmp_rec{value}; } + + my $line = "##$tmp_rec{key}=".$value."\n"; + return $line; +} + +=head2 parse_header_line + + Usage : $vcf->parse_header_line(q[##FORMAT=]) + $vcf->parse_header_line(q[reference=1000GenomesPilot-NCBI36]) + Args : + Returns : + +=cut + +sub Vcf4_0::parse_header_line +{ + my ($self,$line) = @_; + + chomp($line); + $line =~ s/^##//; + + if ( !($line=~/^([^=]+)=/) ) { $self->throw("Expected key=value pair in the header: $line\n"); } + my $key = $1; + my $value = $'; + + if ( !($value=~/^<(.+)>\s*$/) ) + { + # Simple sanity check for subtle typos + if ( $key eq 'INFO' or $key eq 'FILTER' or $key eq 'FORMAT' or $key eq 'ALT' ) + { + $self->throw("Hmm, is this a typo? [$key] [$value]"); + } + return { key=>$key, value=>$value }; + } + + my $rec = { key=>$key }; + my $tmp = $1; + my ($attr_key,$attr_value,$quoted); + while ($tmp ne '') + { + if ( !defined $attr_key ) + { + if ( $tmp=~/^([^=]+)="/ ) { $attr_key=$1; $quoted=1; $tmp=$'; next; } + elsif ( $tmp=~/^([^=]+)=/ ) { $attr_key=$1; $quoted=0; $tmp=$'; next; } + else { $self->throw(qq[Could not parse header line: $line\nStopped at [$tmp].\n]); } + } + + if ( $tmp=~/^[^,\\"]+/ ) { $attr_value .= $&; $tmp = $'; } + if ( $tmp=~/^\\\\/ ) { $attr_value .= '\\\\'; $tmp = $'; next; } + if ( $tmp=~/^\\"/ ) { $attr_value .= '\\"'; $tmp = $'; next; } + if ( $tmp eq '' or ($tmp=~/^,/ && !$quoted) or $tmp=~/^"/ ) + { + if ( $attr_key=~/^\s+/ or $attr_key=~/\s+$/ or $attr_value=~/^\s+/ or $attr_value=~/\s+$/ ) + { + $self->warn("Leading or trailing space in attr_key-attr_value pairs is discouraged:\n\t[$attr_key] [$attr_value]\n\t$line\n"); + $attr_key =~ s/^\s+//; + $attr_key =~ s/\s+$//; + $attr_value =~ s/^\s+//; + $attr_value =~ s/\s+$//; + } + $$rec{$attr_key} = $attr_value; + $tmp = $'; + if ( $quoted && $tmp=~/^,/ ) { $tmp = $'; } + $attr_key = $attr_value = $quoted = undef; + next; + } + if ( $tmp=~/^,/ ) { $attr_value .= $&; $tmp = $'; next; } + $self->throw(qq[Could not parse header line: $line\nStopped at [$tmp].\n]); + } + + if ( $key ne 'PEDIGREE' && !exists($$rec{ID}) ) { $self->throw("Missing the ID tag in $line\n"); } + if ( $key eq 'INFO' or $key eq 'FILTER' or $key eq 'FORMAT' ) + { + if ( !exists($$rec{Description}) ) { $self->warn("Missing the Description tag in $line\n"); } + } + if ( exists($$rec{Number}) && $$rec{Number} eq '-1' ) { $self->warn("The use of -1 for unknown number of values is deprecated, please use '.' instead.\n\t$line\n"); } + if ( exists($$rec{Number}) && $$rec{Number} eq '.' ) { $$rec{Number}=-1; } + + return $rec; +} + +sub Vcf4_0::validate_ref_field +{ + my ($self,$ref) = @_; + if ( !($ref=~/^[ACGTN]+$/) ) + { + my $offending = $ref; + $offending =~ s/[ACGTN]+//g; + return "Expected combination of A,C,G,T,N for REF, got [$ref], the offending chars were [$offending]\n"; + } + return undef; +} + +sub Vcf4_0::validate_alt_field +{ + my ($self,$values,$ref) = @_; + + if ( @$values == 1 && $$values[0] eq '.' ) { return undef; } + + my $ret = $self->_validate_alt_field($values,$ref); + if ( $ret ) { return $ret; } + + my $ref_len = length($ref); + my $ref1 = substr($ref,0,1); + + my @err; + my $msg = ''; + for my $item (@$values) + { + if ( !($item=~/^[ACTGN]+$|^<[^<>\s]+>$/) ) { push @err,$item; next; } + if ( $item=~/^<[^<>\s]+>$/ ) { next; } + if ( $ref_len==length($item) ) { next; } + if ( substr($item,0,1) ne $ref1 ) { $msg=', first base does not match the reference.'; push @err,$item; next; } + } + if ( !@err ) { return undef; } + return 'Could not parse the allele(s) [' .join(',',@err). ']' . $msg; +} + + +=head2 fill_ref_alt_mapping + + About : A tool for merging VCFv4.0 records. The subroutine unifies the REFs and creates a mapping + from the original haplotypes to the haplotypes based on the new REF. Consider the following + example: + REF ALT + G GA + GT G + GT GA + GT GAA + GTC G + G + my $map={G=>{GA=>1},GT=>{G=>1,GA=>1,GAA=>1},GTC=>{G=>1},G=>{''=>1}}; + my $new_ref=$vcf->fill_ref_alt_mapping($map); + + The call returns GTC and $map is now + G GA -> GTC GATC + GT G -> GTC GC + GT GA -> GTC GAC + GT GAA -> GTC GAAC + GTC G -> GTC G + G -> GTC + Args : + Returns : New REF string and fills the hash with appropriate ALT. + +=cut + +sub Vcf4_0::fill_ref_alt_mapping +{ + my ($self,$map) = @_; + + my $max_len = 0; + my $new_ref; + for my $ref (keys %$map) + { + my $len = length($ref); + if ( $max_len<$len ) + { + $max_len = $len; + $new_ref = $ref; + } + $$map{$ref}{$ref} = 1; + } + for my $ref (keys %$map) + { + my $rlen = length($ref); + if ( substr($new_ref,0,$rlen) ne $ref ) { $self->throw("The reference prefixes do not agree: $ref vs $new_ref\n"); } + for my $alt (keys %{$$map{$ref}}) + { + # The second part of the regex is for VCF>4.0, but does no harm for v<=4.0 + if ( $alt=~/^<.+>$/ or $alt=~/\[|\]/ ) { $$map{$ref}{$alt} = $alt; next; } + my $new = $alt; + if ( $rlen<$max_len ) { $new .= substr($new_ref,$rlen); } + $$map{$ref}{$alt} = $new; + } + } + return $new_ref; +} + + + +sub Vcf4_0::event_type +{ + my ($self,$rec,$allele) = @_; + + my $ref = $rec; + if ( ref($rec) eq 'HASH' ) + { + if ( exists($$rec{_cached_events}{$allele}) ) { return (@{$$rec{_cached_events}{$allele}}); } + $ref = $$rec{REF}; + } + + if ( $allele=~/^<[^>]+>$/ ) + { + if ( ref($rec) eq 'HASH' ) { $$rec{_cached_events}{$allele} = ['u',0,$allele]; } + return ('u',0,$allele); + } + if ( $allele eq '.' ) + { + if ( ref($rec) eq 'HASH' ) { $$rec{_cached_events}{$allele} = ['r',0,$ref]; } + return ('r',0,$ref); + } + + my $reflen = length($ref); + my $len = length($allele); + + my $ht; + my $type; + if ( $len==$reflen ) + { + # This can be a reference, a SNP, or multiple SNPs + my $mism = 0; + for (my $i=0; $i<$len; $i++) + { + if ( substr($ref,$i,1) ne substr($allele,$i,1) ) { $mism++; } + } + if ( $mism==0 ) { $type='r'; $len=0; } + else { $type='s'; $len=$mism; } + } + else + { + ($len,$ht)=$self->is_indel($ref,$allele); + if ( $len ) + { + # Indel + $type = 'i'; + $allele = $ht; + } + else + { + $type = 'o'; $len = $len>$reflen ? $len-1 : $reflen-1; + } + } + + if ( ref($rec) eq 'HASH' ) + { + $$rec{_cached_events}{$allele} = [$type,$len,$allele]; + } + return ($type,$len,$allele); +} + +# The sequences start at the same position, which simplifies things greatly. +# Returns length of the indel (+ insertion, - deletion), the deleted/inserted sequence +# and the position of the first base after the shared sequence +sub is_indel +{ + my ($self,$seq1,$seq2) = @_; + + my $len1 = length($seq1); + my $len2 = length($seq2); + if ( $len1 eq $len2 ) { return (0,'',0); } + + my ($del,$len,$LEN); + if ( $len1<$len2 ) + { + $len = $len1; + $LEN = $len2; + $del = 1; + } + else + { + $len = $len2; + $LEN = $len1; + $del = -1; + my $tmp=$seq1; $seq1=$seq2; $seq2=$tmp; + } + + my $ileft; + for ($ileft=0; $ileft<$len; $ileft++) + { + if ( substr($seq1,$ileft,1) ne substr($seq2,$ileft,1) ) { last; } + } + if ( $ileft==$len ) + { + return ($del*($LEN-$len), substr($seq2,$ileft), $ileft); + } + + my $iright; + for ($iright=0; $iright<$len; $iright++) + { + if ( substr($seq1,$len-$iright,1) ne substr($seq2,$LEN-$iright,1) ) { last; } + } + if ( $iright+$ileft<=$len ) { return (0,'',0); } + + return ($del*($LEN-$len),substr($seq2,$ileft,$LEN-$len),$ileft); +} + + +#------------------------------------------------ +# Version 4.1 specific functions + +=head1 VCFv4.1 + +VCFv4.1 specific functions + +=cut + +package Vcf4_1; +use base qw(Vcf4_0); + +sub new +{ + my ($class,@args) = @_; + my $self = $class->SUPER::new(@args); + bless $self, ref($class) || $class; + + $$self{_defaults} = + { + version => '4.1', + drop_trailings => 1, + filter_passed => 'PASS', + + defaults => + { + QUAL => '.', + Flag => undef, + GT => '.', + default => '.', + }, + reserved => + { + FILTER => { 0=>1 }, + }, + + handlers => + { + Integer => \&VcfReader::validate_int, + Float => \&VcfReader::validate_float, + Character => \&VcfReader::validate_char, + String => undef, + Flag => undef, + }, + + regex_snp => qr/^[ACGTN]$|^<[\w:.]+>$/i, + regex_ins => qr/^[ACGTN]+$/i, + regex_del => qr/^[ACGTN]+$/i, + regex_gtsep => qr{[|/]}, # | / + regex_gt => qr{^(\.|\d+)([|/]?)(\.?|\d*)$}, # . ./. 0/1 0|1 + regex_gt2 => qr{^(\.|[0-9ACGTNacgtn]+|<[\w:.]+>)([|/]?)}, # . ./. 0/1 0|1 A/A A|A 0| + }; + + $$self{ignore_missing_GT} = 1; + + for my $key (keys %{$$self{_defaults}}) + { + $$self{$key}=$$self{_defaults}{$key}; + } + + return $self; +} + +sub Vcf4_1::validate_header +{ + my ($self) = @_; + my $lines = $self->get_header_line(key=>'reference'); + if ( !@$lines ) { $self->warn("The header tag 'reference' not present. (Not required but highly recommended.)\n"); } +} + +sub Vcf4_1::validate_line +{ + my ($self,$line) = @_; + + if ( !$$self{_contig_validated}{$$line{CHROM}} ) + { + my $lines = $self->get_header_line(key=>'contig',ID=>$$line{CHROM}); + if ( !@$lines ) { $self->warn("The header tag 'contig' not present for CHROM=$$line{CHROM}. (Not required but highly recommended.)\n"); } + $$self{_contig_validated}{$$line{CHROM}} = 1; + } + + # Is the ID composed of alphanumeric chars + if ( !($$line{ID}=~/^\S+$/) ) { $self->warn("Expected non-whitespace ID at $$line{CHROM}:$$line{POS}, but got [$$line{ID}]\n"); } +} + +sub Vcf4_1::validate_alt_field +{ + my ($self,$values,$ref) = @_; + + if ( @$values == 1 && $$values[0] eq '.' ) { return undef; } + + my $ret = $self->_validate_alt_field($values,$ref); + if ( $ret ) { return $ret; } + + my $ref_len = length($ref); + my $ref1 = substr($ref,0,1); + + my @err; + my $msg = ''; + for my $item (@$values) + { + if ( $item=~/^(.*)\[(.+)\[(.*)$/ or $item=~/^(.*)\](.+)\](.*)$/ ) + { + if ( $1 ne '' && $3 ne '' ) { $msg=', two replacement strings given (expected one)'; push @err,$item; next; } + my $rpl; + if ( $1 ne '' ) + { + $rpl = $1; + if ( $rpl ne '.' ) + { + my $rref = substr($rpl,0,1); + if ( $rref ne $ref1 ) { $msg=', the first base of the replacement string does not match the reference'; push @err,$item; next; } + } + } + else + { + $rpl = $3; + if ( $rpl ne '.' ) + { + my $rref = substr($rpl,-1,1); + if ( $rref ne $ref1 ) { $msg=', the last base of the replacement string does not match the reference'; push @err,$item; next; } + } + } + my $pos = $2; + if ( !($rpl=~/^[ACTGNacgtn]+$/) && $rpl ne '.' ) { $msg=', replacement string not valid (expected [ACTGNacgtn]+)'; push @err,$item; next; } + if ( !($pos=~/^\S+:\d+$/) ) { $msg=', cannot parse sequence:position'; push @err,$item; next; } + next; + } + if ( $item=~/^\.[ACTGNactgn]*([ACTGNactgn])$/ ) + { + if ( $ref1 ne $1 ) { $msg=', last base does not match the reference'; push @err,$item; } + next; + } + elsif ( $item=~/^([ACTGNactgn])[ACTGNactgn]*\.$/ ) + { + if ( substr($ref,-1,1) ne $1 ) { $msg=', first base does not match the reference'; push @err,$item; } + next; + } + if ( !($item=~/^[ACTGNactgn]+$|^<[^<>\s]+>$/) ) { push @err,$item; next; } + if ( $item=~/^<[^<>\s]+>$/ ) { next; } + if ( $ref_len==length($item) ) { next; } + if ( substr($item,0,1) ne $ref1 ) { $msg=', first base does not match the reference'; push @err,$item; next; } + } + if ( !@err ) { return undef; } + return 'Could not parse the allele(s) [' .join(',',@err). ']' . $msg; +} + +sub Vcf4_1::next_data_hash +{ + my ($self,@args) = @_; + + my $out = $self->SUPER::next_data_hash(@args); + if ( !defined $out or $$self{assume_uppercase} ) { return $out; } + + # Case-insensitive ALT and REF bases + $$out{REF} = uc($$out{REF}); + my $nalt = @{$$out{ALT}}; + for (my $i=0; $i<$nalt; $i++) + { + if ( $$out{ALT}[$i]=~/^SUPER::next_data_array(@args); + if ( !defined $out or $$self{assume_uppercase} ) { return $out; } + + # Case-insensitive ALT and REF bases + $$out[3] = uc($$out[3]); + my $alt = $$out[4]; + $$out[4] = ''; + my $pos = 0; + while ( $pos',$start+1); + if ( $end==-1 ) { $self->throw("Could not parse ALT [$alt]\n") } + if ( $start>$pos ) + { + $$out[4] .= uc(substr($alt,$pos,$start-$pos)); + } + $$out[4] .= substr($alt,$start,$end-$start+1); + $pos = $end+1; + } + if ( $posSUPER::event_type($rec,$allele); +} + +1; + diff --git a/app.rb b/app.rb new file mode 100644 index 00000000..a9d12656 --- /dev/null +++ b/app.rb @@ -0,0 +1,186 @@ +require 'yaml' +require 'logger' +require 'sequel' +require 'sinatra/base' +require 'puma/server' + +module App + + class Routes < Sinatra::Base + class << self + ## OVERRIDE + def middleware + @middleware + end + + def inherited(klass) + super + use klass + end + ## /OVERRIDE + end + + enable :logging + + disable :show_exceptions + end + + class Server < Puma::Server + + attr_reader :binds, :daemonize + + def initialize(binds, daemonize: false) + @binds = binds + @daemonize = daemonize + super nil + end + + def serve(app) + self.app = app + binder.parse binds, events + if daemonize + events.log "* Daemonizing ..." + Process.daemon(true) + end + run.join + rescue Interrupt + # swallow it + end + end + + extend self + + def init_config(**config) + defaults = { + db_uri: 'postgres://localhost', + binds: ['tcp://localhost:9292'] + } + @config = defaults.update config + rescue Errno::ENOENT + puts "Couldn't find file: #{config}." + end + + def init_db + @db = Sequel.connect config[:db_uri], loggers: Logger.new($stderr) + @db.extension :pg_json + @db.extension :pg_array + Sequel.extension :pg_json_ops + end + + def init_server + @server = Server.new(config[:binds], daemonize: config[:daemonize]) + end + + attr_reader :config, :db, :server + + def load_models + init_db + Sequel::Model.db = db + Sequel::Model.plugin :json_serializer + Dir['models/*.rb'].each do |model| + require_relative model + end + rescue Sequel::DatabaseConnectionError + puts "Couldn't connect to database." + exit + end + + def load_routes + Dir['routes/*.rb'].each do |route| + require_relative route + end + end + + def migrate(version: nil, **options) + init_config **options + init_db + Sequel.extension :migration + db.extension :constraint_validations + Sequel::Migrator.apply(db, File.expand_path('migrations'), version) + rescue Sequel::DatabaseConnectionError + puts "Couldn't connect to database." + exit + end + + def current_migration(**options) + init_config **options + init_db + Sequel.extension :migration + Sequel::IntegerMigrator.new(db, File.expand_path('migrations')).current + end + + def gff2jbrowse(**options) + init_config **options + puts "Converting GFF to JBrowse ..." + system "bin/gff2jbrowse.pl -o data/jbrowse 'data/gene/Solenopsis invicta/Si_gnF.gff'" + puts "Generateing index ..." + system "bin/generate-names.pl -o data/jbrowse" + end + + def register_features(**options) + puts "Registering features ..." + init_config **options + init_db + load_models + Dir[File.join('data', 'jbrowse', 'tracks', 'maker', '*')].each do |dir| + next if dir =~ /^\.+/ + names = File.readlines File.join(dir, 'names.txt') + names.each do |name| + name = eval name.chomp + + PredictedFeature.create({ + name: name[-4], + ref: name[-3], + start: name[-2], + end: name[-1] + }) + end + end + end + + def create_tasks(**options) + puts "Creating tasks ..." + init_config **options + init_db + load_models + Feature.each do |feature| + CurationTask.create(feature: feature) + end + end + + def auto_check_tasks(**options) + puts "Auto check ..." + init_config **options + load_models + Task.all.select do |t| + next unless t.submissions.count >= 3 # TODO: I think it should be == 3 + + if t.submissions.uniq.length == 1 + t.contributions.each do |contribution| + contribution.status = 'accepted' + contribution.save + end + else + t.contributions.each do |contribution| + contribution.status = 'accepted' + contribution.save + end + end + end + end + + def irb(**options) + init_config **options + load_models + require 'irb' + IRB.start + end + + def serve(**options) + init_config **options + load_models + load_routes + init_server + server.serve(Routes) + end +end diff --git a/bin/add-bam-track.pl b/bin/add-bam-track.pl new file mode 100755 index 00000000..c55abe91 --- /dev/null +++ b/bin/add-bam-track.pl @@ -0,0 +1,196 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Getopt::Long qw(:config no_ignore_case bundling); +use IO::File; +use File::Basename; +use JSON; + +use Pod::Usage; + +my $STORE_CLASS = "JBrowse/Store/SeqFeature/BAM"; +my $ALIGNMENT_TYPE = "JBrowse/View/Track/Alignments2"; +my $COVERAGE_TYPE = "JBrowse/View/Track/SNPCoverage"; + +my $in_file; +my $out_file; +my $label; +my $bam_url; +my $key; +my $coverage = 0; +my $classname = undef; +my $min_score = undef; +my $max_score = undef; + +parse_options(); +add_bam_track(); +exit; + +sub parse_options { + my $help; + GetOptions("in|i=s" => \$in_file, + "out|o=s" => \$out_file, + "label|l=s" => \$label, + "bam_url|u=s" => \$bam_url, + "key|k=s" => \$key, + "classname|c=s" => \$classname, + "coverage|C" => \$coverage, + "min_score|s=i" => \$min_score, + "max_score|S=i" => \$max_score, + "help|h" => \$help); + pod2usage( -verbose => 2 ) if $help; + pod2usage("Missing label option") if !$label; + pod2usage("Missing bam_url option") if !$bam_url; + pod2usage("Missing min_score option") if $coverage && !defined $min_score; + pod2usage("Missing max_score option") if $coverage && !defined $max_score; + $key ||= $label; + $in_file ||= 'data/trackList.json'; + $out_file ||= $in_file; +} + + +sub add_bam_track { + my $json = new JSON; + local $/; + my $in; + $in = new IO::File($in_file) or + die "Error reading input $in_file: $!"; + my $track_list_contents = <$in>; + $in->close(); + my $track_list = $json->decode($track_list_contents); + my $bam_entry; + my $index; + my $tracks = $track_list->{tracks}; + for ($index = 0; $index < scalar(@{$tracks}); ++$index) { + my $track = $tracks->[$index]; + if ($track->{label} eq $label) { + $bam_entry = $track; + last; + } + } + if (!$bam_entry) { + $bam_entry = !$coverage ? generate_new_bam_alignment_entry() : + generate_new_bam_coverage_entry(); + push @{$track_list->{tracks}}, $bam_entry; + } + else { + if ($coverage) { + if ($bam_entry->{type} eq $ALIGNMENT_TYPE) { + $bam_entry = generate_new_bam_coverage_entry(); + $tracks->[$index] = $bam_entry; + } + } + else { + if ($bam_entry->{type} eq $COVERAGE_TYPE) { + $bam_entry = generate_new_bam_alignment_entry(); + $tracks->[$index] = $bam_entry; + } + } + } + $bam_entry->{label} = $label; + $bam_entry->{urlTemplate} = $bam_url; + $bam_entry->{key} = $key; + if (!$coverage) { + if (defined $classname) { + if (! $bam_entry->{style}) { + $bam_entry->{style} = {}; + } + $bam_entry->{style}->{className} = $classname; + } + } + else { + $bam_entry->{min_score} = $min_score; + $bam_entry->{max_score} = $max_score; + } + my $out; + $out = new IO::File($out_file, "w") or + die "Error writing output $out_file: $!"; + print $out $json->pretty->encode($track_list); + $out->close(); +} + +sub generate_new_bam_alignment_entry { + return { + storeClass => $STORE_CLASS, + type => $ALIGNMENT_TYPE, + }; +} + +sub generate_new_bam_coverage_entry { + return { + storeClass => $STORE_CLASS, + type => $COVERAGE_TYPE + }; +} + +__END__ + + +=head1 NAME + +add_bam_track.pl - add track configuration snippet(s) for BAM track(s) + +=cut + +=head1 USAGE + + add_bam_track.pl + [ --in ] \ + [ --out \ + --label \ + --bam_url \ + [ --key ] \ + [ --classname ] \ + [ --coverage ] \ + [ --min_score ] \ + [ --max_score ] \ + [ --help ] + +=head1 ARGUMENTS + +=over 4 + +=item --in + +input trackList.json file. Default: data/trackList.json. + +=item --out + +Output trackList.json file. Default: data/trackList.json. + +=item --bam_url + +URL to BAM file (can be a relative path) + +=item --label + +unique track label for the new track. + +=item --key + +key (display name) for track [default: label value] + +=item --classname + +CSS class for display [default: bam] + +=item --coverage + +display coverage data instead of alignments + +=item --min_score + +optional minimum score to use for generating coverage plot (only applicable with --coverage option) + +=item --max_score + +optional maximum score to use for generating coverage plot (only applicable with --coverage option) + +=back + +=cut diff --git a/bin/add-bw-track.pl b/bin/add-bw-track.pl new file mode 100755 index 00000000..30919768 --- /dev/null +++ b/bin/add-bw-track.pl @@ -0,0 +1,245 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Getopt::Long qw(:config no_ignore_case bundling); +use IO::File; +use File::Basename; +use JSON; + +use Pod::Usage; + +my $STORE_CLASS = "JBrowse/Store/SeqFeature/BigWig"; +my $HEATMAP_TYPE = "JBrowse/View/Track/Wiggle/Density"; +my $PLOT_TYPE = "JBrowse/View/Track/Wiggle/XYPlot"; + +my $in_file; +my $out_file; +my $label; +my $bw_url; +my $key; +my $plot = 0; +my $bicolor_pivot = "zero"; +my $pos_color = undef; +my $neg_color = undef; +my $min_score = undef; +my $max_score = undef; + +parse_options(); +add_bw_track(); + +sub parse_options { + my $help; + GetOptions("in|i=s" => \$in_file, + "out|o=s" => \$out_file, + "label|l=s" => \$label, + "bw_url|u=s" => \$bw_url, + "key|k=s" => \$key, + "plot|P" => \$plot, + "bicolor_pivot|b=s" => \$bicolor_pivot, + "pos_color|c=s" => \$pos_color, + "neg_color|C=s" => \$neg_color, + "min_score|s=i" => \$min_score, + "max_score|S=i" => \$max_score, + "help|h" => \$help); + pod2usage( -verbose => 2 ) if $help; + pod2usage( "Missing label option" ) if !$label; + pod2usage( "Missing bw_url option" ) if !$bw_url; + $key ||= $label; + $in_file ||= 'data/trackList.json'; + $out_file ||= $in_file; +} + +sub add_bw_track { + my $json = new JSON; + local $/; + my $in; + $in = new IO::File($in_file) or + die "Error reading input $in_file: $!"; + my $track_list_contents = <$in>; + $in->close(); + my $track_list = $json->decode($track_list_contents); + my $bw_entry; + + my $index; + my $tracks = $track_list->{tracks}; + for ($index = 0; $index < scalar(@{$tracks}); ++$index) { + my $track = $tracks->[$index]; + if ($track->{label} eq $label) { + $bw_entry = $track; + last; + } + } + +# foreach my $track (@{$track_list->{tracks}}) { +# if ($track->{label} eq $label) { +# $bw_entry = $track; +# last; +# } +# } + if (!$bw_entry) { + # $bw_entry = generate_new_bw_heatmap_entry(); + $bw_entry = !$plot ? generate_new_bw_heatmap_entry() : + generate_new_bw_plot_entry(); + + push @{$track_list->{tracks}}, $bw_entry; + } + else { + if ($plot) { + if ($bw_entry->{type} eq $HEATMAP_TYPE) { + $bw_entry = generate_new_bw_plot_entry(); + $tracks->[$index] = $bw_entry; + } + } + else { + if ($bw_entry->{type} eq $PLOT_TYPE) { + $bw_entry = generate_new_bw_heatmap_entry(); + $tracks->[$index] = $bw_entry; + } + } + } + + $bw_entry->{label} = $label; + $bw_entry->{autoscale} = "local"; + $bw_entry->{urlTemplate} = $bw_url; + $bw_entry->{key} = $key; + $bw_entry->{bicolor_pivot} = $bicolor_pivot; + if (defined $min_score) { + $bw_entry->{min_score} = $min_score; + } + else { + delete $bw_entry->{min_score}; + } + if (defined $max_score) { + $bw_entry->{max_score} = $max_score; + } + else { + delete $bw_entry->{max_score}; + } + if ($pos_color) { + $bw_entry->{style}->{pos_color} = $pos_color; + } + else { + delete $bw_entry->{style}->{pos_color}; + } + if ($neg_color) { + $bw_entry->{style}->{neg_color} = $neg_color; + } + else { + delete $bw_entry->{style}->{neg_color}; + } + delete $bw_entry->{style} if !scalar(keys %{$bw_entry->{style}}); + my $out; + $out = new IO::File($out_file, "w") or + die "Error writing output $out_file: $!"; + print $out $json->pretty->encode($track_list); + $out->close(); +} + +sub generate_new_bw_heatmap_entry { + return { + storeClass => $STORE_CLASS, + type => $HEATMAP_TYPE + }; +} + +sub generate_new_bw_plot_entry { + return { + storeClass => $STORE_CLASS, + type => $PLOT_TYPE + }; +} + +__END__ + + +=head1 NAME + +add_bw_track.pl - add track configuration snippet(s) for BAM track(s) + +=cut + +=head1 USAGE + + add_bw_track.pl + [ --in ] \ + [ --out ] \ + --label \ + --bw_url \ + [ --key ] \ + [ --plot ] \ + [ --bicolor_pivot ] \ + [ --pos_color ] \ + [ --neg_color ] \ + [ --min_score ] \ + [ --max_score ] \ + [ -h|--help ] + +=head1 ARGUMENTS + +=over 4 + +=item --bicolor_pivot + +point where to set pivot for color changes - can be "mean", "zero", or +a numeric value [default: zero] + +=item --plot + +display as XY plot instead of density heatmap + +=item --pos_color + +CSS color for positive side of pivot [default: blue] + +=item --neg_color + +CSS color for negative side of pivot [default: red] + +=item --in + +input trackList.json file. Default: data/trackList.json. + +=item --out + +Output trackList.json file. Default: data/trackList.json. + +=item --bw_url + +URL to BigWig file (can be relative to the trackList.json) + +=item --label + +unique track label for the new track. + +=item --key + +key (display name) for track [default: label value] + +=item --classname + +CSS class for display [default: bam] + +=item --mismatches + +display mismatches in alignment (generates no subfeatures) + +=item --coverage + +display coverage data instead of alignments + +=item --min_score + +optional minimum score to be graphed + +=item --max_score + +optional maximum score to be graphed + +=back + +=cut diff --git a/bin/add-json.pl b/bin/add-json.pl new file mode 100755 index 00000000..cbc19b2c --- /dev/null +++ b/bin/add-json.pl @@ -0,0 +1,45 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use IO::Handle; +use Pod::Usage; + +use JSON 2; + +my $j = JSON->new->pretty->relaxed; + +my ( $json, $filename ) = @ARGV; +pod2usage() unless $json && $filename; + +die "cannot read '$filename' not found" unless -f $filename && -r $filename; + +my $in = $j->decode( $json ); +my $data = $j->decode(do { + open my $f, '<', $filename or die "$! reading $filename"; + local $/; + scalar <$f> +}); + +%$data = ( %$data, %$in ); + +open my $f, '>', $filename or die "$! writing $filename"; +$f->print( $j->encode( $data ) ); + +__END__ + +=head1 NAME + +add-json.pl - write values into an existing JSON file + +=head1 USAGE + + # set dataset_id in an existing config file + add-json.pl '{ "dataset_id": "volvox" }' sample_data/json/volvox/trackList.json + +=cut + diff --git a/bin/add-track-json.pl b/bin/add-track-json.pl new file mode 100755 index 00000000..c0591271 --- /dev/null +++ b/bin/add-track-json.pl @@ -0,0 +1,94 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use IO::Handle; +use Pod::Usage; + +use Bio::JBrowse::JSON; + +@ARGV or pod2usage( -verbose => 2 ); + +# read in the JSON +my $j = Bio::JBrowse::JSON->new->pretty; +my $json_fh = + @ARGV == 1 ? \*STDIN : do { + my $file = shift @ARGV; + open( my $fh, '<', $file ) or die "$! reading $file"; + $fh + }; +my $track_data = $j->decode( do{ local $/; scalar <$json_fh> } ); + +# if it's a single definition, coerce to an array +if( ref $track_data eq 'HASH' ) { + $track_data = [ $track_data ]; +} + +# validate the track JSON structure +for my $def ( @$track_data ) { + $def->{label} or die "invalid track JSON: missing a label element\n"; +} + +# read and parse the target file +my $target_file = shift @ARGV or pod2usage(); +my $target_file_data = $j->decode_file( $target_file ); + +for my $def ( @$track_data ) { + for( my $i = 0; $i < @{$target_file_data->{tracks}|| []}; $i++ ) { + my $track = $target_file_data->{tracks}[$i]; + if( $track->{label} eq $def->{label} ) { + $target_file_data->{tracks}[$i] = $def; + undef $def; + } + } + + if( $def ) { + push @{ $target_file_data->{tracks} ||= [] }, $def; + } +} + +{ + open my $fh, '>', $target_file or die "$! writing $target_file"; + print $fh $j->encode( $target_file_data ); +} + + +__END__ + +=head1 NAME + +add-track-json.pl - add a single JSON track configuration snippet(from STDIN +or from a file) to the given JBrowse configuration file + +=head1 DESCRIPTION + +Reads a block of JSON describing a track from a file or from standard +input or from a file, and adds it to the target JBrowse configuration +file. + +For example, if you wanted to add a sequence track to +data/trackList.json, you could run something like: + + echo ' { "urlTemplate" : "seq/{refseq}/", + "label" : "DNA", + "type" : "SequenceTrack" + } ' | bin/add-track-json.pl data/trackList.json + + +=head1 USAGE + + bin/add-track-json.pl myTrack.json data/trackList.json + + # OR + + cat track.json | bin/add-track-json.pl data/trackList.json + +=head2 OPTIONS + +none yet + +=cut diff --git a/bin/biodb-to-json.pl b/bin/biodb-to-json.pl new file mode 100755 index 00000000..70b40c8d --- /dev/null +++ b/bin/biodb-to-json.pl @@ -0,0 +1,68 @@ +#!/usr/bin/env perl +use strict; +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Bio::JBrowse::Cmd::BioDBToJson; + +exit Bio::JBrowse::Cmd::BioDBToJson->new(@ARGV)->run; + +__END__ + +=head1 NAME + +biodb-to-json.pl - format JBrowse JSON as described in a configuration file + +=head1 DESCRIPTION + +Reads a configuration file, in a format currently documented in +docs/config.html, and formats JBrowse JSON from the data sources +defined in it. + +=head1 USAGE + + bin/biodb-to-json.pl \ + --conf \ + [--ref | --refid ] \ + [--track ] \ + [--out ] \ + [--compress] + + + # format the example volvox track data + bin/biodb-to-json.pl --conf docs/tutorial/conf_files/volvox.json + +=head2 OPTIONS + +=over 4 + +=item --help | -? | -h + +Display an extended help screen. + +=item --quiet | -q + +Quiet. Don't print progress messages. + +=item --conf + +Required. Path to the configuration file to read. File must be in JSON format. + +=item --ref | --refid + +Optional. Single reference sequence name or id for which to process data. + +By default, processes all data. + +=item --out + +Directory where output should go. Default: data/ + +=item --compress + +If passed, compress the output with gzip (requires some web server configuration to serve properly). + +=back + +=cut diff --git a/bin/cpanm b/bin/cpanm new file mode 100755 index 00000000..14a9a3cb --- /dev/null +++ b/bin/cpanm @@ -0,0 +1,11316 @@ +#!/usr/bin/env perl +# +# You want to install cpanminus? Run the following command and it will +# install itself for you. You might want to run it as a root with sudo +# if you want to install to places like /usr/local/bin. +# +# % curl -L http://cpanmin.us | perl - --self-upgrade +# +# If you don't have curl but wget, replace `curl -L` with `wget -O -`. +# +# For more details about this program, visit http://search.cpan.org/dist/App-cpanminus +# +# DO NOT EDIT -- this is an auto generated file +# This chunk of stuff was generated by App::FatPacker. To find the original +# file's code, look for the end of this BEGIN block or the string 'FATPACK' +BEGIN { +my %fatpacked; + +$fatpacked{"App/cpanminus.pm"} = <<'APP_CPANMINUS'; + package App::cpanminus; + our $VERSION = "1.5007"; + + =head1 NAME + + App::cpanminus - get, unpack, build and install modules from CPAN + + =head1 SYNOPSIS + + cpanm Module + + Run C for more options. + + =head1 DESCRIPTION + + cpanminus is a script to get, unpack, build and install modules from + CPAN and does nothing else. + + It's dependency free (can bootstrap itself), requires zero + configuration, and stands alone. When running, it requires only 10MB + of RAM. + + =head1 INSTALLATION + + There are several ways to install cpanminus to your system. + + =head2 Package management system + + There are Debian packages, RPMs, FreeBSD ports, and packages for other + operation systems available. If you want to use the package management system, + search for cpanminus and use the appropriate command to install. This makes it + easy to install C to your system without thinking about where to + install, and later upgrade. + + =head2 Installing to system perl + + You can also use the latest cpanminus to install cpanminus itself: + + curl -L http://cpanmin.us | perl - --sudo App::cpanminus + + This will install C to your bin directory like + C (unless you configured C with + L), so you probably need the C<--sudo> option. + + =head2 Installing to local perl (perlbrew) + + If you have perl in your home directory, which is the case if you use + tools like L, you don't need the C<--sudo> option, since + you're most likely to have a write permission to the perl's library + path. You can just do: + + curl -L http://cpanmin.us | perl - App::cpanminus + + to install the C executable to the perl's bin path, like + C<~/perl5/perlbrew/bin/cpanm>. + + =head2 Downloading the standalone executable + + You can also copy the standalone executable to whatever location you'd like. + + cd ~/bin + curl -LO http://xrl.us/cpanm + chmod +x cpanm + # edit shebang if you don't have /usr/bin/env + + This just works, but be sure to grab the new version manually when you + upgrade because C<--self-upgrade> might not work for this. + + =head1 DEPENDENCIES + + perl 5.8 or later. + + =over 4 + + =item * + + 'tar' executable (bsdtar or GNU tar version 1.22 are rcommended) or Archive::Tar to unpack files. + + =item * + + C compiler, if you want to build XS modules. + + =item * + + make + + =item * + + Module::Build (core in 5.10) + + =back + + =head1 QUESTIONS + + =head2 Another CPAN installer? + + OK, the first motivation was this: the CPAN shell runs out of memory (or swaps + heavily and gets really slow) on Slicehost/linode's most affordable plan with + only 256MB RAM. Should I pay more to install perl modules from CPAN? I don't + think so. + + =head2 But why a new client? + + First of all, let me be clear that CPAN and CPANPLUS are great tools + I've used for I years (you know how many modules I have on + CPAN, right?). I really respect their efforts of maintaining the most + important tools in the CPAN toolchain ecosystem. + + However, for less experienced users (mostly from outside the Perl community), + or even really experienced Perl developers who know how to shoot themselves in + their feet, setting up the CPAN toolchain often feels like yak shaving, + especially when all they want to do is just install some modules and start + writing code. + + =head2 Zero-conf? How does this module get/parse/update the CPAN index? + + It queries the CPAN Meta DB site running on Google AppEngine at + L. The site is updated every hour to reflect + the latest changes from fast syncing mirrors. The script then also falls back + to scrape the site L. + + Fetched files are unpacked in C<~/.cpanm> and automatically cleaned up + periodically. You can configure the location of this with the + C environment variable. + + =head2 Where does this install modules to? Do I need root access? + + It installs to wherever ExtUtils::MakeMaker and Module::Build are + configured to (via C and C). So if you're + using local::lib, then it installs to your local perl5 + directory. Otherwise it installs to the site_perl directory that + belongs to your perl. + + cpanminus at a boot time checks whether you have configured + local::lib, or have the permission to install modules to the site_perl + directory. If neither, it automatically sets up local::lib compatible + installation path in a C directory under your home + directory. To avoid this, run the script as the root user, with + C<--sudo> option or with C<--local-lib> option. + + =head2 cpanminus can't install the module XYZ. Is it a bug? + + It is more likely a problem with the distribution itself. cpanminus + doesn't support or is known to have issues with distributions like as + follows: + + =over 4 + + =item * + + Tests that require input from STDIN. + + =item * + + Tests that might fail when C is enabled. + + =item * + + Modules that have invalid numeric values as VERSION (such as C<1.1a>) + + =back + + These failures can be reported back to the author of the module so + that they can fix it accordingly, rather than me. + + =head2 Does cpanm support the feature XYZ of L and L? + + Most likely not. Here are the things that cpanm doesn't do by + itself. And it's a feature - you got that from the name I, + right? + + If you need these features, use L, L or the standalone + tools that are mentioned. + + =over 4 + + =item * + + Bundle:: module dependencies + + =item * + + CPAN testers reporting + + =item * + + Building RPM packages from CPAN modules + + =item * + + Listing the outdated modules that needs upgrading. See L + + =item * + + Uninstalling modules. See L. + + =item * + + Showing the changes of the modules you're about to upgrade. See L + + =item * + + Patching CPAN modules with distroprefs. + + =back + + See L or C to see what cpanminus I do :) + + =head1 COPYRIGHT + + Copyright 2010- Tatsuhiko Miyagawa + + The standalone executable contains the following modules embedded. + + =over 4 + + =item L Copyright 2003 Graham Barr + + =item L Copyright 2006-2009 Adam Kennedy + + =item L Copyright 2007-2009 Matt S Trout + + =item L Copyright 2011 Christian Hansen + + =item L Copyright 2001-2006 Ken Williams. 2010 Matt S Trout + + =item L Copyright 2004-2010 John Peacock + + =item L Copyright 2007−2011 by Makamaka Hannyaharamitu + + =item L Copyright (c) 2010 by David Golden and Ricardo Signes + + =item L Copyright (c) 2009 Yuval Kogman + + =item L Copyright (c) 2007-10 Max Maischein + + =item L copyright (c) 2010 by Ricardo Signes + + =item L copyright (c) 2010 by Adam Kennedy + + =back + + =head1 LICENSE + + Same as Perl. + + =head1 CREDITS + + =head2 CONTRIBUTORS + + Patches and code improvements were contributed by: + + Goro Fuji, Kazuhiro Osawa, Tokuhiro Matsuno, Kenichi Ishigaki, Ian + Wells, Pedro Melo, Masayoshi Sekimura, Matt S Trout (mst), squeeky, + horus and Ingy dot Net. + + =head2 ACKNOWLEDGEMENTS + + Bug reports, suggestions and feedbacks were sent by, or general + acknowledgement goes to: + + Jesse Vincent, David Golden, Andreas Koenig, Jos Boumans, Chris + Williams, Adam Kennedy, Audrey Tang, J. Shirley, Chris Prather, Jesse + Luehrs, Marcus Ramberg, Shawn M Moore, chocolateboy, Chirs Nehren, + Jonathan Rockway, Leon Brocard, Simon Elliott, Ricardo Signes, AEvar + Arnfjord Bjarmason, Eric Wilhelm, Florian Ragwitz and xaicron. + + =head1 COMMUNITY + + =over 4 + + =item L - source code repository, issue tracker + + =item L - discussions about Perl toolchain. I'm there. + + =back + + =head1 NO WARRANTY + + This software is provided "as-is," without any express or implied + warranty. In no event shall the author be held liable for any damages + arising from the use of the software. + + =head1 SEE ALSO + + L L L + + =cut + + 1; +APP_CPANMINUS + +$fatpacked{"App/cpanminus/script.pm"} = <<'APP_CPANMINUS_SCRIPT'; + package App::cpanminus::script; + use strict; + use Config; + use Cwd (); + use File::Basename (); + use File::Find (); + use File::Path (); + use File::Spec (); + use File::Copy (); + use Getopt::Long (); + use Parse::CPAN::Meta; + use Symbol (); + + use constant WIN32 => $^O eq 'MSWin32'; + use constant SUNOS => $^O eq 'solaris'; + + our $VERSION = "1.5007"; + + my $quote = WIN32 ? q/"/ : q/'/; + + sub new { + my $class = shift; + + bless { + home => "$ENV{HOME}/.cpanm", + cmd => 'install', + seen => {}, + notest => undef, + installdeps => undef, + force => undef, + sudo => undef, + make => undef, + verbose => undef, + quiet => undef, + interactive => undef, + log => undef, + mirrors => [], + mirror_only => undef, + mirror_index => undef, + perl => $^X, + argv => [], + local_lib => undef, + self_contained => undef, + prompt_timeout => 0, + prompt => undef, + configure_timeout => 60, + try_lwp => 1, + try_wget => 1, + try_curl => 1, + uninstall_shadows => ($] < 5.012), + skip_installed => 1, + skip_satisfied => 0, + auto_cleanup => 7, # days + pod2man => 1, + installed_dists => 0, + showdeps => 0, + scandeps => 0, + scandeps_tree => [], + format => 'tree', + save_dists => undef, + skip_configure => 0, + @_, + }, $class; + } + + sub env { + my($self, $key) = @_; + $ENV{"PERL_CPANM_" . $key}; + } + + sub parse_options { + my $self = shift; + + local @ARGV = @{$self->{argv}}; + push @ARGV, split /\s+/, $self->env('OPT'); + push @ARGV, @_; + + Getopt::Long::Configure("bundling"); + Getopt::Long::GetOptions( + 'f|force' => sub { $self->{skip_installed} = 0; $self->{force} = 1 }, + 'n|notest!' => \$self->{notest}, + 'S|sudo!' => \$self->{sudo}, + 'v|verbose' => sub { $self->{verbose} = $self->{interactive} = 1 }, + 'q|quiet!' => \$self->{quiet}, + 'h|help' => sub { $self->{action} = 'show_help' }, + 'V|version' => sub { $self->{action} = 'show_version' }, + 'perl=s' => \$self->{perl}, + 'l|local-lib=s' => sub { $self->{local_lib} = $self->maybe_abs($_[1]) }, + 'L|local-lib-contained=s' => sub { + $self->{local_lib} = $self->maybe_abs($_[1]); + $self->{self_contained} = 1; + $self->{pod2man} = undef; + }, + 'mirror=s@' => $self->{mirrors}, + 'mirror-only!' => \$self->{mirror_only}, + 'mirror-index=s' => sub { $self->{mirror_index} = $_[1]; $self->{mirror_only} = 1 }, + 'cascade-search!' => \$self->{cascade_search}, + 'prompt!' => \$self->{prompt}, + 'installdeps' => \$self->{installdeps}, + 'skip-installed!' => \$self->{skip_installed}, + 'skip-satisfied!' => \$self->{skip_satisfied}, + 'reinstall' => sub { $self->{skip_installed} = 0 }, + 'interactive!' => \$self->{interactive}, + 'i|install' => sub { $self->{cmd} = 'install' }, + 'info' => sub { $self->{cmd} = 'info' }, + 'look' => sub { $self->{cmd} = 'look'; $self->{skip_installed} = 0 }, + 'self-upgrade' => sub { $self->{cmd} = 'install'; $self->{skip_installed} = 1; push @ARGV, 'App::cpanminus' }, + 'uninst-shadows!' => \$self->{uninstall_shadows}, + 'lwp!' => \$self->{try_lwp}, + 'wget!' => \$self->{try_wget}, + 'curl!' => \$self->{try_curl}, + 'auto-cleanup=s' => \$self->{auto_cleanup}, + 'man-pages!' => \$self->{pod2man}, + 'scandeps' => \$self->{scandeps}, + 'showdeps' => sub { $self->{showdeps} = 1; $self->{skip_installed} = 0 }, + 'format=s' => \$self->{format}, + 'save-dists=s' => sub { + $self->{save_dists} = $self->maybe_abs($_[1]); + }, + 'skip-configure!' => \$self->{skip_configure}, + 'metacpan' => \$self->{metacpan}, + ); + + if (!@ARGV && $0 ne '-' && !-t STDIN){ # e.g. # cpanm < author/requires.cpanm + push @ARGV, $self->load_argv_from_fh(\*STDIN); + $self->{load_from_stdin} = 1; + } + + $self->{argv} = \@ARGV; + } + + sub check_libs { + my $self = shift; + return if $self->{_checked}++; + + $self->bootstrap_local_lib; + if (@{$self->{bootstrap_deps} || []}) { + local $self->{notest} = 1; # test failure in bootstrap should be tolerated + local $self->{scandeps} = 0; + $self->install_deps(Cwd::cwd, 0, @{$self->{bootstrap_deps}}); + } + } + + sub doit { + my $self = shift; + + $self->setup_home; + $self->init_tools; + + if (my $action = $self->{action}) { + $self->$action() and return 1; + } + + $self->show_help(1) + unless @{$self->{argv}} or $self->{load_from_stdin}; + + $self->configure_mirrors; + + my $cwd = Cwd::cwd; + + my @fail; + for my $module (@{$self->{argv}}) { + if ($module =~ s/\.pm$//i) { + my ($volume, $dirs, $file) = File::Spec->splitpath($module); + $module = join '::', grep { $_ } File::Spec->splitdir($dirs), $file; + } + + ($module, my $version) = split /\~/, $module, 2; + if ($self->{skip_satisfied} or defined $version) { + $self->check_libs; + my($ok, $local) = $self->check_module($module, $version || 0); + if ($ok) { + $self->diag("You have $module (" . ($local || 'undef') . ")\n", 1); + next; + } + } + + $self->chdir($cwd); + $self->install_module($module, 0, $version) + or push @fail, $module; + } + + if ($self->{base} && $self->{auto_cleanup}) { + $self->cleanup_workdirs; + } + + if ($self->{installed_dists}) { + my $dists = $self->{installed_dists} > 1 ? "distributions" : "distribution"; + $self->diag("$self->{installed_dists} $dists installed\n", 1); + } + + if ($self->{scandeps}) { + $self->dump_scandeps(); + } + + return !@fail; + } + + sub setup_home { + my $self = shift; + + $self->{home} = $self->env('HOME') if $self->env('HOME'); + + unless (_writable($self->{home})) { + die "Can't write to cpanm home '$self->{home}': You should fix it with chown/chmod first.\n"; + } + + $self->{base} = "$self->{home}/work/" . time . ".$$"; + File::Path::mkpath([ $self->{base} ], 0, 0777); + + my $link = "$self->{home}/latest-build"; + eval { unlink $link; symlink $self->{base}, $link }; + + $self->{log} = File::Spec->catfile($self->{home}, "build.log"); # because we use shell redirect + + { + my $log = $self->{log}; my $base = $self->{base}; + $self->{at_exit} = sub { + my $self = shift; + File::Copy::copy($self->{log}, "$self->{base}/build.log"); + }; + } + + { open my $out, ">$self->{log}" or die "$self->{log}: $!" } + + $self->chat("cpanm (App::cpanminus) $VERSION on perl $] built for $Config{archname}\n" . + "Work directory is $self->{base}\n"); + } + + sub fetch_meta_sco { + my($self, $dist) = @_; + return if $self->{mirror_only}; + + my $meta_yml = $self->get("http://search.cpan.org/meta/$dist->{distvname}/META.yml"); + return $self->parse_meta_string($meta_yml); + } + + sub package_index_for { + my ($self, $mirror) = @_; + return $self->source_for($mirror) . "/02packages.details.txt"; + } + + sub generate_mirror_index { + my ($self, $mirror) = @_; + my $file = $self->package_index_for($mirror); + my $gz_file = $file . '.gz'; + my $index_mtime = (stat $gz_file)[9]; + + unless (-e $file && (stat $file)[9] >= $index_mtime) { + $self->chat("Uncompressing index file...\n"); + if (eval {require Compress::Zlib}) { + my $gz = Compress::Zlib::gzopen($gz_file, "rb") + or do { $self->diag_fail("$Compress::Zlib::gzerrno opening compressed index"); return}; + open my $fh, '>', $file + or do { $self->diag_fail("$! opening uncompressed index for write"); return }; + my $buffer; + while (my $status = $gz->gzread($buffer)) { + if ($status < 0) { + $self->diag_fail($gz->gzerror . " reading compressed index"); + return; + } + print $fh $buffer; + } + } else { + if (system("gunzip -c $gz_file > $file")) { + $self->diag_fail("Cannot uncompress -- please install gunzip or Compress::Zlib"); + return; + } + } + utime $index_mtime, $index_mtime, $file; + } + return 1; + } + + sub search_mirror_index { + my ($self, $mirror, $module, $version) = @_; + $self->search_mirror_index_file($self->package_index_for($mirror), $module, $version); + } + + sub search_mirror_index_file { + my($self, $file, $module, $version) = @_; + + open my $fh, '<', $file or return; + my $found; + while (<$fh>) { + if (m!^\Q$module\E\s+([\w\.]+)\s+(.*)!m) { + $found = $self->cpan_module($module, $2, $1); + last; + } + } + + return $found unless $self->{cascade_search}; + + if ($found) { + if (!$version or + version->new($found->{version} || 0) >= version->new($version)) { + return $found; + } else { + $self->chat("Found $module version $found->{version} < $version.\n"); + } + } + + return; + } + + sub search_module { + my($self, $module, $version) = @_; + + unless ($self->{mirror_only}) { + if ($self->{metacpan}) { + require JSON::PP; + $self->chat("Searching $module on metacpan ...\n"); + my $module_uri = "http://api.metacpan.org/module/$module"; + my $module_json = $self->get($module_uri); + my $module_meta = eval { JSON::PP::decode_json($module_json) }; + if ($module_meta && $module_meta->{distribution}) { + my $dist_uri = "http://api.metacpan.org/release/$module_meta->{distribution}"; + my $dist_json = $self->get($dist_uri); + my $dist_meta = eval { JSON::PP::decode_json($dist_json) }; + if ($dist_meta && $dist_meta->{download_url}) { + (my $distfile = $dist_meta->{download_url}) =~ s!.+/authors/id/!!; + local $self->{mirrors} = $self->{mirrors}; + if ($dist_meta->{stat}->{mtime} > time()-24*60*60) { + $self->{mirrors} = ['http://cpan.metacpan.org']; + } + return $self->cpan_module($module, $distfile, $dist_meta->{version}); + } + } + $self->diag_fail("Finding $module on metacpan failed."); + } + + $self->chat("Searching $module on cpanmetadb ...\n"); + my $uri = "http://cpanmetadb.plackperl.org/v1.0/package/$module"; + my $yaml = $self->get($uri); + my $meta = $self->parse_meta_string($yaml); + if ($meta && $meta->{distfile}) { + return $self->cpan_module($module, $meta->{distfile}, $meta->{version}); + } + + $self->diag_fail("Finding $module on cpanmetadb failed."); + + $self->chat("Searching $module on search.cpan.org ...\n"); + my $uri = "http://search.cpan.org/perldoc?$module"; + my $html = $self->get($uri); + $html =~ m!! + and return $self->cpan_module($module, $1); + + $self->diag_fail("Finding $module on search.cpan.org failed."); + } + + if ($self->{mirror_index}) { + $self->chat("Searching $module on mirror index $self->{mirror_index} ...\n"); + my $pkg = $self->search_mirror_index_file($self->{mirror_index}, $module, $version); + return $pkg if $pkg; + } + + MIRROR: for my $mirror (@{ $self->{mirrors} }) { + $self->chat("Searching $module on mirror $mirror ...\n"); + my $name = '02packages.details.txt.gz'; + my $uri = "$mirror/modules/$name"; + my $gz_file = $self->package_index_for($mirror) . '.gz'; + + unless ($self->{pkgs}{$uri}) { + $self->chat("Downloading index file $uri ...\n"); + $self->mirror($uri, $gz_file); + $self->generate_mirror_index($mirror) or next MIRROR; + $self->{pkgs}{$uri} = "!!retrieved!!"; + } + + my $pkg = $self->search_mirror_index($mirror, $module, $version); + return $pkg if $pkg; + + $self->diag_fail("Finding $module ($version) on mirror $mirror failed."); + } + + return; + } + + sub source_for { + my($self, $mirror) = @_; + $mirror =~ s/[^\w\.\-]+/%/g; + + my $dir = "$self->{home}/sources/$mirror"; + File::Path::mkpath([ $dir ], 0, 0777); + + return $dir; + } + + sub load_argv_from_fh { + my($self, $fh) = @_; + + my @argv; + while(defined(my $line = <$fh>)){ + chomp $line; + $line =~ s/#.+$//; # comment + $line =~ s/^\s+//; # trim spaces + $line =~ s/\s+$//; # trim spaces + + push @argv, split ' ', $line if $line; + } + return @argv; + } + + sub show_version { + print "cpanm (App::cpanminus) version $VERSION\n"; + return 1; + } + + sub show_help { + my $self = shift; + + if ($_[0]) { + die <splitdir($dir); + while (@dir) { + $dir = File::Spec->catdir(@dir); + if (-e $dir) { + return -w _; + } + pop @dir; + } + + return; + } + + sub maybe_abs { + my($self, $lib) = @_; + return $lib if $lib eq '_'; # special case: gh-113 + $lib =~ /^[~\/]/ ? $lib : File::Spec->canonpath(Cwd::cwd . "/$lib"); + } + + sub bootstrap_local_lib { + my $self = shift; + + # If -l is specified, use that. + if ($self->{local_lib}) { + return $self->setup_local_lib($self->{local_lib}); + } + + # root, locally-installed perl or --sudo: don't care about install_base + return if $self->{sudo} or (_writable($Config{installsitelib}) and _writable($Config{installsitebin})); + + # local::lib is configured in the shell -- yay + if ($ENV{PERL_MM_OPT} and ($ENV{MODULEBUILDRC} or $ENV{PERL_MB_OPT})) { + $self->bootstrap_local_lib_deps; + return; + } + + $self->setup_local_lib; + + $self->diag(<install_base_perl_path($base), + local::lib->install_base_arch_path($base), + @Config{qw(privlibexp archlibexp)}, + ); + } + + sub _diff { + my($self, $old, $new) = @_; + + my @diff; + my %old = map { $_ => 1 } @$old; + for my $n (@$new) { + push @diff, $n unless exists $old{$n}; + } + + @diff; + } + + sub _setup_local_lib_env { + my($self, $base) = @_; + local $SIG{__WARN__} = sub { }; # catch 'Attempting to write ...' + local::lib->setup_env_hash_for($base); + } + + sub setup_local_lib { + my($self, $base) = @_; + $base = undef if $base eq '_'; + + require local::lib; + { + local $0 = 'cpanm'; # so curl/wget | perl works + $base ||= "~/perl5"; + if ($self->{self_contained}) { + my @inc = $self->_core_only_inc($base); + $self->{search_inc} = [ @inc ]; + } else { + $self->{search_inc} = [ + local::lib->install_base_arch_path($base), + local::lib->install_base_perl_path($base), + @INC, + ]; + } + $self->_setup_local_lib_env($base); + } + + $self->bootstrap_local_lib_deps; + } + + sub bootstrap_local_lib_deps { + my $self = shift; + push @{$self->{bootstrap_deps}}, + 'ExtUtils::MakeMaker' => 6.31, + 'ExtUtils::Install' => 1.46; + } + + sub prompt_bool { + my($self, $mess, $def) = @_; + + my $val = $self->prompt($mess, $def); + return lc $val eq 'y'; + } + + sub prompt { + my($self, $mess, $def) = @_; + + my $isa_tty = -t STDIN && (-t STDOUT || !(-f STDOUT || -c STDOUT)) ; + my $dispdef = defined $def ? "[$def] " : " "; + $def = defined $def ? $def : ""; + + if (!$self->{prompt} || (!$isa_tty && eof STDIN)) { + return $def; + } + + local $|=1; + local $\; + my $ans; + eval { + local $SIG{ALRM} = sub { undef $ans; die "alarm\n" }; + print STDOUT "$mess $dispdef"; + alarm $self->{prompt_timeout} if $self->{prompt_timeout}; + $ans = ; + alarm 0; + }; + if ( defined $ans ) { + chomp $ans; + } else { # user hit ctrl-D or alarm timeout + print STDOUT "\n"; + } + + return (!defined $ans || $ans eq '') ? $def : $ans; + } + + sub diag_ok { + my($self, $msg) = @_; + chomp $msg; + $msg ||= "OK"; + if ($self->{in_progress}) { + $self->_diag("$msg\n"); + $self->{in_progress} = 0; + } + $self->log("-> $msg\n"); + } + + sub diag_fail { + my($self, $msg, $always) = @_; + chomp $msg; + if ($self->{in_progress}) { + $self->_diag("FAIL\n"); + $self->{in_progress} = 0; + } + + if ($msg) { + $self->_diag("! $msg\n", $always); + $self->log("-> FAIL $msg\n"); + } + } + + sub diag_progress { + my($self, $msg) = @_; + chomp $msg; + $self->{in_progress} = 1; + $self->_diag("$msg ... "); + $self->log("$msg\n"); + } + + sub _diag { + my($self, $msg, $always) = @_; + print STDERR $msg if $always or $self->{verbose} or !$self->{quiet}; + } + + sub diag { + my($self, $msg, $always) = @_; + $self->_diag($msg, $always); + $self->log($msg); + } + + sub chat { + my $self = shift; + print STDERR @_ if $self->{verbose}; + $self->log(@_); + } + + sub log { + my $self = shift; + open my $out, ">>$self->{log}"; + print $out @_; + } + + sub run { + my($self, $cmd) = @_; + + if (WIN32 && ref $cmd eq 'ARRAY') { + $cmd = join q{ }, map { $self->shell_quote($_) } @$cmd; + } + + if (ref $cmd eq 'ARRAY') { + my $pid = fork; + if ($pid) { + waitpid $pid, 0; + return !$?; + } else { + $self->run_exec($cmd); + } + } else { + unless ($self->{verbose}) { + $cmd .= " >> " . $self->shell_quote($self->{log}) . " 2>&1"; + } + !system $cmd; + } + } + + sub run_exec { + my($self, $cmd) = @_; + + if (ref $cmd eq 'ARRAY') { + unless ($self->{verbose}) { + open my $logfh, ">>", $self->{log}; + open STDERR, '>&', $logfh; + open STDOUT, '>&', $logfh; + close $logfh; + } + exec @$cmd; + } else { + unless ($self->{verbose}) { + $cmd .= " >> " . $self->shell_quote($self->{log}) . " 2>&1"; + } + exec $cmd; + } + } + + sub run_timeout { + my($self, $cmd, $timeout) = @_; + return $self->run($cmd) if WIN32 || $self->{verbose} || !$timeout; + + my $pid = fork; + if ($pid) { + eval { + local $SIG{ALRM} = sub { die "alarm\n" }; + alarm $timeout; + waitpid $pid, 0; + alarm 0; + }; + if ($@ && $@ eq "alarm\n") { + $self->diag_fail("Timed out (> ${timeout}s). Use --verbose to retry."); + local $SIG{TERM} = 'IGNORE'; + kill TERM => 0; + waitpid $pid, 0; + return; + } + return !$?; + } elsif ($pid == 0) { + $self->run_exec($cmd); + } else { + $self->chat("! fork failed: falling back to system()\n"); + $self->run($cmd); + } + } + + sub configure { + my($self, $cmd) = @_; + + # trick AutoInstall + local $ENV{PERL5_CPAN_IS_RUNNING} = local $ENV{PERL5_CPANPLUS_IS_RUNNING} = $$; + + # e.g. skip CPAN configuration on local::lib + local $ENV{PERL5_CPANM_IS_RUNNING} = $$; + + my $use_default = !$self->{interactive}; + local $ENV{PERL_MM_USE_DEFAULT} = $use_default; + + # skip man page generation + local $ENV{PERL_MM_OPT} = $ENV{PERL_MM_OPT}; + unless ($self->{pod2man}) { + $ENV{PERL_MM_OPT} .= " INSTALLMAN1DIR=none INSTALLMAN3DIR=none"; + } + + local $self->{verbose} = $self->{verbose} || $self->{interactive}; + $self->run_timeout($cmd, $self->{configure_timeout}); + } + + sub build { + my($self, $cmd, $distname) = @_; + + return 1 if $self->run_timeout($cmd, $self->{build_timeout}); + while (1) { + my $ans = lc $self->prompt("Building $distname failed.\nYou can s)kip, r)etry, e)xamine build log, or l)ook ?", "s"); + return if $ans eq 's'; + return $self->build($cmd, $distname) if $ans eq 'r'; + $self->show_build_log if $ans eq 'e'; + $self->look if $ans eq 'l'; + } + } + + sub test { + my($self, $cmd, $distname) = @_; + return 1 if $self->{notest}; + + # https://rt.cpan.org/Ticket/Display.html?id=48965#txn-1013385 + local $ENV{PERL_MM_USE_DEFAULT} = 1; + + return 1 if $self->run_timeout($cmd, $self->{test_timeout}); + if ($self->{force}) { + $self->diag_fail("Testing $distname failed but installing it anyway."); + return 1; + } else { + $self->diag_fail; + while (1) { + my $ans = lc $self->prompt("Testing $distname failed.\nYou can s)kip, r)etry, f)orce install, e)xamine build log, or l)ook ?", "s"); + return if $ans eq 's'; + return $self->test($cmd, $distname) if $ans eq 'r'; + return 1 if $ans eq 'f'; + $self->show_build_log if $ans eq 'e'; + $self->look if $ans eq 'l'; + } + } + } + + sub install { + my($self, $cmd, $uninst_opts) = @_; + + if ($self->{sudo}) { + unshift @$cmd, "sudo"; + } + + if ($self->{uninstall_shadows} && !$ENV{PERL_MM_OPT}) { + push @$cmd, @$uninst_opts; + } + + $self->run($cmd); + } + + sub look { + my $self = shift; + + my $shell = $ENV{SHELL}; + $shell ||= $ENV{COMSPEC} if WIN32; + if ($shell) { + my $cwd = Cwd::cwd; + $self->diag("Entering $cwd with $shell\n"); + system $shell; + } else { + $self->diag_fail("You don't seem to have a SHELL :/"); + } + } + + sub show_build_log { + my $self = shift; + + my @pagers = ( + $ENV{PAGER}, + (WIN32 ? () : ('less')), + 'more' + ); + my $pager; + while (@pagers) { + $pager = shift @pagers; + next unless $pager; + $pager = $self->which($pager); + next unless $pager; + last; + } + + if ($pager) { + # win32 'more' doesn't allow "more build.log", the < is required + system("$pager < $self->{log}"); + } + else { + $self->diag_fail("You don't seem to have a PAGER :/"); + } + } + + sub chdir { + my $self = shift; + Cwd::chdir(File::Spec->canonpath($_[0])) or die "$_[0]: $!"; + } + + sub configure_mirrors { + my $self = shift; + unless (@{$self->{mirrors}}) { + $self->{mirrors} = [ 'http://search.cpan.org/CPAN' ]; + } + for (@{$self->{mirrors}}) { + s!^/!file:///!; + s!/$!!; + } + } + + sub self_upgrade { + my $self = shift; + $self->{argv} = [ 'App::cpanminus' ]; + return; # continue + } + + sub install_module { + my($self, $module, $depth, $version) = @_; + + if ($self->{seen}{$module}++) { + $self->chat("Already tried $module. Skipping.\n"); + return 1; + } + + my $dist = $self->resolve_name($module, $version); + unless ($dist) { + $self->diag_fail("Couldn't find module or a distribution $module ($version)", 1); + return; + } + + if ($dist->{distvname} && $self->{seen}{$dist->{distvname}}++) { + $self->chat("Already tried $dist->{distvname}. Skipping.\n"); + return 1; + } + + if ($self->{cmd} eq 'info') { + print $self->format_dist($dist), "\n"; + return 1; + } + + $self->check_libs; + $self->setup_module_build_patch unless $self->{pod2man}; + + if ($dist->{module}) { + my($ok, $local) = $self->check_module($dist->{module}, $dist->{module_version} || 0); + if ($self->{skip_installed} && $ok) { + $self->diag("$dist->{module} is up to date. ($local)\n", 1); + return 1; + } + } + + if ($dist->{dist} eq 'perl'){ + $self->diag("skipping $dist->{pathname}\n"); + return 1; + } + + $self->diag("--> Working on $module\n"); + + $dist->{dir} ||= $self->fetch_module($dist); + + unless ($dist->{dir}) { + $self->diag_fail("Failed to fetch distribution $dist->{distvname}", 1); + return; + } + + $self->chat("Entering $dist->{dir}\n"); + $self->chdir($self->{base}); + $self->chdir($dist->{dir}); + + if ($self->{cmd} eq 'look') { + $self->look; + return 1; + } + + return $self->build_stuff($module, $dist, $depth); + } + + sub format_dist { + my($self, $dist) = @_; + + # TODO support --dist-format? + return "$dist->{cpanid}/$dist->{filename}"; + } + + sub fetch_module { + my($self, $dist) = @_; + + $self->chdir($self->{base}); + + for my $uri (@{$dist->{uris}}) { + $self->diag_progress("Fetching $uri"); + + # Ugh, $dist->{filename} can contain sub directory + my $filename = $dist->{filename} || $uri; + my $name = File::Basename::basename($filename); + + my $cancelled; + my $fetch = sub { + my $file; + eval { + local $SIG{INT} = sub { $cancelled = 1; die "SIGINT\n" }; + $self->mirror($uri, $name); + $file = $name if -e $name; + }; + $self->chat("$@") if $@ && $@ ne "SIGINT\n"; + return $file; + }; + + my($try, $file); + while ($try++ < 3) { + $file = $fetch->(); + last if $cancelled or $file; + $self->diag_fail("Download $uri failed. Retrying ... "); + } + + if ($cancelled) { + $self->diag_fail("Download cancelled."); + return; + } + + unless ($file) { + $self->diag_fail("Failed to download $uri"); + next; + } + + $self->diag_ok; + $dist->{local_path} = File::Spec->rel2abs($name); + + my $dir = $self->unpack($file); + next unless $dir; # unpack failed + + if (my $save = $self->{save_dists}) { + my $path = "$save/authors/id/$dist->{pathname}"; + $self->chat("Copying $name to $path\n"); + File::Path::mkpath([ File::Basename::dirname($path) ], 0, 0777); + File::Copy::copy($file, $path) or warn $!; + } + + return $dist, $dir; + } + } + + sub unpack { + my($self, $file) = @_; + $self->chat("Unpacking $file\n"); + my $dir = $file =~ /\.zip/i ? $self->unzip($file) : $self->untar($file); + unless ($dir) { + $self->diag_fail("Failed to unpack $file: no directory"); + } + return $dir; + } + + sub resolve_name { + my($self, $module, $version) = @_; + + # URL + if ($module =~ /^(ftp|https?|file):/) { + if ($module =~ m!authors/id/!) { + return $self->cpan_dist($module, $module); + } else { + return { uris => [ $module ] }; + } + } + + # Directory + if ($module =~ m!^[\./]! && -d $module) { + return { + source => 'local', + dir => Cwd::abs_path($module), + }; + } + + # File + if (-f $module) { + return { + source => 'local', + uris => [ "file://" . Cwd::abs_path($module) ], + }; + } + + # cpan URI + if ($module =~ s!^cpan:///distfile/!!) { + return $self->cpan_dist($module); + } + + # PAUSEID/foo + if ($module =~ m!([A-Z]{3,})/!) { + return $self->cpan_dist($module); + } + + # Module name + return $self->search_module($module, $version); + } + + sub cpan_module { + my($self, $module, $dist, $version) = @_; + + my $dist = $self->cpan_dist($dist); + $dist->{module} = $module; + $dist->{module_version} = $version if $version && $version ne 'undef'; + + return $dist; + } + + sub cpan_dist { + my($self, $dist, $url) = @_; + + $dist =~ s!^([A-Z]{3})!substr($1,0,1)."/".substr($1,0,2)."/".$1!e; + + require CPAN::DistnameInfo; + my $d = CPAN::DistnameInfo->new($dist); + + if ($url) { + $url = [ $url ] unless ref $url eq 'ARRAY'; + } else { + my $id = $d->cpanid; + my $fn = substr($id, 0, 1) . "/" . substr($id, 0, 2) . "/" . $id . "/" . $d->filename; + + my @mirrors = @{$self->{mirrors}}; + my @urls = map "$_/authors/id/$fn", @mirrors; + + $url = \@urls, + } + + return { + $d->properties, + source => 'cpan', + uris => $url, + }; + } + + sub setup_module_build_patch { + my $self = shift; + + open my $out, ">$self->{base}/ModuleBuildSkipMan.pm" or die $!; + print $out <new_from_module($mod, inc => $self->{search_inc}) + or return 0, undef; + + my $version = $meta->version; + + # When -L is in use, the version loaded from 'perl' library path + # might be newer than (or actually wasn't core at) the version + # that is shipped with the current perl + if ($self->{self_contained} && $self->loaded_from_perl_lib($meta)) { + require Module::CoreList; + unless (exists $Module::CoreList::version{$]+0}{$mod}) { + return 0, undef; + } + $version = $Module::CoreList::version{$]+0}{$mod}; + } + + $self->{local_versions}{$mod} = $version; + + if ($self->is_deprecated($meta)){ + return 0, $version; + } elsif (!$want_ver or $version >= version->new($want_ver)) { + return 1, ($version || 'undef'); + } else { + return 0, $version; + } + } + + sub is_deprecated { + my($self, $meta) = @_; + + my $deprecated = eval { + require Module::CoreList; + Module::CoreList::is_deprecated($meta->{module}); + }; + + return unless $deprecated; + return $self->loaded_from_perl_lib($meta); + } + + sub loaded_from_perl_lib { + my($self, $meta) = @_; + + require Config; + for my $dir (qw(archlibexp privlibexp)) { + my $confdir = $Config{$dir}; + if ($confdir eq substr($meta->filename, 0, length($confdir))) { + return 1; + } + } + + return; + } + + sub should_install { + my($self, $mod, $ver) = @_; + + $self->chat("Checking if you have $mod $ver ... "); + my($ok, $local) = $self->check_module($mod, $ver); + + if ($ok) { $self->chat("Yes ($local)\n") } + elsif ($local) { $self->chat("No ($local < $ver)\n") } + else { $self->chat("No\n") } + + return $mod unless $ok; + return; + } + + sub install_deps { + my($self, $dir, $depth, @deps) = @_; + + my(@install, %seen); + while (my($mod, $ver) = splice @deps, 0, 2) { + next if $seen{$mod} or $mod eq 'perl' or $mod eq 'Config'; + if ($self->should_install($mod, $ver)) { + push @install, [ $mod, $ver ]; + $seen{$mod} = 1; + } + } + + if (@install) { + $self->diag("==> Found dependencies: " . join(", ", map $_->[0], @install) . "\n"); + } + + my @fail; + for my $mod (@install) { + $self->install_module($mod->[0], $depth + 1, $mod->[1]) + or push @fail, $mod->[0]; + } + + $self->chdir($self->{base}); + $self->chdir($dir) if $dir; + + return @fail; + } + + sub install_deps_bailout { + my($self, $target, $dir, $depth, @deps) = @_; + + my @fail = $self->install_deps($dir, $depth, @deps); + if (@fail) { + unless ($self->prompt_bool("Installing the following dependencies failed:\n==> " . + join(", ", @fail) . "\nDo you want to continue building $target anyway?", "n")) { + $self->diag_fail("Bailing out the installation for $target. Retry with --prompt or --force.", 1); + return; + } + } + + return 1; + } + + sub build_stuff { + my($self, $stuff, $dist, $depth) = @_; + + my @config_deps; + if (!%{$dist->{meta} || {}} && -e 'META.yml') { + $self->chat("Checking configure dependencies from META.yml\n"); + $dist->{meta} = $self->parse_meta('META.yml'); + } + + if (!$dist->{meta} && $dist->{source} eq 'cpan') { + $self->chat("META.yml not found or unparsable. Fetching META.yml from search.cpan.org\n"); + $dist->{meta} = $self->fetch_meta_sco($dist); + } + + $dist->{meta} ||= {}; + + push @config_deps, %{$dist->{meta}{configure_requires} || {}}; + + my $target = $dist->{meta}{name} ? "$dist->{meta}{name}-$dist->{meta}{version}" : $dist->{dir}; + + $self->install_deps_bailout($target, $dist->{dir}, $depth, @config_deps) + or return; + + $self->diag_progress("Configuring $target"); + + my $configure_state = $self->configure_this($dist); + + $self->diag_ok($configure_state->{configured_ok} ? "OK" : "N/A"); + + my @deps = $self->find_prereqs($dist); + my $module_name = $self->find_module_name($configure_state) || $dist->{meta}{name}; + $module_name =~ s/-/::/g; + + if ($self->{showdeps}) { + my %rootdeps = (@config_deps, @deps); # merge + for my $mod (keys %rootdeps) { + my $ver = $rootdeps{$mod}; + print $mod, ($ver ? "~$ver" : ""), "\n"; + } + return 1; + } + + my $distname = $dist->{meta}{name} ? "$dist->{meta}{name}-$dist->{meta}{version}" : $stuff; + + my $walkup; + if ($self->{scandeps}) { + $walkup = $self->scandeps_append_child($dist); + } + + $self->install_deps_bailout($distname, $dist->{dir}, $depth, @deps) + or return; + + if ($self->{scandeps}) { + unless ($configure_state->{configured_ok}) { + my $diag = <{log} for details. + ! You might have to install the following modules first to get --scandeps working correctly. + DIAG + if (@config_deps) { + my @tree = @{$self->{scandeps_tree}}; + $diag .= "!\n" . join("", map "! * $_->[0]{module}\n", @tree[0..$#tree-1]) if @tree; + } + $self->diag("!\n$diag!\n", 1); + } + $walkup->(); + return 1; + } + + if ($self->{installdeps} && $depth == 0) { + if ($configure_state->{configured_ok}) { + $self->diag("<== Installed dependencies for $stuff. Finishing.\n"); + return 1; + } else { + $self->diag("! Configuring $distname failed. See $self->{log} for details.\n", 1); + return; + } + } + + my $installed; + if ($configure_state->{use_module_build} && -e 'Build' && -f _) { + my @switches = $self->{pod2man} ? () : ("-I$self->{base}", "-MModuleBuildSkipMan"); + $self->diag_progress("Building " . ($self->{notest} ? "" : "and testing ") . $distname); + $self->build([ $self->{perl}, @switches, "./Build" ], $distname) && + $self->test([ $self->{perl}, "./Build", "test" ], $distname) && + $self->install([ $self->{perl}, @switches, "./Build", "install" ], [ "--uninst", 1 ]) && + $installed++; + } elsif ($self->{make} && -e 'Makefile') { + $self->diag_progress("Building " . ($self->{notest} ? "" : "and testing ") . $distname); + $self->build([ $self->{make} ], $distname) && + $self->test([ $self->{make}, "test" ], $distname) && + $self->install([ $self->{make}, "install" ], [ "UNINST=1" ]) && + $installed++; + } else { + my $why; + my $configure_failed = $configure_state->{configured} && !$configure_state->{configured_ok}; + if ($configure_failed) { $why = "Configure failed for $distname." } + elsif ($self->{make}) { $why = "The distribution doesn't have a proper Makefile.PL/Build.PL" } + else { $why = "Can't configure the distribution. You probably need to have 'make'." } + + $self->diag_fail("$why See $self->{log} for details.", 1); + return; + } + + if ($installed) { + my $local = $self->{local_versions}{$dist->{module} || ''}; + my $version = $dist->{module_version} || $dist->{meta}{version} || $dist->{version}; + my $reinstall = $local && ($local eq $version); + + my $how = $reinstall ? "reinstalled $distname" + : $local ? "installed $distname (upgraded from $local)" + : "installed $distname" ; + my $msg = "Successfully $how"; + $self->diag_ok; + $self->diag("$msg\n", 1); + $self->{installed_dists}++; + $self->save_meta($stuff, $dist, $module_name, \@config_deps, \@deps); + return 1; + } else { + my $msg = "Building $distname failed"; + $self->diag_fail("Installing $stuff failed. See $self->{log} for details.", 1); + return; + } + } + + sub configure_this { + my($self, $dist) = @_; + + if ($self->{skip_configure}) { + my $eumm = -e 'Makefile'; + my $mb = -e 'Build' && -f _; + return { + configured => 1, + configured_ok => $eumm || $mb, + use_module_build => $mb, + }; + } + + my @mb_switches; + unless ($self->{pod2man}) { + # it has to be push, so Module::Build is loaded from the adjusted path when -L is in use + push @mb_switches, ("-I$self->{base}", "-MModuleBuildSkipMan"); + } + + my $state = {}; + + my $try_eumm = sub { + if (-e 'Makefile.PL') { + $self->chat("Running Makefile.PL\n"); + + # NOTE: according to Devel::CheckLib, most XS modules exit + # with 0 even if header files are missing, to avoid receiving + # tons of FAIL reports in such cases. So exit code can't be + # trusted if it went well. + if ($self->configure([ $self->{perl}, "Makefile.PL" ])) { + $state->{configured_ok} = -e 'Makefile'; + } + $state->{configured}++; + } + }; + + my $try_mb = sub { + if (-e 'Build.PL') { + $self->chat("Running Build.PL\n"); + if ($self->configure([ $self->{perl}, @mb_switches, "Build.PL" ])) { + $state->{configured_ok} = -e 'Build' && -f _; + } + $state->{use_module_build}++; + $state->{configured}++; + } + }; + + # Module::Build deps should use MakeMaker because that causes circular deps and fail + # Otherwise we should prefer Build.PL + my %should_use_mm = map { $_ => 1 } qw( version ExtUtils-ParseXS ExtUtils-Install ExtUtils-Manifest ); + + my @try; + if ($dist->{dist} && $should_use_mm{$dist->{dist}}) { + @try = ($try_eumm, $try_mb); + } else { + @try = ($try_mb, $try_eumm); + } + + for my $try (@try) { + $try->(); + last if $state->{configured_ok}; + } + + unless ($state->{configured_ok}) { + while (1) { + my $ans = lc $self->prompt("Configuring $dist->{dist} failed.\nYou can s)kip, r)etry, e)xamine build log, or l)ook ?", "s"); + last if $ans eq 's'; + return $self->configure_this($dist) if $ans eq 'r'; + $self->show_build_log if $ans eq 'e'; + $self->look if $ans eq 'l'; + } + } + + return $state; + } + + sub find_module_name { + my($self, $state) = @_; + + return unless $state->{configured_ok}; + + if ($state->{use_module_build} && + -e "_build/build_params") { + my $params = do { open my $in, "_build/build_params"; $self->safe_eval(join "", <$in>) }; + return eval { $params->[2]{module_name} } || undef; + } elsif (-e "Makefile") { + open my $mf, "Makefile"; + while (<$mf>) { + if (/^\#\s+NAME\s+=>\s+(.*)/) { + return $self->safe_eval($1); + } + } + } + + return; + } + + sub save_meta { + my($self, $module, $dist, $module_name, $config_deps, $build_deps) = @_; + + return unless $dist->{distvname} && $dist->{source} eq 'cpan'; + + my $base = ($ENV{PERL_MM_OPT} || '') =~ /INSTALL_BASE=/ + ? ($self->install_base($ENV{PERL_MM_OPT}) . "/lib/perl5") : $Config{sitelibexp}; + + my $provides = $self->_merge_hashref( + map Module::Metadata->package_versions_from_directory($_), + qw( blib/lib blib/arch ) # FCGI.pm :( + ); + + mkdir "blib/meta", 0777 or die $!; + + my $local = { + name => $module_name, + module => $module, + version => $provides->{$module}{version} || $dist->{version}, + dist => $dist->{distvname}, + pathname => $dist->{pathname}, + provides => $provides, + }; + + require JSON::PP; + open my $fh, ">", "blib/meta/install.json" or die $!; + print $fh JSON::PP::encode_json($local); + + # Existence of MYMETA.* Depends on EUMM/M::B versions and CPAN::Meta + if (-e "MYMETA.json") { + File::Copy::copy("MYMETA.json", "blib/meta/MYMETA.json"); + } + + my @cmd = ( + ($self->{sudo} ? 'sudo' : ()), + $^X, + '-MExtUtils::Install=install', + '-e', + qq[install({ 'blib/meta' => '$base/$Config{archname}/.meta/$dist->{distvname}' })], + ); + $self->run(\@cmd); + } + + sub _merge_hashref { + my($self, @hashrefs) = @_; + + my %hash; + for my $h (@hashrefs) { + %hash = (%hash, %$h); + } + + return \%hash; + } + + sub install_base { + my($self, $mm_opt) = @_; + $mm_opt =~ /INSTALL_BASE=(\S+)/ and return $1; + die "Your PERL_MM_OPT doesn't contain INSTALL_BASE"; + } + + sub safe_eval { + my($self, $code) = @_; + eval $code; + } + + sub find_prereqs { + my($self, $dist) = @_; + + my @deps = $self->extract_meta_prereqs($dist); + + if ($dist->{module} =~ /^Bundle::/i) { + push @deps, $self->bundle_deps($dist); + } + + return @deps; + } + + sub extract_meta_prereqs { + my($self, $dist) = @_; + + my $meta = $dist->{meta}; + + my @deps; + if (-e "MYMETA.json") { + require JSON::PP; + $self->chat("Checking dependencies from MYMETA.json ...\n"); + my $json = do { open my $in, " }; + my $mymeta = JSON::PP::decode_json($json); + if ($mymeta) { + $meta->{$_} = $mymeta->{$_} for qw(name version); + return $self->extract_requires($mymeta); + } + } + + if (-e 'MYMETA.yml') { + $self->chat("Checking dependencies from MYMETA.yml ...\n"); + my $mymeta = $self->parse_meta('MYMETA.yml'); + if ($mymeta) { + $meta->{$_} = $mymeta->{$_} for qw(name version); + return $self->extract_requires($mymeta); + } + } + + if (-e '_build/prereqs') { + $self->chat("Checking dependencies from _build/prereqs ...\n"); + my $mymeta = do { open my $in, "_build/prereqs"; $self->safe_eval(join "", <$in>) }; + @deps = $self->extract_requires($mymeta); + } elsif (-e 'Makefile') { + $self->chat("Finding PREREQ from Makefile ...\n"); + open my $mf, "Makefile"; + while (<$mf>) { + if (/^\#\s+PREREQ_PM => {\s*(.*?)\s*}/) { + my @all; + my @pairs = split ', ', $1; + for (@pairs) { + my ($pkg, $v) = split '=>', $_; + push @all, [ $pkg, $v ]; + } + my $list = join ", ", map { "'$_->[0]' => $_->[1]" } @all; + my $prereq = $self->safe_eval("no strict; +{ $list }"); + push @deps, %$prereq if $prereq; + last; + } + } + } + + return @deps; + } + + sub bundle_deps { + my($self, $dist) = @_; + + my @files; + File::Find::find({ + wanted => sub { push @files, File::Spec->rel2abs($_) if /\.pm/i }, + no_chdir => 1, + }, '.'); + + my @deps; + + for my $file (@files) { + open my $pod, "<", $file or next; + my $in_contents; + while (<$pod>) { + if (/^=head\d\s+CONTENTS/) { + $in_contents = 1; + } elsif (/^=/) { + $in_contents = 0; + } elsif ($in_contents) { + /^(\S+)\s*(\S+)?/ + and push @deps, $1, $self->maybe_version($2); + } + } + } + + return @deps; + } + + sub maybe_version { + my($self, $string) = @_; + return $string && $string =~ /^\.?\d/ ? $string : undef; + } + + sub extract_requires { + my($self, $meta) = @_; + + if ($meta->{'meta-spec'} && $meta->{'meta-spec'}{version} == 2) { + my @phase = $self->{notest} ? qw( build runtime ) : qw( build test runtime ); + my @deps = map { + my $p = $meta->{prereqs}{$_} || {}; + %{$p->{requires} || {}}; + } @phase; + return @deps; + } + + my @deps; + push @deps, %{$meta->{build_requires}} if $meta->{build_requires}; + push @deps, %{$meta->{requires}} if $meta->{requires}; + + return @deps; + } + + sub cleanup_workdirs { + my $self = shift; + + my $expire = time - 24 * 60 * 60 * $self->{auto_cleanup}; + my @targets; + + opendir my $dh, "$self->{home}/work"; + while (my $e = readdir $dh) { + next if $e !~ /^(\d+)\.\d+$/; # {UNIX time}.{PID} + my $time = $1; + if ($time < $expire) { + push @targets, "$self->{home}/work/$e"; + } + } + + if (@targets) { + $self->chat("Expiring ", scalar(@targets), " work directories.\n"); + File::Path::rmtree(\@targets, 0, 0); # safe = 0, since blib usually doesn't have write bits + } + } + + sub scandeps_append_child { + my($self, $dist) = @_; + + my $new_node = [ $dist, [] ]; + + my $curr_node = $self->{scandeps_current} || [ undef, $self->{scandeps_tree} ]; + push @{$curr_node->[1]}, $new_node; + + $self->{scandeps_current} = $new_node; + + return sub { $self->{scandeps_current} = $curr_node }; + } + + sub dump_scandeps { + my $self = shift; + + if ($self->{format} eq 'tree') { + $self->walk_down(sub { + my($dist, $depth) = @_; + if ($depth == 0) { + print "$dist->{distvname}\n"; + } else { + print " " x ($depth - 1); + print "\\_ $dist->{distvname}\n"; + } + }, 1); + } elsif ($self->{format} =~ /^dists?$/) { + $self->walk_down(sub { + my($dist, $depth) = @_; + print $self->format_dist($dist), "\n"; + }, 0); + } elsif ($self->{format} eq 'json') { + require JSON::PP; + print JSON::PP::encode_json($self->{scandeps_tree}); + } elsif ($self->{format} eq 'yaml') { + require YAML; + print YAML::Dump($self->{scandeps_tree}); + } else { + $self->diag("Unknown format: $self->{format}\n"); + } + } + + sub walk_down { + my($self, $cb, $pre) = @_; + $self->_do_walk_down($self->{scandeps_tree}, $cb, 0, $pre); + } + + sub _do_walk_down { + my($self, $children, $cb, $depth, $pre) = @_; + + # DFS - $pre determines when we call the callback + for my $node (@$children) { + $cb->($node->[0], $depth) if $pre; + $self->_do_walk_down($node->[1], $cb, $depth + 1, $pre); + $cb->($node->[0], $depth) unless $pre; + } + } + + sub DESTROY { + my $self = shift; + $self->{at_exit}->($self) if $self->{at_exit}; + } + + # Utils + + sub shell_quote { + my($self, $stuff) = @_; + $stuff =~ /^${quote}.+${quote}$/ ? $stuff : ($quote . $stuff . $quote); + } + + sub which { + my($self, $name) = @_; + my $exe_ext = $Config{_exe}; + for my $dir (File::Spec->path) { + my $fullpath = File::Spec->catfile($dir, $name); + if (-x $fullpath || -x ($fullpath .= $exe_ext)) { + if ($fullpath =~ /\s/ && $fullpath !~ /^$quote/) { + $fullpath = $self->shell_quote($fullpath); + } + return $fullpath; + } + } + return; + } + + sub get { $_[0]->{_backends}{get}->(@_) }; + sub mirror { $_[0]->{_backends}{mirror}->(@_) }; + sub untar { $_[0]->{_backends}{untar}->(@_) }; + sub unzip { $_[0]->{_backends}{unzip}->(@_) }; + + sub file_get { + my($self, $uri) = @_; + open my $fh, "<$uri" or return; + join '', <$fh>; + } + + sub file_mirror { + my($self, $uri, $path) = @_; + File::Copy::copy($uri, $path); + } + + sub init_tools { + my $self = shift; + + return if $self->{initialized}++; + + if ($self->{make} = $self->which($Config{make})) { + $self->chat("You have make $self->{make}\n"); + } + + # use --no-lwp if they have a broken LWP, to upgrade LWP + if ($self->{try_lwp} && eval { require LWP::UserAgent; LWP::UserAgent->VERSION(5.802) }) { + $self->chat("You have LWP $LWP::VERSION\n"); + my $ua = sub { + LWP::UserAgent->new( + parse_head => 0, + env_proxy => 1, + agent => "cpanminus/$VERSION", + timeout => 30, + @_, + ); + }; + $self->{_backends}{get} = sub { + my $self = shift; + my $res = $ua->()->request(HTTP::Request->new(GET => $_[0])); + return unless $res->is_success; + return $res->decoded_content; + }; + $self->{_backends}{mirror} = sub { + my $self = shift; + my $res = $ua->()->mirror(@_); + $res->code; + }; + } elsif ($self->{try_wget} and my $wget = $self->which('wget')) { + $self->chat("You have $wget\n"); + $self->{_backends}{get} = sub { + my($self, $uri) = @_; + return $self->file_get($uri) if $uri =~ s!^file:/+!/!; + $self->safeexec( my $fh, $wget, $uri, ( $self->{verbose} ? () : '-q' ), '-O', '-' ) or die "wget $uri: $!"; + local $/; + <$fh>; + }; + $self->{_backends}{mirror} = sub { + my($self, $uri, $path) = @_; + return $self->file_mirror($uri, $path) if $uri =~ s!^file:/+!/!; + $self->safeexec( my $fh, $wget, '--retry-connrefused', $uri, ( $self->{verbose} ? () : '-q' ), '-O', $path ) or die "wget $uri: $!"; + local $/; + <$fh>; + }; + } elsif ($self->{try_curl} and my $curl = $self->which('curl')) { + $self->chat("You have $curl\n"); + $self->{_backends}{get} = sub { + my($self, $uri) = @_; + return $self->file_get($uri) if $uri =~ s!^file:/+!/!; + $self->safeexec( my $fh, $curl, '-L', ( $self->{verbose} ? () : '-s' ), $uri ) or die "curl $uri: $!"; + local $/; + <$fh>; + }; + $self->{_backends}{mirror} = sub { + my($self, $uri, $path) = @_; + return $self->file_mirror($uri, $path) if $uri =~ s!^file:/+!/!; + $self->safeexec( my $fh, $curl, '-L', $uri, ( $self->{verbose} ? () : '-s' ), '-#', '-o', $path ) or die "curl $uri: $!"; + local $/; + <$fh>; + }; + } else { + require HTTP::Tiny; + $self->chat("Falling back to HTTP::Tiny $HTTP::Tiny::VERSION\n"); + + $self->{_backends}{get} = sub { + my $self = shift; + my $res = HTTP::Tiny->new->get($_[0]); + return unless $res->{success}; + return $res->{content}; + }; + $self->{_backends}{mirror} = sub { + my $self = shift; + my $res = HTTP::Tiny->new->mirror(@_); + return $res->{status}; + }; + } + + my $tar = $self->which('tar'); + my $tar_ver; + my $maybe_bad_tar = sub { WIN32 || SUNOS || (($tar_ver = `$tar --version 2>/dev/null`) =~ /GNU.*1\.13/i) }; + + if ($tar && !$maybe_bad_tar->()) { + chomp $tar_ver; + $self->chat("You have $tar: $tar_ver\n"); + $self->{_backends}{untar} = sub { + my($self, $tarfile) = @_; + + my $xf = "xf" . ($self->{verbose} ? 'v' : ''); + my $ar = $tarfile =~ /bz2$/ ? 'j' : 'z'; + + my($root, @others) = `$tar tf$ar $tarfile` + or return undef; + + chomp $root; + $root =~ s!^\./!!; + $root =~ s{^(.+?)/.*$}{$1}; + + system "$tar $xf$ar $tarfile"; + return $root if -d $root; + + $self->diag_fail("Bad archive: $tarfile"); + return undef; + } + } elsif ( $tar + and my $gzip = $self->which('gzip') + and my $bzip2 = $self->which('bzip2')) { + $self->chat("You have $tar, $gzip and $bzip2\n"); + $self->{_backends}{untar} = sub { + my($self, $tarfile) = @_; + + my $x = "x" . ($self->{verbose} ? 'v' : '') . "f -"; + my $ar = $tarfile =~ /bz2$/ ? $bzip2 : $gzip; + + my($root, @others) = `$ar -dc $tarfile | $tar tf -` + or return undef; + + chomp $root; + $root =~ s{^(.+?)/.*$}{$1}; + + system "$ar -dc $tarfile | $tar $x"; + return $root if -d $root; + + $self->diag_fail("Bad archive: $tarfile"); + return undef; + } + } elsif (eval { require Archive::Tar }) { # uses too much memory! + $self->chat("Falling back to Archive::Tar $Archive::Tar::VERSION\n"); + $self->{_backends}{untar} = sub { + my $self = shift; + my $t = Archive::Tar->new($_[0]); + my $root = ($t->list_files)[0]; + $root =~ s{^(.+?)/.*$}{$1}; + $t->extract; + return -d $root ? $root : undef; + }; + } else { + $self->{_backends}{untar} = sub { + die "Failed to extract $_[1] - You need to have tar or Archive::Tar installed.\n"; + }; + } + + if (my $unzip = $self->which('unzip')) { + $self->chat("You have $unzip\n"); + $self->{_backends}{unzip} = sub { + my($self, $zipfile) = @_; + + my $opt = $self->{verbose} ? '' : '-q'; + my(undef, $root, @others) = `$unzip -t $zipfile` + or return undef; + + chomp $root; + $root =~ s{^\s+testing:\s+(.+?)/\s+OK$}{$1}; + + system "$unzip $opt $zipfile"; + return $root if -d $root; + + $self->diag_fail("Bad archive: [$root] $zipfile"); + return undef; + } + } else { + $self->{_backends}{unzip} = sub { + eval { require Archive::Zip } + or die "Failed to extract $_[1] - You need to have unzip or Archive::Zip installed.\n"; + my($self, $file) = @_; + my $zip = Archive::Zip->new(); + my $status; + $status = $zip->read($file); + $self->diag_fail("Read of file[$file] failed") + if $status != Archive::Zip::AZ_OK(); + my @members = $zip->members(); + my $root; + for my $member ( @members ) { + my $af = $member->fileName(); + next if ($af =~ m!^(/|\.\./)!); + $root = $af unless $root; + $status = $member->extractToFileNamed( $af ); + $self->diag_fail("Extracting of file[$af] from zipfile[$file failed") + if $status != Archive::Zip::AZ_OK(); + } + return -d $root ? $root : undef; + }; + } + } + + sub safeexec { + my $self = shift; + my $rdr = $_[0] ||= Symbol::gensym(); + + if (WIN32) { + my $cmd = join q{ }, map { $self->shell_quote($_) } @_[ 1 .. $#_ ]; + return open( $rdr, "$cmd |" ); + } + + if ( my $pid = open( $rdr, '-|' ) ) { + return $pid; + } + elsif ( defined $pid ) { + exec( @_[ 1 .. $#_ ] ); + exit 1; + } + else { + return; + } + } + + sub parse_meta { + my($self, $file) = @_; + return eval { (Parse::CPAN::Meta::LoadFile($file))[0] } || undef; + } + + sub parse_meta_string { + my($self, $yaml) = @_; + return eval { (Parse::CPAN::Meta::Load($yaml))[0] } || undef; + } + + 1; +APP_CPANMINUS_SCRIPT + +$fatpacked{"CPAN/DistnameInfo.pm"} = <<'CPAN_DISTNAMEINFO'; + + package CPAN::DistnameInfo; + + $VERSION = "0.11"; + use strict; + + sub distname_info { + my $file = shift or return; + + my ($dist, $version) = $file =~ /^ + ((?:[-+.]*(?:[A-Za-z0-9]+|(?<=\D)_|_(?=\D))* + (?: + [A-Za-z](?=[^A-Za-z]|$) + | + \d(?=-) + )(? 6 and $1 & 1) or ($2 and $2 >= 50)) or $3; + } + elsif ($version =~ /\d\D\d+_\d/ or $version =~ /-TRIAL/) { + $dev = 1; + } + } + else { + $version = undef; + } + + ($dist, $version, $dev); + } + + sub new { + my $class = shift; + my $distfile = shift; + + $distfile =~ s,//+,/,g; + + my %info = ( pathname => $distfile ); + + ($info{filename} = $distfile) =~ s,^(((.*?/)?authors/)?id/)?([A-Z])/(\4[A-Z])/(\5[-A-Z0-9]*)/,, + and $info{cpanid} = $6; + + if ($distfile =~ m,([^/]+)\.(tar\.(?:g?z|bz2)|zip|tgz)$,i) { # support more ? + $info{distvname} = $1; + $info{extension} = $2; + } + + @info{qw(dist version beta)} = distname_info($info{distvname}); + $info{maturity} = delete $info{beta} ? 'developer' : 'released'; + + return bless \%info, $class; + } + + sub dist { shift->{dist} } + sub version { shift->{version} } + sub maturity { shift->{maturity} } + sub filename { shift->{filename} } + sub cpanid { shift->{cpanid} } + sub distvname { shift->{distvname} } + sub extension { shift->{extension} } + sub pathname { shift->{pathname} } + + sub properties { %{ $_[0] } } + + 1; + + __END__ + +CPAN_DISTNAMEINFO + +$fatpacked{"CPAN/Meta.pm"} = <<'CPAN_META'; + use 5.006; + use strict; + use warnings; + package CPAN::Meta; + BEGIN { + $CPAN::Meta::VERSION = '2.110930'; + } + # ABSTRACT: the distribution metadata for a CPAN dist + + + use Carp qw(carp croak); + use CPAN::Meta::Feature; + use CPAN::Meta::Prereqs; + use CPAN::Meta::Converter; + use CPAN::Meta::Validator; + use Parse::CPAN::Meta 1.4400 (); + + sub _dclone { + my $ref = shift; + my $backend = Parse::CPAN::Meta->json_backend(); + return $backend->new->decode( + $backend->new->convert_blessed->encode($ref) + ); + } + + + BEGIN { + my @STRING_READERS = qw( + abstract + description + dynamic_config + generated_by + name + release_status + version + ); + + no strict 'refs'; + for my $attr (@STRING_READERS) { + *$attr = sub { $_[0]{ $attr } }; + } + } + + + BEGIN { + my @LIST_READERS = qw( + author + keywords + license + ); + + no strict 'refs'; + for my $attr (@LIST_READERS) { + *$attr = sub { + my $value = $_[0]{ $attr }; + croak "$attr must be called in list context" + unless wantarray; + return @{ _dclone($value) } if ref $value; + return $value; + }; + } + } + + sub authors { $_[0]->author } + sub licenses { $_[0]->license } + + + BEGIN { + my @MAP_READERS = qw( + meta-spec + resources + provides + no_index + + prereqs + optional_features + ); + + no strict 'refs'; + for my $attr (@MAP_READERS) { + (my $subname = $attr) =~ s/-/_/; + *$subname = sub { + my $value = $_[0]{ $attr }; + return _dclone($value) if $value; + return {}; + }; + } + } + + + sub custom_keys { + return grep { /^x_/i } keys %{$_[0]}; + } + + sub custom { + my ($self, $attr) = @_; + my $value = $self->{$attr}; + return _dclone($value) if ref $value; + return $value; + } + + + sub _new { + my ($class, $struct, $options) = @_; + my $self; + + if ( $options->{lazy_validation} ) { + # try to convert to a valid structure; if succeeds, then return it + my $cmc = CPAN::Meta::Converter->new( $struct ); + $self = $cmc->convert( version => 2 ); # valid or dies + return bless $self, $class; + } + else { + # validate original struct + my $cmv = CPAN::Meta::Validator->new( $struct ); + unless ( $cmv->is_valid) { + die "Invalid metadata structure. Errors: " + . join(", ", $cmv->errors) . "\n"; + } + } + + # up-convert older spec versions + my $version = $struct->{'meta-spec'}{version} || '1.0'; + if ( $version == 2 ) { + $self = $struct; + } + else { + my $cmc = CPAN::Meta::Converter->new( $struct ); + $self = $cmc->convert( version => 2 ); + } + + return bless $self, $class; + } + + sub new { + my ($class, $struct, $options) = @_; + my $self = eval { $class->_new($struct, $options) }; + croak($@) if $@; + return $self; + } + + + sub create { + my ($class, $struct, $options) = @_; + my $version = __PACKAGE__->VERSION || 2; + $struct->{generated_by} ||= __PACKAGE__ . " version $version" ; + $struct->{'meta-spec'}{version} ||= int($version); + my $self = eval { $class->_new($struct, $options) }; + croak ($@) if $@; + return $self; + } + + + sub load_file { + my ($class, $file, $options) = @_; + $options->{lazy_validation} = 1 unless exists $options->{lazy_validation}; + + croak "load_file() requires a valid, readable filename" + unless -r $file; + + my $self; + eval { + my $struct = Parse::CPAN::Meta->load_file( $file ); + $self = $class->_new($struct, $options); + }; + croak($@) if $@; + return $self; + } + + + sub load_yaml_string { + my ($class, $yaml, $options) = @_; + $options->{lazy_validation} = 1 unless exists $options->{lazy_validation}; + + my $self; + eval { + my ($struct) = Parse::CPAN::Meta->load_yaml_string( $yaml ); + $self = $class->_new($struct, $options); + }; + croak($@) if $@; + return $self; + } + + + sub load_json_string { + my ($class, $json, $options) = @_; + $options->{lazy_validation} = 1 unless exists $options->{lazy_validation}; + + my $self; + eval { + my $struct = Parse::CPAN::Meta->load_json_string( $json ); + $self = $class->_new($struct, $options); + }; + croak($@) if $@; + return $self; + } + + + sub save { + my ($self, $file, $options) = @_; + + my $version = $options->{version} || '2'; + my $layer = $] ge '5.008001' ? ':utf8' : ''; + + if ( $version ge '2' ) { + carp "'$file' should end in '.json'" + unless $file =~ m{\.json$}; + } + else { + carp "'$file' should end in '.yml'" + unless $file =~ m{\.yml$}; + } + + my $data = $self->as_string( $options ); + open my $fh, ">$layer", $file + or die "Error opening '$file' for writing: $!\n"; + + print {$fh} $data; + close $fh + or die "Error closing '$file': $!\n"; + + return 1; + } + + + sub meta_spec_version { + my ($self) = @_; + return $self->meta_spec->{version}; + } + + + sub effective_prereqs { + my ($self, $features) = @_; + $features ||= []; + + my $prereq = CPAN::Meta::Prereqs->new($self->prereqs); + + return $prereq unless @$features; + + my @other = map {; $self->feature($_)->prereqs } @$features; + + return $prereq->with_merged_prereqs(\@other); + } + + + sub should_index_file { + my ($self, $filename) = @_; + + for my $no_index_file (@{ $self->no_index->{file} || [] }) { + return if $filename eq $no_index_file; + } + + for my $no_index_dir (@{ $self->no_index->{directory} }) { + $no_index_dir =~ s{$}{/} unless $no_index_dir =~ m{/\z}; + return if index($filename, $no_index_dir) == 0; + } + + return 1; + } + + + sub should_index_package { + my ($self, $package) = @_; + + for my $no_index_pkg (@{ $self->no_index->{package} || [] }) { + return if $package eq $no_index_pkg; + } + + for my $no_index_ns (@{ $self->no_index->{namespace} }) { + return if index($package, "${no_index_ns}::") == 0; + } + + return 1; + } + + + sub features { + my ($self) = @_; + + my $opt_f = $self->optional_features; + my @features = map {; CPAN::Meta::Feature->new($_ => $opt_f->{ $_ }) } + keys %$opt_f; + + return @features; + } + + + sub feature { + my ($self, $ident) = @_; + + croak "no feature named $ident" + unless my $f = $self->optional_features->{ $ident }; + + return CPAN::Meta::Feature->new($ident, $f); + } + + + sub as_struct { + my ($self, $options) = @_; + my $struct = _dclone($self); + if ( $options->{version} ) { + my $cmc = CPAN::Meta::Converter->new( $struct ); + $struct = $cmc->convert( version => $options->{version} ); + } + return $struct; + } + + + sub as_string { + my ($self, $options) = @_; + + my $version = $options->{version} || '2'; + + my $struct; + if ( $self->meta_spec_version ne $version ) { + my $cmc = CPAN::Meta::Converter->new( $self->as_struct ); + $struct = $cmc->convert( version => $version ); + } + else { + $struct = $self->as_struct; + } + + my ($data, $backend); + if ( $version ge '2' ) { + $backend = Parse::CPAN::Meta->json_backend(); + $data = $backend->new->pretty->canonical->encode($struct); + } + else { + $backend = Parse::CPAN::Meta->yaml_backend(); + $data = eval { no strict 'refs'; &{"$backend\::Dump"}($struct) }; + if ( $@ ) { + croak $backend->can('errstr') ? $backend->errstr : $@ + } + } + + return $data; + } + + # Used by JSON::PP, etc. for "convert_blessed" + sub TO_JSON { + return { %{ $_[0] } }; + } + + 1; + + + + + __END__ + + +CPAN_META + +$fatpacked{"CPAN/Meta/Converter.pm"} = <<'CPAN_META_CONVERTER'; + use 5.006; + use strict; + use warnings; + package CPAN::Meta::Converter; + BEGIN { + $CPAN::Meta::Converter::VERSION = '2.110930'; + } + # ABSTRACT: Convert CPAN distribution metadata structures + + + use CPAN::Meta::Validator; + use version 0.82 (); + use Parse::CPAN::Meta 1.4400 (); + + sub _dclone { + my $ref = shift; + my $backend = Parse::CPAN::Meta->json_backend(); + return $backend->new->decode( + $backend->new->convert_blessed->encode($ref) + ); + } + + my %known_specs = ( + '2' => 'http://search.cpan.org/perldoc?CPAN::Meta::Spec', + '1.4' => 'http://module-build.sourceforge.net/META-spec-v1.4.html', + '1.3' => 'http://module-build.sourceforge.net/META-spec-v1.3.html', + '1.2' => 'http://module-build.sourceforge.net/META-spec-v1.2.html', + '1.1' => 'http://module-build.sourceforge.net/META-spec-v1.1.html', + '1.0' => 'http://module-build.sourceforge.net/META-spec-v1.0.html' + ); + + my @spec_list = sort { $a <=> $b } keys %known_specs; + my ($LOWEST, $HIGHEST) = @spec_list[0,-1]; + + #--------------------------------------------------------------------------# + # converters + # + # called as $converter->($element, $field_name, $full_meta, $to_version) + # + # defined return value used for field + # undef return value means field is skipped + #--------------------------------------------------------------------------# + + sub _keep { $_[0] } + + sub _keep_or_one { defined($_[0]) ? $_[0] : 1 } + + sub _keep_or_zero { defined($_[0]) ? $_[0] : 0 } + + sub _keep_or_unknown { defined($_[0]) && length($_[0]) ? $_[0] : "unknown" } + + sub _generated_by { + my $gen = shift; + my $sig = __PACKAGE__ . " version " . (__PACKAGE__->VERSION || ""); + + return $sig unless defined $gen and length $gen; + return $gen if $gen =~ /(, )\Q$sig/; + return "$gen, $sig"; + } + + sub _listify { ! defined $_[0] ? undef : ref $_[0] eq 'ARRAY' ? $_[0] : [$_[0]] } + + sub _prefix_custom { + my $key = shift; + $key =~ s/^(?!x_) # Unless it already starts with x_ + (?:x-?)? # Remove leading x- or x (if present) + /x_/ix; # and prepend x_ + return $key; + } + + sub _ucfirst_custom { + my $key = shift; + $key = ucfirst $key unless $key =~ /[A-Z]/; + return $key; + } + + sub _change_meta_spec { + my ($element, undef, undef, $version) = @_; + $element->{version} = $version; + $element->{url} = $known_specs{$version}; + return $element; + } + + my @valid_licenses_1 = ( + 'perl', + 'gpl', + 'apache', + 'artistic', + 'artistic_2', + 'lgpl', + 'bsd', + 'gpl', + 'mit', + 'mozilla', + 'open_source', + 'unrestricted', + 'restrictive', + 'unknown', + ); + + my %license_map_1 = ( + ( map { $_ => $_ } @valid_licenses_1 ), + artistic2 => 'artistic_2', + ); + + sub _license_1 { + my ($element) = @_; + return 'unknown' unless defined $element; + if ( $license_map_1{lc $element} ) { + return $license_map_1{lc $element}; + } + return 'unknown'; + } + + my @valid_licenses_2 = qw( + agpl_3 + apache_1_1 + apache_2_0 + artistic_1 + artistic_2 + bsd + freebsd + gfdl_1_2 + gfdl_1_3 + gpl_1 + gpl_2 + gpl_3 + lgpl_2_1 + lgpl_3_0 + mit + mozilla_1_0 + mozilla_1_1 + openssl + perl_5 + qpl_1_0 + ssleay + sun + zlib + open_source + restricted + unrestricted + unknown + ); + + # The "old" values were defined by Module::Build, and were often vague. I have + # made the decisions below based on reading Module::Build::API and how clearly + # it specifies the version of the license. + my %license_map_2 = ( + (map { $_ => $_ } @valid_licenses_2), + apache => 'apache_2_0', # clearly stated as 2.0 + artistic => 'artistic_1', # clearly stated as 1 + artistic2 => 'artistic_2', # clearly stated as 2 + gpl => 'open_source', # we don't know which GPL; punt + lgpl => 'open_source', # we don't know which LGPL; punt + mozilla => 'open_source', # we don't know which MPL; punt + perl => 'perl_5', # clearly Perl 5 + restrictive => 'restricted', + ); + + sub _license_2 { + my ($element) = @_; + return [ 'unknown' ] unless defined $element; + $element = [ $element ] unless ref $element eq 'ARRAY'; + my @new_list; + for my $lic ( @$element ) { + next unless defined $lic; + if ( my $new = $license_map_2{lc $lic} ) { + push @new_list, $new; + } + } + return @new_list ? \@new_list : [ 'unknown' ]; + } + + my %license_downgrade_map = qw( + agpl_3 open_source + apache_1_1 apache + apache_2_0 apache + artistic_1 artistic + artistic_2 artistic_2 + bsd bsd + freebsd open_source + gfdl_1_2 open_source + gfdl_1_3 open_source + gpl_1 gpl + gpl_2 gpl + gpl_3 gpl + lgpl_2_1 lgpl + lgpl_3_0 lgpl + mit mit + mozilla_1_0 mozilla + mozilla_1_1 mozilla + openssl open_source + perl_5 perl + qpl_1_0 open_source + ssleay open_source + sun open_source + zlib open_source + open_source open_source + restricted restrictive + unrestricted unrestricted + unknown unknown + ); + + sub _downgrade_license { + my ($element) = @_; + if ( ! defined $element ) { + return "unknown"; + } + elsif( ref $element eq 'ARRAY' ) { + if ( @$element == 1 ) { + return $license_downgrade_map{$element->[0]} || "unknown"; + } + } + elsif ( ! ref $element ) { + return $license_downgrade_map{$element} || "unknown"; + } + return "unknown"; + } + + my $no_index_spec_1_2 = { + 'file' => \&_listify, + 'dir' => \&_listify, + 'package' => \&_listify, + 'namespace' => \&_listify, + }; + + my $no_index_spec_1_3 = { + 'file' => \&_listify, + 'directory' => \&_listify, + 'package' => \&_listify, + 'namespace' => \&_listify, + }; + + my $no_index_spec_2 = { + 'file' => \&_listify, + 'directory' => \&_listify, + 'package' => \&_listify, + 'namespace' => \&_listify, + ':custom' => \&_prefix_custom, + }; + + sub _no_index_1_2 { + my (undef, undef, $meta) = @_; + my $no_index = $meta->{no_index} || $meta->{private}; + return unless $no_index; + + # cleanup wrong format + if ( ! ref $no_index ) { + my $item = $no_index; + $no_index = { dir => [ $item ], file => [ $item ] }; + } + elsif ( ref $no_index eq 'ARRAY' ) { + my $list = $no_index; + $no_index = { dir => [ @$list ], file => [ @$list ] }; + } + + # common mistake: files -> file + if ( exists $no_index->{files} ) { + $no_index->{file} = delete $no_index->{file}; + } + # common mistake: modules -> module + if ( exists $no_index->{modules} ) { + $no_index->{module} = delete $no_index->{module}; + } + return _convert($no_index, $no_index_spec_1_2); + } + + sub _no_index_directory { + my ($element, $key, $meta, $version) = @_; + return unless $element; + + # cleanup wrong format + if ( ! ref $element ) { + my $item = $element; + $element = { directory => [ $item ], file => [ $item ] }; + } + elsif ( ref $element eq 'ARRAY' ) { + my $list = $element; + $element = { directory => [ @$list ], file => [ @$list ] }; + } + + if ( exists $element->{dir} ) { + $element->{directory} = delete $element->{dir}; + } + # common mistake: files -> file + if ( exists $element->{files} ) { + $element->{file} = delete $element->{file}; + } + # common mistake: modules -> module + if ( exists $element->{modules} ) { + $element->{module} = delete $element->{module}; + } + my $spec = $version == 2 ? $no_index_spec_2 : $no_index_spec_1_3; + return _convert($element, $spec); + } + + sub _is_module_name { + my $mod = shift; + return unless defined $mod && length $mod; + return $mod =~ m{^[A-Za-z][A-Za-z0-9_]*(?:::[A-Za-z0-9_]+)*$}; + } + + sub _clean_version { + my ($element, $key, $meta, $to_version) = @_; + return 0 if ! defined $element; + + $element =~ s{^\s*}{}; + $element =~ s{\s*$}{}; + $element =~ s{^\.}{0.}; + + return 0 if ! length $element; + return 0 if ( $element eq 'undef' || $element eq '' ); + + if ( my $v = eval { version->new($element) } ) { + return $v->is_qv ? $v->normal : $element; + } + else { + return 0; + } + } + + sub _version_map { + my ($element) = @_; + return undef unless defined $element; + if ( ref $element eq 'HASH' ) { + my $new_map = {}; + for my $k ( keys %$element ) { + next unless _is_module_name($k); + my $value = $element->{$k}; + if ( ! ( defined $value && length $value ) ) { + $new_map->{$k} = 0; + } + elsif ( $value eq 'undef' || $value eq '' ) { + $new_map->{$k} = 0; + } + elsif ( _is_module_name( $value ) ) { # some weird, old META have this + $new_map->{$k} = 0; + $new_map->{$value} = 0; + } + else { + $new_map->{$k} = _clean_version($value); + } + } + return $new_map; + } + elsif ( ref $element eq 'ARRAY' ) { + my $hashref = { map { $_ => 0 } @$element }; + return _version_map($hashref); # cleanup any weird stuff + } + elsif ( ref $element eq '' && length $element ) { + return { $element => 0 } + } + return; + } + + sub _prereqs_from_1 { + my (undef, undef, $meta) = @_; + my $prereqs = {}; + for my $phase ( qw/build configure/ ) { + my $key = "${phase}_requires"; + $prereqs->{$phase}{requires} = _version_map($meta->{$key}) + if $meta->{$key}; + } + for my $rel ( qw/requires recommends conflicts/ ) { + $prereqs->{runtime}{$rel} = _version_map($meta->{$rel}) + if $meta->{$rel}; + } + return $prereqs; + } + + my $prereqs_spec = { + configure => \&_prereqs_rel, + build => \&_prereqs_rel, + test => \&_prereqs_rel, + runtime => \&_prereqs_rel, + develop => \&_prereqs_rel, + ':custom' => \&_prefix_custom, + }; + + my $relation_spec = { + requires => \&_version_map, + recommends => \&_version_map, + suggests => \&_version_map, + conflicts => \&_version_map, + ':custom' => \&_prefix_custom, + }; + + sub _cleanup_prereqs { + my ($prereqs, $key, $meta, $to_version) = @_; + return unless $prereqs && ref $prereqs eq 'HASH'; + return _convert( $prereqs, $prereqs_spec, $to_version ); + } + + sub _prereqs_rel { + my ($relation, $key, $meta, $to_version) = @_; + return unless $relation && ref $relation eq 'HASH'; + return _convert( $relation, $relation_spec, $to_version ); + } + + + BEGIN { + my @old_prereqs = qw( + requires + configure_requires + recommends + conflicts + ); + + for ( @old_prereqs ) { + my $sub = "_get_$_"; + my ($phase,$type) = split qr/_/, $_; + if ( ! defined $type ) { + $type = $phase; + $phase = 'runtime'; + } + no strict 'refs'; + *{$sub} = sub { _extract_prereqs($_[2]->{prereqs},$phase,$type) }; + } + } + + sub _get_build_requires { + my ($data, $key, $meta) = @_; + + my $test_h = _extract_prereqs($_[2]->{prereqs}, qw(test requires)) || {}; + my $build_h = _extract_prereqs($_[2]->{prereqs}, qw(build requires)) || {}; + + require Version::Requirements; + my $test_req = Version::Requirements->from_string_hash($test_h); + my $build_req = Version::Requirements->from_string_hash($build_h); + + $test_req->add_requirements($build_req)->as_string_hash; + } + + sub _extract_prereqs { + my ($prereqs, $phase, $type) = @_; + return unless ref $prereqs eq 'HASH'; + return $prereqs->{$phase}{$type}; + } + + sub _downgrade_optional_features { + my (undef, undef, $meta) = @_; + return undef unless exists $meta->{optional_features}; + my $origin = $meta->{optional_features}; + my $features = {}; + for my $name ( keys %$origin ) { + $features->{$name} = { + description => $origin->{$name}{description}, + requires => _extract_prereqs($origin->{$name}{prereqs},'runtime','requires'), + configure_requires => _extract_prereqs($origin->{$name}{prereqs},'runtime','configure_requires'), + build_requires => _extract_prereqs($origin->{$name}{prereqs},'runtime','build_requires'), + recommends => _extract_prereqs($origin->{$name}{prereqs},'runtime','recommends'), + conflicts => _extract_prereqs($origin->{$name}{prereqs},'runtime','conflicts'), + }; + for my $k (keys %{$features->{$name}} ) { + delete $features->{$name}{$k} unless defined $features->{$name}{$k}; + } + } + return $features; + } + + sub _upgrade_optional_features { + my (undef, undef, $meta) = @_; + return undef unless exists $meta->{optional_features}; + my $origin = $meta->{optional_features}; + my $features = {}; + for my $name ( keys %$origin ) { + $features->{$name} = { + description => $origin->{$name}{description}, + prereqs => _prereqs_from_1(undef, undef, $origin->{$name}), + }; + delete $features->{$name}{prereqs}{configure}; + } + return $features; + } + + my $optional_features_2_spec = { + description => \&_keep, + prereqs => \&_cleanup_prereqs, + ':custom' => \&_prefix_custom, + }; + + sub _feature_2 { + my ($element, $key, $meta, $to_version) = @_; + return unless $element && ref $element eq 'HASH'; + _convert( $element, $optional_features_2_spec, $to_version ); + } + + sub _cleanup_optional_features_2 { + my ($element, $key, $meta, $to_version) = @_; + return unless $element && ref $element eq 'HASH'; + my $new_data = {}; + for my $k ( keys %$element ) { + $new_data->{$k} = _feature_2( $element->{$k}, $k, $meta, $to_version ); + } + return unless keys %$new_data; + return $new_data; + } + + sub _optional_features_1_4 { + my ($element) = @_; + return unless $element; + $element = _optional_features_as_map($element); + for my $name ( keys %$element ) { + for my $drop ( qw/requires_packages requires_os excluded_os/ ) { + delete $element->{$name}{$drop}; + } + } + return $element; + } + + sub _optional_features_as_map { + my ($element) = @_; + return unless $element; + if ( ref $element eq 'ARRAY' ) { + my %map; + for my $feature ( @$element ) { + my (@parts) = %$feature; + $map{$parts[0]} = $parts[1]; + } + $element = \%map; + } + return $element; + } + + sub _is_urlish { defined $_[0] && $_[0] =~ m{\A[-+.a-z0-9]+:.+}i } + + sub _url_or_drop { + my ($element) = @_; + return $element if _is_urlish($element); + return; + } + + sub _url_list { + my ($element) = @_; + return unless $element; + $element = _listify( $element ); + $element = [ grep { _is_urlish($_) } @$element ]; + return unless @$element; + return $element; + } + + sub _author_list { + my ($element) = @_; + return [ 'unknown' ] unless $element; + $element = _listify( $element ); + $element = [ map { defined $_ && length $_ ? $_ : 'unknown' } @$element ]; + return [ 'unknown' ] unless @$element; + return $element; + } + + my $resource2_upgrade = { + license => sub { return _is_urlish($_[0]) ? _listify( $_[0] ) : undef }, + homepage => \&_url_or_drop, + bugtracker => sub { + my ($item) = @_; + return unless $item; + if ( $item =~ m{^mailto:(.*)$} ) { return { mailto => $1 } } + elsif( _is_urlish($item) ) { return { web => $item } } + else { return undef } + }, + repository => sub { return _is_urlish($_[0]) ? { url => $_[0] } : undef }, + ':custom' => \&_prefix_custom, + }; + + sub _upgrade_resources_2 { + my (undef, undef, $meta, $version) = @_; + return undef unless exists $meta->{resources}; + return _convert($meta->{resources}, $resource2_upgrade); + } + + my $bugtracker2_spec = { + web => \&_url_or_drop, + mailto => \&_keep, + ':custom' => \&_prefix_custom, + }; + + sub _repo_type { + my ($element, $key, $meta, $to_version) = @_; + return $element if defined $element; + return unless exists $meta->{url}; + my $repo_url = $meta->{url}; + for my $type ( qw/git svn/ ) { + return $type if $repo_url =~ m{\A$type}; + } + return; + } + + my $repository2_spec = { + web => \&_url_or_drop, + url => \&_url_or_drop, + type => \&_repo_type, + ':custom' => \&_prefix_custom, + }; + + my $resources2_cleanup = { + license => \&_url_list, + homepage => \&_url_or_drop, + bugtracker => sub { ref $_[0] ? _convert( $_[0], $bugtracker2_spec ) : undef }, + repository => sub { my $data = shift; ref $data ? _convert( $data, $repository2_spec ) : undef }, + ':custom' => \&_prefix_custom, + }; + + sub _cleanup_resources_2 { + my ($resources, $key, $meta, $to_version) = @_; + return undef unless $resources && ref $resources eq 'HASH'; + return _convert($resources, $resources2_cleanup, $to_version); + } + + my $resource1_spec = { + license => \&_url_or_drop, + homepage => \&_url_or_drop, + bugtracker => \&_url_or_drop, + repository => \&_url_or_drop, + ':custom' => \&_keep, + }; + + sub _resources_1_3 { + my (undef, undef, $meta, $version) = @_; + return undef unless exists $meta->{resources}; + return _convert($meta->{resources}, $resource1_spec); + } + + *_resources_1_4 = *_resources_1_3; + + sub _resources_1_2 { + my (undef, undef, $meta) = @_; + my $resources = $meta->{resources} || {}; + if ( $meta->{license_url} && ! $resources->{license} ) { + $resources->{license} = $meta->license_url + if _is_urlish($meta->{license_url}); + } + return undef unless keys %$resources; + return _convert($resources, $resource1_spec); + } + + my $resource_downgrade_spec = { + license => sub { return ref $_[0] ? $_[0]->[0] : $_[0] }, + homepage => \&_url_or_drop, + bugtracker => sub { return $_[0]->{web} }, + repository => sub { return $_[0]->{url} || $_[0]->{web} }, + ':custom' => \&_ucfirst_custom, + }; + + sub _downgrade_resources { + my (undef, undef, $meta, $version) = @_; + return undef unless exists $meta->{resources}; + return _convert($meta->{resources}, $resource_downgrade_spec); + } + + sub _release_status { + my ($element, undef, $meta) = @_; + return $element if $element && $element =~ m{\A(?:stable|testing|unstable)\z}; + return _release_status_from_version(undef, undef, $meta); + } + + sub _release_status_from_version { + my (undef, undef, $meta) = @_; + my $version = $meta->{version} || ''; + return ( $version =~ /_/ ) ? 'testing' : 'stable'; + } + + my $provides_spec = { + file => \&_keep, + version => \&_clean_version, + }; + + my $provides_spec_2 = { + file => \&_keep, + version => \&_clean_version, + ':custom' => \&_prefix_custom, + }; + + sub _provides { + my ($element, $key, $meta, $to_version) = @_; + return unless defined $element && ref $element eq 'HASH'; + my $spec = $to_version == 2 ? $provides_spec_2 : $provides_spec; + my $new_data = {}; + for my $k ( keys %$element ) { + $new_data->{$k} = _convert($element->{$k}, $spec, $to_version); + } + return $new_data; + } + + sub _convert { + my ($data, $spec, $to_version) = @_; + + my $new_data = {}; + for my $key ( keys %$spec ) { + next if $key eq ':custom' || $key eq ':drop'; + next unless my $fcn = $spec->{$key}; + die "spec for '$key' is not a coderef" + unless ref $fcn && ref $fcn eq 'CODE'; + my $new_value = $fcn->($data->{$key}, $key, $data, $to_version); + $new_data->{$key} = $new_value if defined $new_value; + } + + my $drop_list = $spec->{':drop'}; + my $customizer = $spec->{':custom'} || \&_keep; + + for my $key ( keys %$data ) { + next if $drop_list && grep { $key eq $_ } @$drop_list; + next if exists $spec->{$key}; # we handled it + $new_data->{ $customizer->($key) } = $data->{$key}; + } + + return $new_data; + } + + #--------------------------------------------------------------------------# + # define converters for each conversion + #--------------------------------------------------------------------------# + + # each converts from prior version + # special ":custom" field is used for keys not recognized in spec + my %up_convert = ( + '2-from-1.4' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_2, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # CHANGED TO MANDATORY + 'dynamic_config' => \&_keep_or_one, + # ADDED MANDATORY + 'release_status' => \&_release_status_from_version, + # PRIOR OPTIONAL + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_upgrade_optional_features, + 'provides' => \&_provides, + 'resources' => \&_upgrade_resources_2, + # ADDED OPTIONAL + 'description' => \&_keep, + 'prereqs' => \&_prereqs_from_1, + + # drop these deprecated fields, but only after we convert + ':drop' => [ qw( + build_requires + configure_requires + conflicts + distribution_type + license_url + private + recommends + requires + ) ], + + # other random keys need x_ prefixing + ':custom' => \&_prefix_custom, + }, + '1.4-from-1.3' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_optional_features_1_4, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_4, + # ADDED OPTIONAL + 'configure_requires' => \&_keep, + + # drop these deprecated fields, but only after we convert + ':drop' => [ qw( + license_url + private + )], + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.3-from-1.2' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_3, + + # drop these deprecated fields, but only after we convert + ':drop' => [ qw( + license_url + private + )], + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.2-from-1.1' => { + # PRIOR MANDATORY + 'version' => \&_keep, + # CHANGED TO MANDATORY + 'license' => \&_license_1, + 'name' => \&_keep, + 'generated_by' => \&_generated_by, + # ADDED MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'meta-spec' => \&_change_meta_spec, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + # ADDED OPTIONAL + 'keywords' => \&_keep, + 'no_index' => \&_no_index_1_2, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'resources' => \&_resources_1_2, + + # drop these deprecated fields, but only after we convert + ':drop' => [ qw( + license_url + private + )], + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.1-from-1.0' => { + # CHANGED TO MANDATORY + 'version' => \&_keep, + # IMPLIED MANDATORY + 'name' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + # ADDED OPTIONAL + 'license_url' => \&_url_or_drop, + 'private' => \&_keep, + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + ); + + my %down_convert = ( + '1.4-from-2' => { + # MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_downgrade_license, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # OPTIONAL + 'build_requires' => \&_get_build_requires, + 'configure_requires' => \&_get_configure_requires, + 'conflicts' => \&_get_conflicts, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_downgrade_optional_features, + 'provides' => \&_provides, + 'recommends' => \&_get_recommends, + 'requires' => \&_get_requires, + 'resources' => \&_downgrade_resources, + + # drop these unsupported fields (after conversion) + ':drop' => [ qw( + description + prereqs + release_status + )], + + # custom keys will be left unchanged + ':custom' => \&_keep + }, + '1.3-from-1.4' => { + # MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_3, + + # drop these unsupported fields, but only after we convert + ':drop' => [ qw( + configure_requires + )], + + # other random keys are OK if already valid + ':custom' => \&_keep, + }, + '1.2-from-1.3' => { + # MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_1_2, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_3, + + # other random keys are OK if already valid + ':custom' => \&_keep, + }, + '1.1-from-1.2' => { + # MANDATORY + 'version' => \&_keep, + # IMPLIED MANDATORY + 'name' => \&_keep, + 'meta-spec' => \&_change_meta_spec, + # OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'private' => \&_keep, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + + # drop unsupported fields + ':drop' => [ qw( + abstract + author + provides + no_index + keywords + resources + )], + + # other random keys are OK if already valid + ':custom' => \&_keep, + }, + '1.0-from-1.1' => { + # IMPLIED MANDATORY + 'name' => \&_keep, + 'meta-spec' => \&_change_meta_spec, + 'version' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + + # other random keys are OK if already valid + ':custom' => \&_keep, + }, + ); + + my %cleanup = ( + '2' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_2, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # CHANGED TO MANDATORY + 'dynamic_config' => \&_keep_or_one, + # ADDED MANDATORY + 'release_status' => \&_release_status, + # PRIOR OPTIONAL + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_cleanup_optional_features_2, + 'provides' => \&_provides, + 'resources' => \&_cleanup_resources_2, + # ADDED OPTIONAL + 'description' => \&_keep, + 'prereqs' => \&_cleanup_prereqs, + + # drop these deprecated fields, but only after we convert + ':drop' => [ qw( + build_requires + configure_requires + conflicts + distribution_type + license_url + private + recommends + requires + ) ], + + # other random keys need x_ prefixing + ':custom' => \&_prefix_custom, + }, + '1.4' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_optional_features_1_4, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_4, + # ADDED OPTIONAL + 'configure_requires' => \&_keep, + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.3' => { + # PRIOR MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'meta-spec' => \&_change_meta_spec, + 'name' => \&_keep, + 'version' => \&_keep, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'keywords' => \&_keep, + 'no_index' => \&_no_index_directory, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + 'resources' => \&_resources_1_3, + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.2' => { + # PRIOR MANDATORY + 'version' => \&_keep, + # CHANGED TO MANDATORY + 'license' => \&_license_1, + 'name' => \&_keep, + 'generated_by' => \&_generated_by, + # ADDED MANDATORY + 'abstract' => \&_keep_or_unknown, + 'author' => \&_author_list, + 'meta-spec' => \&_change_meta_spec, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + # ADDED OPTIONAL + 'keywords' => \&_keep, + 'no_index' => \&_no_index_1_2, + 'optional_features' => \&_optional_features_as_map, + 'provides' => \&_provides, + 'resources' => \&_resources_1_2, + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.1' => { + # CHANGED TO MANDATORY + 'version' => \&_keep, + # IMPLIED MANDATORY + 'name' => \&_keep, + 'meta-spec' => \&_change_meta_spec, + # PRIOR OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + # ADDED OPTIONAL + 'license_url' => \&_url_or_drop, + 'private' => \&_keep, + + # other random keys are OK if already valid + ':custom' => \&_keep + }, + '1.0' => { + # IMPLIED MANDATORY + 'name' => \&_keep, + 'meta-spec' => \&_change_meta_spec, + 'version' => \&_keep, + # IMPLIED OPTIONAL + 'build_requires' => \&_version_map, + 'conflicts' => \&_version_map, + 'distribution_type' => \&_keep, + 'dynamic_config' => \&_keep_or_one, + 'generated_by' => \&_generated_by, + 'license' => \&_license_1, + 'recommends' => \&_version_map, + 'requires' => \&_version_map, + + # other random keys are OK if already valid + ':custom' => \&_keep, + }, + ); + + #--------------------------------------------------------------------------# + # Code + #--------------------------------------------------------------------------# + + + sub new { + my ($class,$data) = @_; + + # create an attributes hash + my $self = { + 'data' => $data, + 'spec' => $data->{'meta-spec'}{'version'} || "1.0", + }; + + # create the object + return bless $self, $class; + } + + + sub convert { + my ($self, %args) = @_; + my $args = { %args }; + + my $new_version = $args->{version} || $HIGHEST; + + my ($old_version) = $self->{spec}; + my $converted = _dclone($self->{data}); + + if ( $old_version == $new_version ) { + $converted = _convert( $converted, $cleanup{$old_version}, $old_version ); + my $cmv = CPAN::Meta::Validator->new( $converted ); + unless ( $cmv->is_valid ) { + my $errs = join("\n", $cmv->errors); + die "Failed to clean-up $old_version metadata. Errors:\n$errs\n"; + } + return $converted; + } + elsif ( $old_version > $new_version ) { + my @vers = sort { $b <=> $a } keys %known_specs; + for my $i ( 0 .. $#vers-1 ) { + next if $vers[$i] > $old_version; + last if $vers[$i+1] < $new_version; + my $spec_string = "$vers[$i+1]-from-$vers[$i]"; + $converted = _convert( $converted, $down_convert{$spec_string}, $vers[$i+1] ); + my $cmv = CPAN::Meta::Validator->new( $converted ); + unless ( $cmv->is_valid ) { + my $errs = join("\n", $cmv->errors); + die "Failed to downconvert metadata to $vers[$i+1]. Errors:\n$errs\n"; + } + } + return $converted; + } + else { + my @vers = sort { $a <=> $b } keys %known_specs; + for my $i ( 0 .. $#vers-1 ) { + next if $vers[$i] < $old_version; + last if $vers[$i+1] > $new_version; + my $spec_string = "$vers[$i+1]-from-$vers[$i]"; + $converted = _convert( $converted, $up_convert{$spec_string}, $vers[$i+1] ); + my $cmv = CPAN::Meta::Validator->new( $converted ); + unless ( $cmv->is_valid ) { + my $errs = join("\n", $cmv->errors); + die "Failed to upconvert metadata to $vers[$i+1]. Errors:\n$errs\n"; + } + } + return $converted; + } + } + + 1; + + + + + __END__ + + +CPAN_META_CONVERTER + +$fatpacked{"CPAN/Meta/Feature.pm"} = <<'CPAN_META_FEATURE'; + use 5.006; + use strict; + use warnings; + package CPAN::Meta::Feature; + BEGIN { + $CPAN::Meta::Feature::VERSION = '2.110930'; + } + # ABSTRACT: an optional feature provided by a CPAN distribution + + use CPAN::Meta::Prereqs; + + + sub new { + my ($class, $identifier, $spec) = @_; + + my %guts = ( + identifier => $identifier, + description => $spec->{description}, + prereqs => CPAN::Meta::Prereqs->new($spec->{prereqs}), + ); + + bless \%guts => $class; + } + + + sub identifier { $_[0]{identifier} } + + + sub description { $_[0]{description} } + + + sub prereqs { $_[0]{prereqs} } + + 1; + + + + + __END__ + + + +CPAN_META_FEATURE + +$fatpacked{"CPAN/Meta/History.pm"} = <<'CPAN_META_HISTORY'; + # vi:tw=72 + use 5.006; + use strict; + use warnings; + package CPAN::Meta::History; + BEGIN { + $CPAN::Meta::History::VERSION = '2.110930'; + } + # ABSTRACT: history of CPAN Meta Spec changes + 1; + + + + __END__ + =pod + +CPAN_META_HISTORY + +$fatpacked{"CPAN/Meta/Prereqs.pm"} = <<'CPAN_META_PREREQS'; + use 5.006; + use strict; + use warnings; + package CPAN::Meta::Prereqs; + BEGIN { + $CPAN::Meta::Prereqs::VERSION = '2.110930'; + } + # ABSTRACT: a set of distribution prerequisites by phase and type + + + use Carp qw(confess); + use Scalar::Util qw(blessed); + use Version::Requirements 0.101020; # finalize + + + sub __legal_phases { qw(configure build test runtime develop) } + sub __legal_types { qw(requires recommends suggests conflicts) } + + # expect a prereq spec from META.json -- rjbs, 2010-04-11 + sub new { + my ($class, $prereq_spec) = @_; + $prereq_spec ||= {}; + + my %is_legal_phase = map {; $_ => 1 } $class->__legal_phases; + my %is_legal_type = map {; $_ => 1 } $class->__legal_types; + + my %guts; + PHASE: for my $phase (keys %$prereq_spec) { + next PHASE unless $phase =~ /\Ax_/i or $is_legal_phase{$phase}; + + my $phase_spec = $prereq_spec->{ $phase }; + next PHASE unless keys %$phase_spec; + + TYPE: for my $type (keys %$phase_spec) { + next TYPE unless $type =~ /\Ax_/i or $is_legal_type{$type}; + + my $spec = $phase_spec->{ $type }; + + next TYPE unless keys %$spec; + + $guts{prereqs}{$phase}{$type} = Version::Requirements->from_string_hash( + $spec + ); + } + } + + return bless \%guts => $class; + } + + + sub requirements_for { + my ($self, $phase, $type) = @_; + + confess "requirements_for called without phase" unless defined $phase; + confess "requirements_for called without type" unless defined $type; + + unless ($phase =~ /\Ax_/i or grep { $phase eq $_ } $self->__legal_phases) { + confess "requested requirements for unknown phase: $phase"; + } + + unless ($type =~ /\Ax_/i or grep { $type eq $_ } $self->__legal_types) { + confess "requested requirements for unknown type: $type"; + } + + my $req = ($self->{prereqs}{$phase}{$type} ||= Version::Requirements->new); + + $req->finalize if $self->is_finalized; + + return $req; + } + + + sub with_merged_prereqs { + my ($self, $other) = @_; + + my @other = blessed($other) ? $other : @$other; + + my @prereq_objs = ($self, @other); + + my %new_arg; + + for my $phase ($self->__legal_phases) { + for my $type ($self->__legal_types) { + my $req = Version::Requirements->new; + + for my $prereq (@prereq_objs) { + my $this_req = $prereq->requirements_for($phase, $type); + next unless $this_req->required_modules; + + $req->add_requirements($this_req); + } + + next unless $req->required_modules; + + $new_arg{ $phase }{ $type } = $req->as_string_hash; + } + } + + return (ref $self)->new(\%new_arg); + } + + + sub as_string_hash { + my ($self) = @_; + + my %hash; + + for my $phase ($self->__legal_phases) { + for my $type ($self->__legal_types) { + my $req = $self->requirements_for($phase, $type); + next unless $req->required_modules; + + $hash{ $phase }{ $type } = $req->as_string_hash; + } + } + + return \%hash; + } + + + sub is_finalized { $_[0]{finalized} } + + + sub finalize { + my ($self) = @_; + + $self->{finalized} = 1; + + for my $phase (keys %{ $self->{prereqs} }) { + $_->finalize for values %{ $self->{prereqs}{$phase} }; + } + } + + + sub clone { + my ($self) = @_; + + my $clone = (ref $self)->new( $self->as_string_hash ); + } + + 1; + + + + + __END__ + + + +CPAN_META_PREREQS + +$fatpacked{"CPAN/Meta/Spec.pm"} = <<'CPAN_META_SPEC'; + # vi:tw=72 + use 5.006; + use strict; + use warnings; + package CPAN::Meta::Spec; + BEGIN { + $CPAN::Meta::Spec::VERSION = '2.110930'; + } + # ABSTRACT: specification for CPAN distribution metadata + 1; + + + + __END__ + =pod + +CPAN_META_SPEC + +$fatpacked{"CPAN/Meta/Validator.pm"} = <<'CPAN_META_VALIDATOR'; + use 5.006; + use strict; + use warnings; + package CPAN::Meta::Validator; + BEGIN { + $CPAN::Meta::Validator::VERSION = '2.110930'; + } + # ABSTRACT: validate CPAN distribution metadata structures + + + #--------------------------------------------------------------------------# + # This code copied and adapted from Test::CPAN::Meta + # by Barbie, for Miss Barbell Productions, + # L + #--------------------------------------------------------------------------# + + #--------------------------------------------------------------------------# + # Specification Definitions + #--------------------------------------------------------------------------# + + my %known_specs = ( + '1.4' => 'http://module-build.sourceforge.net/META-spec-v1.4.html', + '1.3' => 'http://module-build.sourceforge.net/META-spec-v1.3.html', + '1.2' => 'http://module-build.sourceforge.net/META-spec-v1.2.html', + '1.1' => 'http://module-build.sourceforge.net/META-spec-v1.1.html', + '1.0' => 'http://module-build.sourceforge.net/META-spec-v1.0.html' + ); + my %known_urls = map {$known_specs{$_} => $_} keys %known_specs; + + my $module_map1 = { 'map' => { ':key' => { name => \&module, value => \&exversion } } }; + + my $module_map2 = { 'map' => { ':key' => { name => \&module, value => \&version } } }; + + my $no_index_2 = { + 'map' => { file => { list => { value => \&string } }, + directory => { list => { value => \&string } }, + 'package' => { list => { value => \&string } }, + namespace => { list => { value => \&string } }, + ':key' => { name => \&custom_2, value => \&anything }, + } + }; + + my $no_index_1_3 = { + 'map' => { file => { list => { value => \&string } }, + directory => { list => { value => \&string } }, + 'package' => { list => { value => \&string } }, + namespace => { list => { value => \&string } }, + ':key' => { name => \&string, value => \&anything }, + } + }; + + my $no_index_1_2 = { + 'map' => { file => { list => { value => \&string } }, + dir => { list => { value => \&string } }, + 'package' => { list => { value => \&string } }, + namespace => { list => { value => \&string } }, + ':key' => { name => \&string, value => \&anything }, + } + }; + + my $no_index_1_1 = { + 'map' => { ':key' => { name => \&string, list => { value => \&string } }, + } + }; + + my $prereq_map = { + map => { + ':key' => { + name => \&phase, + 'map' => { + ':key' => { + name => \&relation, + %$module_map1, + }, + }, + } + }, + }; + + my %definitions = ( + '2' => { + # REQUIRED + 'abstract' => { mandatory => 1, value => \&string }, + 'author' => { mandatory => 1, lazylist => { value => \&string } }, + 'dynamic_config' => { mandatory => 1, value => \&boolean }, + 'generated_by' => { mandatory => 1, value => \&string }, + 'license' => { mandatory => 1, lazylist => { value => \&license } }, + 'meta-spec' => { + mandatory => 1, + 'map' => { + version => { mandatory => 1, value => \&version}, + url => { value => \&url }, + ':key' => { name => \&custom_2, value => \&anything }, + } + }, + 'name' => { mandatory => 1, value => \&string }, + 'release_status' => { mandatory => 1, value => \&release_status }, + 'version' => { mandatory => 1, value => \&version }, + + # OPTIONAL + 'description' => { value => \&string }, + 'keywords' => { lazylist => { value => \&string } }, + 'no_index' => $no_index_2, + 'optional_features' => { + 'map' => { + ':key' => { + name => \&string, + 'map' => { + description => { value => \&string }, + prereqs => $prereq_map, + ':key' => { name => \&custom_2, value => \&anything }, + } + } + } + }, + 'prereqs' => $prereq_map, + 'provides' => { + 'map' => { + ':key' => { + name => \&module, + 'map' => { + file => { mandatory => 1, value => \&file }, + version => { value => \&version }, + ':key' => { name => \&custom_2, value => \&anything }, + } + } + } + }, + 'resources' => { + 'map' => { + license => { lazylist => { value => \&url } }, + homepage => { value => \&url }, + bugtracker => { + 'map' => { + web => { value => \&url }, + mailto => { value => \&string}, + ':key' => { name => \&custom_2, value => \&anything }, + } + }, + repository => { + 'map' => { + web => { value => \&url }, + url => { value => \&url }, + type => { value => \&string }, + ':key' => { name => \&custom_2, value => \&anything }, + } + }, + ':key' => { value => \&string, name => \&custom_2 }, + } + }, + + # CUSTOM -- additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&custom_2, value => \&anything }, + }, + + '1.4' => { + 'meta-spec' => { + mandatory => 1, + 'map' => { + version => { mandatory => 1, value => \&version}, + url => { mandatory => 1, value => \&urlspec }, + ':key' => { name => \&string, value => \&anything }, + }, + }, + + 'name' => { mandatory => 1, value => \&string }, + 'version' => { mandatory => 1, value => \&version }, + 'abstract' => { mandatory => 1, value => \&string }, + 'author' => { mandatory => 1, list => { value => \&string } }, + 'license' => { mandatory => 1, value => \&license }, + 'generated_by' => { mandatory => 1, value => \&string }, + + 'distribution_type' => { value => \&string }, + 'dynamic_config' => { value => \&boolean }, + + 'requires' => $module_map1, + 'recommends' => $module_map1, + 'build_requires' => $module_map1, + 'configure_requires' => $module_map1, + 'conflicts' => $module_map2, + + 'optional_features' => { + 'map' => { + ':key' => { name => \&string, + 'map' => { description => { value => \&string }, + requires => $module_map1, + recommends => $module_map1, + build_requires => $module_map1, + conflicts => $module_map2, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + 'provides' => { + 'map' => { + ':key' => { name => \&module, + 'map' => { + file => { mandatory => 1, value => \&file }, + version => { value => \&version }, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + 'no_index' => $no_index_1_3, + 'private' => $no_index_1_3, + + 'keywords' => { list => { value => \&string } }, + + 'resources' => { + 'map' => { license => { value => \&url }, + homepage => { value => \&url }, + bugtracker => { value => \&url }, + repository => { value => \&url }, + ':key' => { value => \&string, name => \&custom_1 }, + } + }, + + # additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&string, value => \&anything }, + }, + + '1.3' => { + 'meta-spec' => { + mandatory => 1, + 'map' => { + version => { mandatory => 1, value => \&version}, + url => { mandatory => 1, value => \&urlspec }, + ':key' => { name => \&string, value => \&anything }, + }, + }, + + 'name' => { mandatory => 1, value => \&string }, + 'version' => { mandatory => 1, value => \&version }, + 'abstract' => { mandatory => 1, value => \&string }, + 'author' => { mandatory => 1, list => { value => \&string } }, + 'license' => { mandatory => 1, value => \&license }, + 'generated_by' => { mandatory => 1, value => \&string }, + + 'distribution_type' => { value => \&string }, + 'dynamic_config' => { value => \&boolean }, + + 'requires' => $module_map1, + 'recommends' => $module_map1, + 'build_requires' => $module_map1, + 'conflicts' => $module_map2, + + 'optional_features' => { + 'map' => { + ':key' => { name => \&string, + 'map' => { description => { value => \&string }, + requires => $module_map1, + recommends => $module_map1, + build_requires => $module_map1, + conflicts => $module_map2, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + 'provides' => { + 'map' => { + ':key' => { name => \&module, + 'map' => { + file => { mandatory => 1, value => \&file }, + version => { value => \&version }, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + + 'no_index' => $no_index_1_3, + 'private' => $no_index_1_3, + + 'keywords' => { list => { value => \&string } }, + + 'resources' => { + 'map' => { license => { value => \&url }, + homepage => { value => \&url }, + bugtracker => { value => \&url }, + repository => { value => \&url }, + ':key' => { value => \&string, name => \&custom_1 }, + } + }, + + # additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&string, value => \&anything }, + }, + + # v1.2 is misleading, it seems to assume that a number of fields where created + # within v1.1, when they were created within v1.2. This may have been an + # original mistake, and that a v1.1 was retro fitted into the timeline, when + # v1.2 was originally slated as v1.1. But I could be wrong ;) + '1.2' => { + 'meta-spec' => { + mandatory => 1, + 'map' => { + version => { mandatory => 1, value => \&version}, + url => { mandatory => 1, value => \&urlspec }, + ':key' => { name => \&string, value => \&anything }, + }, + }, + + + 'name' => { mandatory => 1, value => \&string }, + 'version' => { mandatory => 1, value => \&version }, + 'license' => { mandatory => 1, value => \&license }, + 'generated_by' => { mandatory => 1, value => \&string }, + 'author' => { mandatory => 1, list => { value => \&string } }, + 'abstract' => { mandatory => 1, value => \&string }, + + 'distribution_type' => { value => \&string }, + 'dynamic_config' => { value => \&boolean }, + + 'keywords' => { list => { value => \&string } }, + + 'private' => $no_index_1_2, + '$no_index' => $no_index_1_2, + + 'requires' => $module_map1, + 'recommends' => $module_map1, + 'build_requires' => $module_map1, + 'conflicts' => $module_map2, + + 'optional_features' => { + 'map' => { + ':key' => { name => \&string, + 'map' => { description => { value => \&string }, + requires => $module_map1, + recommends => $module_map1, + build_requires => $module_map1, + conflicts => $module_map2, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + 'provides' => { + 'map' => { + ':key' => { name => \&module, + 'map' => { + file => { mandatory => 1, value => \&file }, + version => { value => \&version }, + ':key' => { name => \&string, value => \&anything }, + } + } + } + }, + + 'resources' => { + 'map' => { license => { value => \&url }, + homepage => { value => \&url }, + bugtracker => { value => \&url }, + repository => { value => \&url }, + ':key' => { value => \&string, name => \&custom_1 }, + } + }, + + # additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&string, value => \&anything }, + }, + + # note that the 1.1 spec only specifies 'version' as mandatory + '1.1' => { + 'name' => { value => \&string }, + 'version' => { mandatory => 1, value => \&version }, + 'license' => { value => \&license }, + 'generated_by' => { value => \&string }, + + 'license_uri' => { value => \&url }, + 'distribution_type' => { value => \&string }, + 'dynamic_config' => { value => \&boolean }, + + 'private' => $no_index_1_1, + + 'requires' => $module_map1, + 'recommends' => $module_map1, + 'build_requires' => $module_map1, + 'conflicts' => $module_map2, + + # additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&string, value => \&anything }, + }, + + # note that the 1.0 spec doesn't specify optional or mandatory fields + # but we will treat version as mandatory since otherwise META 1.0 is + # completely arbitrary and pointless + '1.0' => { + 'name' => { value => \&string }, + 'version' => { mandatory => 1, value => \&version }, + 'license' => { value => \&license }, + 'generated_by' => { value => \&string }, + + 'license_uri' => { value => \&url }, + 'distribution_type' => { value => \&string }, + 'dynamic_config' => { value => \&boolean }, + + 'requires' => $module_map1, + 'recommends' => $module_map1, + 'build_requires' => $module_map1, + 'conflicts' => $module_map2, + + # additional user defined key/value pairs + # note we can only validate the key name, as the structure is user defined + ':key' => { name => \&string, value => \&anything }, + }, + ); + + #--------------------------------------------------------------------------# + # Code + #--------------------------------------------------------------------------# + + + sub new { + my ($class,$data) = @_; + + # create an attributes hash + my $self = { + 'data' => $data, + 'spec' => $data->{'meta-spec'}{'version'} || "1.0", + 'errors' => undef, + }; + + # create the object + return bless $self, $class; + } + + + sub is_valid { + my $self = shift; + my $data = $self->{data}; + my $spec_version = $self->{spec}; + $self->check_map($definitions{$spec_version},$data); + return ! $self->errors; + } + + + sub errors { + my $self = shift; + return () unless(defined $self->{errors}); + return @{$self->{errors}}; + } + + + my $spec_error = "Missing validation action in specification. " + . "Must be one of 'map', 'list', 'lazylist', or 'value'"; + + sub check_map { + my ($self,$spec,$data) = @_; + + if(ref($spec) ne 'HASH') { + $self->_error( "Unknown META specification, cannot validate." ); + return; + } + + if(ref($data) ne 'HASH') { + $self->_error( "Expected a map structure from string or file." ); + return; + } + + for my $key (keys %$spec) { + next unless($spec->{$key}->{mandatory}); + next if(defined $data->{$key}); + push @{$self->{stack}}, $key; + $self->_error( "Missing mandatory field, '$key'" ); + pop @{$self->{stack}}; + } + + for my $key (keys %$data) { + push @{$self->{stack}}, $key; + if($spec->{$key}) { + if($spec->{$key}{value}) { + $spec->{$key}{value}->($self,$key,$data->{$key}); + } elsif($spec->{$key}{'map'}) { + $self->check_map($spec->{$key}{'map'},$data->{$key}); + } elsif($spec->{$key}{'list'}) { + $self->check_list($spec->{$key}{'list'},$data->{$key}); + } elsif($spec->{$key}{'lazylist'}) { + $self->check_lazylist($spec->{$key}{'lazylist'},$data->{$key}); + } else { + $self->_error( "$spec_error for '$key'" ); + } + + } elsif ($spec->{':key'}) { + $spec->{':key'}{name}->($self,$key,$key); + if($spec->{':key'}{value}) { + $spec->{':key'}{value}->($self,$key,$data->{$key}); + } elsif($spec->{':key'}{'map'}) { + $self->check_map($spec->{':key'}{'map'},$data->{$key}); + } elsif($spec->{':key'}{'list'}) { + $self->check_list($spec->{':key'}{'list'},$data->{$key}); + } elsif($spec->{':key'}{'lazylist'}) { + $self->check_lazylist($spec->{':key'}{'lazylist'},$data->{$key}); + } else { + $self->_error( "$spec_error for ':key'" ); + } + + + } else { + $self->_error( "Unknown key, '$key', found in map structure" ); + } + pop @{$self->{stack}}; + } + } + + # if it's a string, make it into a list and check the list + sub check_lazylist { + my ($self,$spec,$data) = @_; + + if ( defined $data && ! ref($data) ) { + $data = [ $data ]; + } + + $self->check_list($spec,$data); + } + + sub check_list { + my ($self,$spec,$data) = @_; + + if(ref($data) ne 'ARRAY') { + $self->_error( "Expected a list structure" ); + return; + } + + if(defined $spec->{mandatory}) { + if(!defined $data->[0]) { + $self->_error( "Missing entries from mandatory list" ); + } + } + + for my $value (@$data) { + push @{$self->{stack}}, $value || ""; + if(defined $spec->{value}) { + $spec->{value}->($self,'list',$value); + } elsif(defined $spec->{'map'}) { + $self->check_map($spec->{'map'},$value); + } elsif(defined $spec->{'list'}) { + $self->check_list($spec->{'list'},$value); + } elsif(defined $spec->{'lazylist'}) { + $self->check_lazylist($spec->{'lazylist'},$value); + } elsif ($spec->{':key'}) { + $self->check_map($spec,$value); + } else { + $self->_error( "$spec_error associated with '$self->{stack}[-2]'" ); + } + pop @{$self->{stack}}; + } + } + + + sub header { + my ($self,$key,$value) = @_; + if(defined $value) { + return 1 if($value && $value =~ /^--- #YAML:1.0/); + } + $self->_error( "file does not have a valid YAML header." ); + return 0; + } + + sub release_status { + my ($self,$key,$value) = @_; + if(defined $value) { + my $version = $self->{data}{version} || ''; + if ( $version =~ /_/ ) { + return 1 if ( $value =~ /\A(?:testing|unstable)\z/ ); + $self->_error( "'$value' for '$key' is invalid for version '$version'" ); + } + else { + return 1 if ( $value =~ /\A(?:stable|testing|unstable)\z/ ); + $self->_error( "'$value' for '$key' is invalid" ); + } + } + else { + $self->_error( "'$key' is not defined" ); + } + return 0; + } + + # _uri_split taken from URI::Split by Gisle Aas, Copyright 2003 + sub _uri_split { + return $_[0] =~ m,(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?,; + } + + sub url { + my ($self,$key,$value) = @_; + if(defined $value) { + my ($scheme, $auth, $path, $query, $frag) = _uri_split($value); + unless ( defined $scheme && length $scheme ) { + $self->_error( "'$value' for '$key' does not have a URL scheme" ); + return 0; + } + unless ( defined $auth && length $auth ) { + $self->_error( "'$value' for '$key' does not have a URL authority" ); + return 0; + } + return 1; + } + $value ||= ''; + $self->_error( "'$value' for '$key' is not a valid URL." ); + return 0; + } + + sub urlspec { + my ($self,$key,$value) = @_; + if(defined $value) { + return 1 if($value && $known_specs{$self->{spec}} eq $value); + if($value && $known_urls{$value}) { + $self->_error( 'META specification URL does not match version' ); + return 0; + } + } + $self->_error( 'Unknown META specification' ); + return 0; + } + + sub anything { return 1 } + + sub string { + my ($self,$key,$value) = @_; + if(defined $value) { + return 1 if($value || $value =~ /^0$/); + } + $self->_error( "value is an undefined string" ); + return 0; + } + + sub string_or_undef { + my ($self,$key,$value) = @_; + return 1 unless(defined $value); + return 1 if($value || $value =~ /^0$/); + $self->_error( "No string defined for '$key'" ); + return 0; + } + + sub file { + my ($self,$key,$value) = @_; + return 1 if(defined $value); + $self->_error( "No file defined for '$key'" ); + return 0; + } + + sub exversion { + my ($self,$key,$value) = @_; + if(defined $value && ($value || $value =~ /0/)) { + my $pass = 1; + for(split(",",$value)) { $self->version($key,$_) or ($pass = 0); } + return $pass; + } + $value = '' unless(defined $value); + $self->_error( "'$value' for '$key' is not a valid version." ); + return 0; + } + + sub version { + my ($self,$key,$value) = @_; + if(defined $value) { + return 0 unless($value || $value =~ /0/); + return 1 if($value =~ /^\s*((<|<=|>=|>|!=|==)\s*)?v?\d+((\.\d+((_|\.)\d+)?)?)/); + } else { + $value = ''; + } + $self->_error( "'$value' for '$key' is not a valid version." ); + return 0; + } + + sub boolean { + my ($self,$key,$value) = @_; + if(defined $value) { + return 1 if($value =~ /^(0|1|true|false)$/); + } else { + $value = ''; + } + $self->_error( "'$value' for '$key' is not a boolean value." ); + return 0; + } + + my %v1_licenses = ( + 'perl' => 'http://dev.perl.org/licenses/', + 'gpl' => 'http://www.opensource.org/licenses/gpl-license.php', + 'apache' => 'http://apache.org/licenses/LICENSE-2.0', + 'artistic' => 'http://opensource.org/licenses/artistic-license.php', + 'artistic_2' => 'http://opensource.org/licenses/artistic-license-2.0.php', + 'lgpl' => 'http://www.opensource.org/licenses/lgpl-license.phpt', + 'bsd' => 'http://www.opensource.org/licenses/bsd-license.php', + 'gpl' => 'http://www.opensource.org/licenses/gpl-license.php', + 'mit' => 'http://opensource.org/licenses/mit-license.php', + 'mozilla' => 'http://opensource.org/licenses/mozilla1.1.php', + 'open_source' => undef, + 'unrestricted' => undef, + 'restrictive' => undef, + 'unknown' => undef, + ); + + my %v2_licenses = map { $_ => 1 } qw( + agpl_3 + apache_1_1 + apache_2_0 + artistic_1 + artistic_2 + bsd + freebsd + gfdl_1_2 + gfdl_1_3 + gpl_1 + gpl_2 + gpl_3 + lgpl_2_1 + lgpl_3_0 + mit + mozilla_1_0 + mozilla_1_1 + openssl + perl_5 + qpl_1_0 + ssleay + sun + zlib + open_source + restricted + unrestricted + unknown + ); + + sub license { + my ($self,$key,$value) = @_; + my $licenses = $self->{spec} < 2 ? \%v1_licenses : \%v2_licenses; + if(defined $value) { + return 1 if($value && exists $licenses->{$value}); + } else { + $value = ''; + } + $self->_error( "License '$value' is invalid" ); + return 0; + } + + sub custom_1 { + my ($self,$key) = @_; + if(defined $key) { + # a valid user defined key should be alphabetic + # and contain at least one capital case letter. + return 1 if($key && $key =~ /^[_a-z]+$/i && $key =~ /[A-Z]/); + } else { + $key = ''; + } + $self->_error( "Custom resource '$key' must be in CamelCase." ); + return 0; + } + + sub custom_2 { + my ($self,$key) = @_; + if(defined $key) { + return 1 if($key && $key =~ /^x_/i); # user defined + } else { + $key = ''; + } + $self->_error( "Custom key '$key' must begin with 'x_' or 'X_'." ); + return 0; + } + + sub identifier { + my ($self,$key) = @_; + if(defined $key) { + return 1 if($key && $key =~ /^([a-z][_a-z]+)$/i); # spec 2.0 defined + } else { + $key = ''; + } + $self->_error( "Key '$key' is not a legal identifier." ); + return 0; + } + + sub module { + my ($self,$key) = @_; + if(defined $key) { + return 1 if($key && $key =~ /^[A-Za-z0-9_]+(::[A-Za-z0-9_]+)*$/); + } else { + $key = ''; + } + $self->_error( "Key '$key' is not a legal module name." ); + return 0; + } + + my @valid_phases = qw/ configure build test runtime develop /; + sub phase { + my ($self,$key) = @_; + if(defined $key) { + return 1 if( length $key && grep { $key eq $_ } @valid_phases ); + return 1 if $key =~ /x_/i; + } else { + $key = ''; + } + $self->_error( "Key '$key' is not a legal phase." ); + return 0; + } + + my @valid_relations = qw/ requires recommends suggests conflicts /; + sub relation { + my ($self,$key) = @_; + if(defined $key) { + return 1 if( length $key && grep { $key eq $_ } @valid_relations ); + return 1 if $key =~ /x_/i; + } else { + $key = ''; + } + $self->_error( "Key '$key' is not a legal prereq relationship." ); + return 0; + } + + sub _error { + my $self = shift; + my $mess = shift; + + $mess .= ' ('.join(' -> ',@{$self->{stack}}).')' if($self->{stack}); + $mess .= " [Validation: $self->{spec}]"; + + push @{$self->{errors}}, $mess; + } + + 1; + + + + + __END__ + + + +CPAN_META_VALIDATOR + +$fatpacked{"CPAN/Meta/YAML.pm"} = <<'CPAN_META_YAML'; + package CPAN::Meta::YAML; + BEGIN { + $CPAN::Meta::YAML::VERSION = '0.003'; + } + + use strict; + + # UTF Support? + sub HAVE_UTF8 () { $] >= 5.007003 } + BEGIN { + if ( HAVE_UTF8 ) { + # The string eval helps hide this from Test::MinimumVersion + eval "require utf8;"; + die "Failed to load UTF-8 support" if $@; + } + + # Class structure + require 5.004; + require Exporter; + require Carp; + @CPAN::Meta::YAML::ISA = qw{ Exporter }; + @CPAN::Meta::YAML::EXPORT = qw{ Load Dump }; + @CPAN::Meta::YAML::EXPORT_OK = qw{ LoadFile DumpFile freeze thaw }; + + # Error storage + $CPAN::Meta::YAML::errstr = ''; + } + + # The character class of all characters we need to escape + # NOTE: Inlined, since it's only used once + # my $RE_ESCAPE = '[\\x00-\\x08\\x0b-\\x0d\\x0e-\\x1f\"\n]'; + + # Printed form of the unprintable characters in the lowest range + # of ASCII characters, listed by ASCII ordinal position. + my @UNPRINTABLE = qw( + z x01 x02 x03 x04 x05 x06 a + x08 t n v f r x0e x0f + x10 x11 x12 x13 x14 x15 x16 x17 + x18 x19 x1a e x1c x1d x1e x1f + ); + + # Printable characters for escapes + my %UNESCAPES = ( + z => "\x00", a => "\x07", t => "\x09", + n => "\x0a", v => "\x0b", f => "\x0c", + r => "\x0d", e => "\x1b", '\\' => '\\', + ); + + # Special magic boolean words + my %QUOTE = map { $_ => 1 } qw{ + null Null NULL + y Y yes Yes YES n N no No NO + true True TRUE false False FALSE + on On ON off Off OFF + }; + + + + + + ##################################################################### + # Implementation + + # Create an empty CPAN::Meta::YAML object + sub new { + my $class = shift; + bless [ @_ ], $class; + } + + # Create an object from a file + sub read { + my $class = ref $_[0] ? ref shift : shift; + + # Check the file + my $file = shift or return $class->_error( 'You did not specify a file name' ); + return $class->_error( "File '$file' does not exist" ) unless -e $file; + return $class->_error( "'$file' is a directory, not a file" ) unless -f _; + return $class->_error( "Insufficient permissions to read '$file'" ) unless -r _; + + # Slurp in the file + local $/ = undef; + local *CFG; + unless ( open(CFG, $file) ) { + return $class->_error("Failed to open file '$file': $!"); + } + my $contents = ; + unless ( close(CFG) ) { + return $class->_error("Failed to close file '$file': $!"); + } + + $class->read_string( $contents ); + } + + # Create an object from a string + sub read_string { + my $class = ref $_[0] ? ref shift : shift; + my $self = bless [], $class; + my $string = $_[0]; + eval { + unless ( defined $string ) { + die \"Did not provide a string to load"; + } + + # Byte order marks + # NOTE: Keeping this here to educate maintainers + # my %BOM = ( + # "\357\273\277" => 'UTF-8', + # "\376\377" => 'UTF-16BE', + # "\377\376" => 'UTF-16LE', + # "\377\376\0\0" => 'UTF-32LE' + # "\0\0\376\377" => 'UTF-32BE', + # ); + if ( $string =~ /^(?:\376\377|\377\376|\377\376\0\0|\0\0\376\377)/ ) { + die \"Stream has a non UTF-8 BOM"; + } else { + # Strip UTF-8 bom if found, we'll just ignore it + $string =~ s/^\357\273\277//; + } + + # Try to decode as utf8 + utf8::decode($string) if HAVE_UTF8; + + # Check for some special cases + return $self unless length $string; + unless ( $string =~ /[\012\015]+\z/ ) { + die \"Stream does not end with newline character"; + } + + # Split the file into lines + my @lines = grep { ! /^\s*(?:\#.*)?\z/ } + split /(?:\015{1,2}\012|\015|\012)/, $string; + + # Strip the initial YAML header + @lines and $lines[0] =~ /^\%YAML[: ][\d\.]+.*\z/ and shift @lines; + + # A nibbling parser + while ( @lines ) { + # Do we have a document header? + if ( $lines[0] =~ /^---\s*(?:(.+)\s*)?\z/ ) { + # Handle scalar documents + shift @lines; + if ( defined $1 and $1 !~ /^(?:\#.+|\%YAML[: ][\d\.]+)\z/ ) { + push @$self, $self->_read_scalar( "$1", [ undef ], \@lines ); + next; + } + } + + if ( ! @lines or $lines[0] =~ /^(?:---|\.\.\.)/ ) { + # A naked document + push @$self, undef; + while ( @lines and $lines[0] !~ /^---/ ) { + shift @lines; + } + + } elsif ( $lines[0] =~ /^\s*\-/ ) { + # An array at the root + my $document = [ ]; + push @$self, $document; + $self->_read_array( $document, [ 0 ], \@lines ); + + } elsif ( $lines[0] =~ /^(\s*)\S/ ) { + # A hash at the root + my $document = { }; + push @$self, $document; + $self->_read_hash( $document, [ length($1) ], \@lines ); + + } else { + die \"CPAN::Meta::YAML failed to classify the line '$lines[0]'"; + } + } + }; + if ( ref $@ eq 'SCALAR' ) { + return $self->_error(${$@}); + } elsif ( $@ ) { + require Carp; + Carp::croak($@); + } + + return $self; + } + + # Deparse a scalar string to the actual scalar + sub _read_scalar { + my ($self, $string, $indent, $lines) = @_; + + # Trim trailing whitespace + $string =~ s/\s*\z//; + + # Explitic null/undef + return undef if $string eq '~'; + + # Single quote + if ( $string =~ /^\'(.*?)\'(?:\s+\#.*)?\z/ ) { + return '' unless defined $1; + $string = $1; + $string =~ s/\'\'/\'/g; + return $string; + } + + # Double quote. + # The commented out form is simpler, but overloaded the Perl regex + # engine due to recursion and backtracking problems on strings + # larger than 32,000ish characters. Keep it for reference purposes. + # if ( $string =~ /^\"((?:\\.|[^\"])*)\"\z/ ) { + if ( $string =~ /^\"([^\\"]*(?:\\.[^\\"]*)*)\"(?:\s+\#.*)?\z/ ) { + # Reusing the variable is a little ugly, + # but avoids a new variable and a string copy. + $string = $1; + $string =~ s/\\"/"/g; + $string =~ s/\\([never\\fartz]|x([0-9a-fA-F]{2}))/(length($1)>1)?pack("H2",$2):$UNESCAPES{$1}/gex; + return $string; + } + + # Special cases + if ( $string =~ /^[\'\"!&]/ ) { + die \"CPAN::Meta::YAML does not support a feature in line '$string'"; + } + return {} if $string =~ /^{}(?:\s+\#.*)?\z/; + return [] if $string =~ /^\[\](?:\s+\#.*)?\z/; + + # Regular unquoted string + if ( $string !~ /^[>|]/ ) { + if ( + $string =~ /^(?:-(?:\s|$)|[\@\%\`])/ + or + $string =~ /:(?:\s|$)/ + ) { + die \"CPAN::Meta::YAML found illegal characters in plain scalar: '$string'"; + } + $string =~ s/\s+#.*\z//; + return $string; + } + + # Error + die \"CPAN::Meta::YAML failed to find multi-line scalar content" unless @$lines; + + # Check the indent depth + $lines->[0] =~ /^(\s*)/; + $indent->[-1] = length("$1"); + if ( defined $indent->[-2] and $indent->[-1] <= $indent->[-2] ) { + die \"CPAN::Meta::YAML found bad indenting in line '$lines->[0]'"; + } + + # Pull the lines + my @multiline = (); + while ( @$lines ) { + $lines->[0] =~ /^(\s*)/; + last unless length($1) >= $indent->[-1]; + push @multiline, substr(shift(@$lines), length($1)); + } + + my $j = (substr($string, 0, 1) eq '>') ? ' ' : "\n"; + my $t = (substr($string, 1, 1) eq '-') ? '' : "\n"; + return join( $j, @multiline ) . $t; + } + + # Parse an array + sub _read_array { + my ($self, $array, $indent, $lines) = @_; + + while ( @$lines ) { + # Check for a new document + if ( $lines->[0] =~ /^(?:---|\.\.\.)/ ) { + while ( @$lines and $lines->[0] !~ /^---/ ) { + shift @$lines; + } + return 1; + } + + # Check the indent level + $lines->[0] =~ /^(\s*)/; + if ( length($1) < $indent->[-1] ) { + return 1; + } elsif ( length($1) > $indent->[-1] ) { + die \"CPAN::Meta::YAML found bad indenting in line '$lines->[0]'"; + } + + if ( $lines->[0] =~ /^(\s*\-\s+)[^\'\"]\S*\s*:(?:\s+|$)/ ) { + # Inline nested hash + my $indent2 = length("$1"); + $lines->[0] =~ s/-/ /; + push @$array, { }; + $self->_read_hash( $array->[-1], [ @$indent, $indent2 ], $lines ); + + } elsif ( $lines->[0] =~ /^\s*\-(\s*)(.+?)\s*\z/ ) { + # Array entry with a value + shift @$lines; + push @$array, $self->_read_scalar( "$2", [ @$indent, undef ], $lines ); + + } elsif ( $lines->[0] =~ /^\s*\-\s*\z/ ) { + shift @$lines; + unless ( @$lines ) { + push @$array, undef; + return 1; + } + if ( $lines->[0] =~ /^(\s*)\-/ ) { + my $indent2 = length("$1"); + if ( $indent->[-1] == $indent2 ) { + # Null array entry + push @$array, undef; + } else { + # Naked indenter + push @$array, [ ]; + $self->_read_array( $array->[-1], [ @$indent, $indent2 ], $lines ); + } + + } elsif ( $lines->[0] =~ /^(\s*)\S/ ) { + push @$array, { }; + $self->_read_hash( $array->[-1], [ @$indent, length("$1") ], $lines ); + + } else { + die \"CPAN::Meta::YAML failed to classify line '$lines->[0]'"; + } + + } elsif ( defined $indent->[-2] and $indent->[-1] == $indent->[-2] ) { + # This is probably a structure like the following... + # --- + # foo: + # - list + # bar: value + # + # ... so lets return and let the hash parser handle it + return 1; + + } else { + die \"CPAN::Meta::YAML failed to classify line '$lines->[0]'"; + } + } + + return 1; + } + + # Parse an array + sub _read_hash { + my ($self, $hash, $indent, $lines) = @_; + + while ( @$lines ) { + # Check for a new document + if ( $lines->[0] =~ /^(?:---|\.\.\.)/ ) { + while ( @$lines and $lines->[0] !~ /^---/ ) { + shift @$lines; + } + return 1; + } + + # Check the indent level + $lines->[0] =~ /^(\s*)/; + if ( length($1) < $indent->[-1] ) { + return 1; + } elsif ( length($1) > $indent->[-1] ) { + die \"CPAN::Meta::YAML found bad indenting in line '$lines->[0]'"; + } + + # Get the key + unless ( $lines->[0] =~ s/^\s*([^\'\" ][^\n]*?)\s*:(\s+(?:\#.*)?|$)// ) { + if ( $lines->[0] =~ /^\s*[?\'\"]/ ) { + die \"CPAN::Meta::YAML does not support a feature in line '$lines->[0]'"; + } + die \"CPAN::Meta::YAML failed to classify line '$lines->[0]'"; + } + my $key = $1; + + # Do we have a value? + if ( length $lines->[0] ) { + # Yes + $hash->{$key} = $self->_read_scalar( shift(@$lines), [ @$indent, undef ], $lines ); + } else { + # An indent + shift @$lines; + unless ( @$lines ) { + $hash->{$key} = undef; + return 1; + } + if ( $lines->[0] =~ /^(\s*)-/ ) { + $hash->{$key} = []; + $self->_read_array( $hash->{$key}, [ @$indent, length($1) ], $lines ); + } elsif ( $lines->[0] =~ /^(\s*)./ ) { + my $indent2 = length("$1"); + if ( $indent->[-1] >= $indent2 ) { + # Null hash entry + $hash->{$key} = undef; + } else { + $hash->{$key} = {}; + $self->_read_hash( $hash->{$key}, [ @$indent, length($1) ], $lines ); + } + } + } + } + + return 1; + } + + # Save an object to a file + sub write { + my $self = shift; + my $file = shift or return $self->_error('No file name provided'); + + # Write it to the file + open( CFG, '>' . $file ) or return $self->_error( + "Failed to open file '$file' for writing: $!" + ); + print CFG $self->write_string; + close CFG; + + return 1; + } + + # Save an object to a string + sub write_string { + my $self = shift; + return '' unless @$self; + + # Iterate over the documents + my $indent = 0; + my @lines = (); + foreach my $cursor ( @$self ) { + push @lines, '---'; + + # An empty document + if ( ! defined $cursor ) { + # Do nothing + + # A scalar document + } elsif ( ! ref $cursor ) { + $lines[-1] .= ' ' . $self->_write_scalar( $cursor, $indent ); + + # A list at the root + } elsif ( ref $cursor eq 'ARRAY' ) { + unless ( @$cursor ) { + $lines[-1] .= ' []'; + next; + } + push @lines, $self->_write_array( $cursor, $indent, {} ); + + # A hash at the root + } elsif ( ref $cursor eq 'HASH' ) { + unless ( %$cursor ) { + $lines[-1] .= ' {}'; + next; + } + push @lines, $self->_write_hash( $cursor, $indent, {} ); + + } else { + Carp::croak("Cannot serialize " . ref($cursor)); + } + } + + join '', map { "$_\n" } @lines; + } + + sub _write_scalar { + my $string = $_[1]; + return '~' unless defined $string; + return "''" unless length $string; + if ( $string =~ /[\x00-\x08\x0b-\x0d\x0e-\x1f\"\'\n]/ ) { + $string =~ s/\\/\\\\/g; + $string =~ s/"/\\"/g; + $string =~ s/\n/\\n/g; + $string =~ s/([\x00-\x1f])/\\$UNPRINTABLE[ord($1)]/g; + return qq|"$string"|; + } + if ( $string =~ /(?:^\W|\s)/ or $QUOTE{$string} ) { + return "'$string'"; + } + return $string; + } + + sub _write_array { + my ($self, $array, $indent, $seen) = @_; + if ( $seen->{refaddr($array)}++ ) { + die "CPAN::Meta::YAML does not support circular references"; + } + my @lines = (); + foreach my $el ( @$array ) { + my $line = (' ' x $indent) . '-'; + my $type = ref $el; + if ( ! $type ) { + $line .= ' ' . $self->_write_scalar( $el, $indent + 1 ); + push @lines, $line; + + } elsif ( $type eq 'ARRAY' ) { + if ( @$el ) { + push @lines, $line; + push @lines, $self->_write_array( $el, $indent + 1, $seen ); + } else { + $line .= ' []'; + push @lines, $line; + } + + } elsif ( $type eq 'HASH' ) { + if ( keys %$el ) { + push @lines, $line; + push @lines, $self->_write_hash( $el, $indent + 1, $seen ); + } else { + $line .= ' {}'; + push @lines, $line; + } + + } else { + die "CPAN::Meta::YAML does not support $type references"; + } + } + + @lines; + } + + sub _write_hash { + my ($self, $hash, $indent, $seen) = @_; + if ( $seen->{refaddr($hash)}++ ) { + die "CPAN::Meta::YAML does not support circular references"; + } + my @lines = (); + foreach my $name ( sort keys %$hash ) { + my $el = $hash->{$name}; + my $line = (' ' x $indent) . "$name:"; + my $type = ref $el; + if ( ! $type ) { + $line .= ' ' . $self->_write_scalar( $el, $indent + 1 ); + push @lines, $line; + + } elsif ( $type eq 'ARRAY' ) { + if ( @$el ) { + push @lines, $line; + push @lines, $self->_write_array( $el, $indent + 1, $seen ); + } else { + $line .= ' []'; + push @lines, $line; + } + + } elsif ( $type eq 'HASH' ) { + if ( keys %$el ) { + push @lines, $line; + push @lines, $self->_write_hash( $el, $indent + 1, $seen ); + } else { + $line .= ' {}'; + push @lines, $line; + } + + } else { + die "CPAN::Meta::YAML does not support $type references"; + } + } + + @lines; + } + + # Set error + sub _error { + $CPAN::Meta::YAML::errstr = $_[1]; + undef; + } + + # Retrieve error + sub errstr { + $CPAN::Meta::YAML::errstr; + } + + + + + + ##################################################################### + # YAML Compatibility + + sub Dump { + CPAN::Meta::YAML->new(@_)->write_string; + } + + sub Load { + my $self = CPAN::Meta::YAML->read_string(@_); + unless ( $self ) { + Carp::croak("Failed to load YAML document from string"); + } + if ( wantarray ) { + return @$self; + } else { + # To match YAML.pm, return the last document + return $self->[-1]; + } + } + + BEGIN { + *freeze = *Dump; + *thaw = *Load; + } + + sub DumpFile { + my $file = shift; + CPAN::Meta::YAML->new(@_)->write($file); + } + + sub LoadFile { + my $self = CPAN::Meta::YAML->read($_[0]); + unless ( $self ) { + Carp::croak("Failed to load YAML document from '" . ($_[0] || '') . "'"); + } + if ( wantarray ) { + return @$self; + } else { + # Return only the last document to match YAML.pm, + return $self->[-1]; + } + } + + + + + + ##################################################################### + # Use Scalar::Util if possible, otherwise emulate it + + BEGIN { + eval { + require Scalar::Util; + *refaddr = *Scalar::Util::refaddr; + }; + eval <<'END_PERL' if $@; + # Failed to load Scalar::Util + sub refaddr { + my $pkg = ref($_[0]) or return undef; + if ( !! UNIVERSAL::can($_[0], 'can') ) { + bless $_[0], 'Scalar::Util::Fake'; + } else { + $pkg = undef; + } + "$_[0]" =~ /0x(\w+)/; + my $i = do { local $^W; hex $1 }; + bless $_[0], $pkg if defined $pkg; + $i; + } + END_PERL + + } + + 1; + + + + + __END__ + + + # ABSTRACT: Read and write a subset of YAML for CPAN Meta files + + +CPAN_META_YAML + +$fatpacked{"HTTP/Tiny.pm"} = <<'HTTP_TINY'; + # vim: ts=4 sts=4 sw=4 et: + # + # This file is part of HTTP-Tiny + # + # This software is copyright (c) 2011 by Christian Hansen. + # + # This is free software; you can redistribute it and/or modify it under + # the same terms as the Perl 5 programming language system itself. + # + package HTTP::Tiny; + BEGIN { + $HTTP::Tiny::VERSION = '0.009'; + } + use strict; + use warnings; + # ABSTRACT: A small, simple, correct HTTP/1.1 client + + use Carp (); + + + my @attributes; + BEGIN { + @attributes = qw(agent default_headers max_redirect max_size proxy timeout); + no strict 'refs'; + for my $accessor ( @attributes ) { + *{$accessor} = sub { + @_ > 1 ? $_[0]->{$accessor} = $_[1] : $_[0]->{$accessor}; + }; + } + } + + sub new { + my($class, %args) = @_; + (my $agent = $class) =~ s{::}{-}g; + my $self = { + agent => $agent . "/" . ($class->VERSION || 0), + max_redirect => 5, + timeout => 60, + }; + for my $key ( @attributes ) { + $self->{$key} = $args{$key} if exists $args{$key} + } + return bless $self, $class; + } + + + sub get { + my ($self, $url, $args) = @_; + @_ == 2 || (@_ == 3 && ref $args eq 'HASH') + or Carp::croak(q/Usage: $http->get(URL, [HASHREF])/); + return $self->request('GET', $url, $args || {}); + } + + + sub mirror { + my ($self, $url, $file, $args) = @_; + @_ == 3 || (@_ == 4 && ref $args eq 'HASH') + or Carp::croak(q/Usage: $http->mirror(URL, FILE, [HASHREF])/); + if ( -e $file and my $mtime = (stat($file))[9] ) { + $args->{headers}{'if-modified-since'} ||= $self->_http_date($mtime); + } + my $tempfile = $file . int(rand(2**31)); + open my $fh, ">", $tempfile + or Carp::croak(qq/Error: Could not open temporary file $tempfile for downloading: $!/); + $args->{data_callback} = sub { print {$fh} $_[0] }; + my $response = $self->request('GET', $url, $args); + close $fh + or Carp::croak(qq/Error: Could not close temporary file $tempfile: $!/); + if ( $response->{success} ) { + rename $tempfile, $file + or Carp::croak "Error replacing $file with $tempfile: $!\n"; + my $lm = $response->{headers}{'last-modified'}; + if ( $lm and my $mtime = $self->_parse_http_date($lm) ) { + utime $mtime, $mtime, $file; + } + } + $response->{success} ||= $response->{status} eq '304'; + unlink $tempfile; + return $response; + } + + + my %idempotent = map { $_ => 1 } qw/GET HEAD PUT DELETE OPTIONS TRACE/; + + sub request { + my ($self, $method, $url, $args) = @_; + @_ == 3 || (@_ == 4 && ref $args eq 'HASH') + or Carp::croak(q/Usage: $http->request(METHOD, URL, [HASHREF])/); + $args ||= {}; # we keep some state in this during _request + + # RFC 2616 Section 8.1.4 mandates a single retry on broken socket + my $response; + for ( 0 .. 1 ) { + $response = eval { $self->_request($method, $url, $args) }; + last unless $@ && $idempotent{$method} + && $@ =~ m{^(?:Socket closed|Unexpected end)}; + } + + if (my $e = "$@") { + $response = { + success => q{}, + status => 599, + reason => 'Internal Exception', + content => $e, + headers => { + 'content-type' => 'text/plain', + 'content-length' => length $e, + } + }; + } + return $response; + } + + my %DefaultPort = ( + http => 80, + https => 443, + ); + + sub _request { + my ($self, $method, $url, $args) = @_; + + my ($scheme, $host, $port, $path_query) = $self->_split_url($url); + + my $request = { + method => $method, + scheme => $scheme, + host_port => ($port == $DefaultPort{$scheme} ? $host : "$host:$port"), + uri => $path_query, + headers => {}, + }; + + my $handle = HTTP::Tiny::Handle->new(timeout => $self->{timeout}); + + if ($self->{proxy}) { + $request->{uri} = "$scheme://$request->{host_port}$path_query"; + croak(qq/HTTPS via proxy is not supported/) + if $request->{scheme} eq 'https'; + $handle->connect(($self->_split_url($self->{proxy}))[0..2]); + } + else { + $handle->connect($scheme, $host, $port); + } + + $self->_prepare_headers_and_cb($request, $args); + $handle->write_request($request); + + my $response; + do { $response = $handle->read_response_header } + until (substr($response->{status},0,1) ne '1'); + + if ( my @redir_args = $self->_maybe_redirect($request, $response, $args) ) { + $handle->close; + return $self->_request(@redir_args, $args); + } + + if ($method eq 'HEAD' || $response->{status} =~ /^[23]04/) { + # response has no message body + } + else { + my $data_cb = $self->_prepare_data_cb($response, $args); + $handle->read_body($data_cb, $response); + } + + $handle->close; + $response->{success} = substr($response->{status},0,1) eq '2'; + return $response; + } + + sub _prepare_headers_and_cb { + my ($self, $request, $args) = @_; + + for ($self->{default_headers}, $args->{headers}) { + next unless defined; + while (my ($k, $v) = each %$_) { + $request->{headers}{lc $k} = $v; + } + } + $request->{headers}{'host'} = $request->{host_port}; + $request->{headers}{'connection'} = "close"; + $request->{headers}{'user-agent'} ||= $self->{agent}; + + if (defined $args->{content}) { + $request->{headers}{'content-type'} ||= "application/octet-stream"; + if (ref $args->{content} eq 'CODE') { + $request->{headers}{'transfer-encoding'} = 'chunked' + unless $request->{headers}{'content-length'} + || $request->{headers}{'transfer-encoding'}; + $request->{cb} = $args->{content}; + } + else { + my $content = $args->{content}; + if ( $] ge '5.008' ) { + utf8::downgrade($content, 1) + or Carp::croak(q/Wide character in request message body/); + } + $request->{headers}{'content-length'} = length $content + unless $request->{headers}{'content-length'} + || $request->{headers}{'transfer-encoding'}; + $request->{cb} = sub { substr $content, 0, length $content, '' }; + } + $request->{trailer_cb} = $args->{trailer_callback} + if ref $args->{trailer_callback} eq 'CODE'; + } + return; + } + + sub _prepare_data_cb { + my ($self, $response, $args) = @_; + my $data_cb = $args->{data_callback}; + $response->{content} = ''; + + if (!$data_cb || $response->{status} !~ /^2/) { + if (defined $self->{max_size}) { + $data_cb = sub { + $_[1]->{content} .= $_[0]; + die(qq/Size of response body exceeds the maximum allowed of $self->{max_size}\n/) + if length $_[1]->{content} > $self->{max_size}; + }; + } + else { + $data_cb = sub { $_[1]->{content} .= $_[0] }; + } + } + return $data_cb; + } + + sub _maybe_redirect { + my ($self, $request, $response, $args) = @_; + my $headers = $response->{headers}; + my ($status, $method) = ($response->{status}, $request->{method}); + if (($status eq '303' or ($status =~ /^30[127]/ && $method =~ /^GET|HEAD$/)) + and $headers->{location} + and ++$args->{redirects} <= $self->{max_redirect} + ) { + my $location = ($headers->{location} =~ /^\//) + ? "$request->{scheme}://$request->{host_port}$headers->{location}" + : $headers->{location} ; + return (($status eq '303' ? 'GET' : $method), $location); + } + return; + } + + sub _split_url { + my $url = pop; + + # URI regex adapted from the URI module + my ($scheme, $authority, $path_query) = $url =~ m<\A([^:/?#]+)://([^/?#]*)([^#]*)> + or Carp::croak(qq/Cannot parse URL: '$url'/); + + $scheme = lc $scheme; + $path_query = "/$path_query" unless $path_query =~ m<\A/>; + + my $host = (length($authority)) ? lc $authority : 'localhost'; + $host =~ s/\A[^@]*@//; # userinfo + my $port = do { + $host =~ s/:([0-9]*)\z// && length $1 + ? $1 + : ($scheme eq 'http' ? 80 : $scheme eq 'https' ? 443 : undef); + }; + + return ($scheme, $host, $port, $path_query); + } + + # Date conversions adapted from HTTP::Date + my $DoW = "Sun|Mon|Tue|Wed|Thu|Fri|Sat"; + my $MoY = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec"; + sub _http_date { + my ($sec, $min, $hour, $mday, $mon, $year, $wday) = gmtime($_[1]); + return sprintf("%s, %02d %s %04d %02d:%02d:%02d GMT", + substr($DoW,$wday*4,3), + $mday, substr($MoY,$mon*4,3), $year+1900, + $hour, $min, $sec + ); + } + + sub _parse_http_date { + my ($self, $str) = @_; + require Time::Local; + my @tl_parts; + if ($str =~ /^[SMTWF][a-z]+, +(\d{1,2}) ($MoY) +(\d\d\d\d) +(\d\d):(\d\d):(\d\d) +GMT$/) { + @tl_parts = ($6, $5, $4, $1, (index($MoY,$2)/4), $3); + } + elsif ($str =~ /^[SMTWF][a-z]+, +(\d\d)-($MoY)-(\d{2,4}) +(\d\d):(\d\d):(\d\d) +GMT$/ ) { + @tl_parts = ($6, $5, $4, $1, (index($MoY,$2)/4), $3); + } + elsif ($str =~ /^[SMTWF][a-z]+ +($MoY) +(\d{1,2}) +(\d\d):(\d\d):(\d\d) +(?:[^0-9]+ +)?(\d\d\d\d)$/ ) { + @tl_parts = ($5, $4, $3, $2, (index($MoY,$1)/4), $6); + } + return eval { + my $t = @tl_parts ? Time::Local::timegm(@tl_parts) : -1; + $t < 0 ? undef : $t; + }; + } + + package + HTTP::Tiny::Handle; # hide from PAUSE/indexers + use strict; + use warnings; + + use Carp qw[croak]; + use Errno qw[EINTR EPIPE]; + use IO::Socket qw[SOCK_STREAM]; + + sub BUFSIZE () { 32768 } + + my $Printable = sub { + local $_ = shift; + s/\r/\\r/g; + s/\n/\\n/g; + s/\t/\\t/g; + s/([^\x20-\x7E])/sprintf('\\x%.2X', ord($1))/ge; + $_; + }; + + my $Token = qr/[\x21\x23-\x27\x2A\x2B\x2D\x2E\x30-\x39\x41-\x5A\x5E-\x7A\x7C\x7E]/; + + sub new { + my ($class, %args) = @_; + return bless { + rbuf => '', + timeout => 60, + max_line_size => 16384, + max_header_lines => 64, + %args + }, $class; + } + + my $ssl_verify_args = { + check_cn => "when_only", + wildcards_in_alt => "anywhere", + wildcards_in_cn => "anywhere" + }; + + sub connect { + @_ == 4 || croak(q/Usage: $handle->connect(scheme, host, port)/); + my ($self, $scheme, $host, $port) = @_; + + if ( $scheme eq 'https' ) { + eval "require IO::Socket::SSL" + unless exists $INC{'IO/Socket/SSL.pm'}; + croak(qq/IO::Socket::SSL must be installed for https support\n/) + unless $INC{'IO/Socket/SSL.pm'}; + } + elsif ( $scheme ne 'http' ) { + croak(qq/Unsupported URL scheme '$scheme'/); + } + + $self->{fh} = 'IO::Socket::INET'->new( + PeerHost => $host, + PeerPort => $port, + Proto => 'tcp', + Type => SOCK_STREAM, + Timeout => $self->{timeout} + ) or croak(qq/Could not connect to '$host:$port': $@/); + + binmode($self->{fh}) + or croak(qq/Could not binmode() socket: '$!'/); + + if ( $scheme eq 'https') { + IO::Socket::SSL->start_SSL($self->{fh}); + ref($self->{fh}) eq 'IO::Socket::SSL' + or die(qq/SSL connection failed for $host\n/); + $self->{fh}->verify_hostname( $host, $ssl_verify_args ) + or die(qq/SSL certificate not valid for $host\n/); + } + + $self->{host} = $host; + $self->{port} = $port; + + return $self; + } + + sub close { + @_ == 1 || croak(q/Usage: $handle->close()/); + my ($self) = @_; + CORE::close($self->{fh}) + or croak(qq/Could not close socket: '$!'/); + } + + sub write { + @_ == 2 || croak(q/Usage: $handle->write(buf)/); + my ($self, $buf) = @_; + + if ( $] ge '5.008' ) { + utf8::downgrade($buf, 1) + or croak(q/Wide character in write()/); + } + + my $len = length $buf; + my $off = 0; + + local $SIG{PIPE} = 'IGNORE'; + + while () { + $self->can_write + or croak(q/Timed out while waiting for socket to become ready for writing/); + my $r = syswrite($self->{fh}, $buf, $len, $off); + if (defined $r) { + $len -= $r; + $off += $r; + last unless $len > 0; + } + elsif ($! == EPIPE) { + croak(qq/Socket closed by remote server: $!/); + } + elsif ($! != EINTR) { + croak(qq/Could not write to socket: '$!'/); + } + } + return $off; + } + + sub read { + @_ == 2 || @_ == 3 || croak(q/Usage: $handle->read(len [, allow_partial])/); + my ($self, $len, $allow_partial) = @_; + + my $buf = ''; + my $got = length $self->{rbuf}; + + if ($got) { + my $take = ($got < $len) ? $got : $len; + $buf = substr($self->{rbuf}, 0, $take, ''); + $len -= $take; + } + + while ($len > 0) { + $self->can_read + or croak(q/Timed out while waiting for socket to become ready for reading/); + my $r = sysread($self->{fh}, $buf, $len, length $buf); + if (defined $r) { + last unless $r; + $len -= $r; + } + elsif ($! != EINTR) { + croak(qq/Could not read from socket: '$!'/); + } + } + if ($len && !$allow_partial) { + croak(q/Unexpected end of stream/); + } + return $buf; + } + + sub readline { + @_ == 1 || croak(q/Usage: $handle->readline()/); + my ($self) = @_; + + while () { + if ($self->{rbuf} =~ s/\A ([^\x0D\x0A]* \x0D?\x0A)//x) { + return $1; + } + if (length $self->{rbuf} >= $self->{max_line_size}) { + croak(qq/Line size exceeds the maximum allowed size of $self->{max_line_size}/); + } + $self->can_read + or croak(q/Timed out while waiting for socket to become ready for reading/); + my $r = sysread($self->{fh}, $self->{rbuf}, BUFSIZE, length $self->{rbuf}); + if (defined $r) { + last unless $r; + } + elsif ($! != EINTR) { + croak(qq/Could not read from socket: '$!'/); + } + } + croak(q/Unexpected end of stream while looking for line/); + } + + sub read_header_lines { + @_ == 1 || @_ == 2 || croak(q/Usage: $handle->read_header_lines([headers])/); + my ($self, $headers) = @_; + $headers ||= {}; + my $lines = 0; + my $val; + + while () { + my $line = $self->readline; + + if (++$lines >= $self->{max_header_lines}) { + croak(qq/Header lines exceeds maximum number allowed of $self->{max_header_lines}/); + } + elsif ($line =~ /\A ([^\x00-\x1F\x7F:]+) : [\x09\x20]* ([^\x0D\x0A]*)/x) { + my ($field_name) = lc $1; + if (exists $headers->{$field_name}) { + for ($headers->{$field_name}) { + $_ = [$_] unless ref $_ eq "ARRAY"; + push @$_, $2; + $val = \$_->[-1]; + } + } + else { + $val = \($headers->{$field_name} = $2); + } + } + elsif ($line =~ /\A [\x09\x20]+ ([^\x0D\x0A]*)/x) { + $val + or croak(q/Unexpected header continuation line/); + next unless length $1; + $$val .= ' ' if length $$val; + $$val .= $1; + } + elsif ($line =~ /\A \x0D?\x0A \z/x) { + last; + } + else { + croak(q/Malformed header line: / . $Printable->($line)); + } + } + return $headers; + } + + sub write_request { + @_ == 2 || croak(q/Usage: $handle->write_request(request)/); + my($self, $request) = @_; + $self->write_request_header(@{$request}{qw/method uri headers/}); + $self->write_body($request) if $request->{cb}; + return; + } + + my %HeaderCase = ( + 'content-md5' => 'Content-MD5', + 'etag' => 'ETag', + 'te' => 'TE', + 'www-authenticate' => 'WWW-Authenticate', + 'x-xss-protection' => 'X-XSS-Protection', + ); + + sub write_header_lines { + (@_ == 2 && ref $_[1] eq 'HASH') || croak(q/Usage: $handle->write_header_lines(headers)/); + my($self, $headers) = @_; + + my $buf = ''; + while (my ($k, $v) = each %$headers) { + my $field_name = lc $k; + if (exists $HeaderCase{$field_name}) { + $field_name = $HeaderCase{$field_name}; + } + else { + $field_name =~ /\A $Token+ \z/xo + or croak(q/Invalid HTTP header field name: / . $Printable->($field_name)); + $field_name =~ s/\b(\w)/\u$1/g; + $HeaderCase{lc $field_name} = $field_name; + } + for (ref $v eq 'ARRAY' ? @$v : $v) { + /[^\x0D\x0A]/ + or croak(qq/Invalid HTTP header field value ($field_name): / . $Printable->($_)); + $buf .= "$field_name: $_\x0D\x0A"; + } + } + $buf .= "\x0D\x0A"; + return $self->write($buf); + } + + sub read_body { + @_ == 3 || croak(q/Usage: $handle->read_body(callback, response)/); + my ($self, $cb, $response) = @_; + my $te = $response->{headers}{'transfer-encoding'} || ''; + if ( grep { /chunked/i } ( ref $te eq 'ARRAY' ? @$te : $te ) ) { + $self->read_chunked_body($cb, $response); + } + else { + $self->read_content_body($cb, $response); + } + return; + } + + sub write_body { + @_ == 2 || croak(q/Usage: $handle->write_body(request)/); + my ($self, $request) = @_; + if ($request->{headers}{'content-length'}) { + return $self->write_content_body($request); + } + else { + return $self->write_chunked_body($request); + } + } + + sub read_content_body { + @_ == 3 || @_ == 4 || croak(q/Usage: $handle->read_content_body(callback, response, [read_length])/); + my ($self, $cb, $response, $content_length) = @_; + $content_length ||= $response->{headers}{'content-length'}; + + if ( $content_length ) { + my $len = $content_length; + while ($len > 0) { + my $read = ($len > BUFSIZE) ? BUFSIZE : $len; + $cb->($self->read($read, 0), $response); + $len -= $read; + } + } + else { + my $chunk; + $cb->($chunk, $response) while length( $chunk = $self->read(BUFSIZE, 1) ); + } + + return; + } + + sub write_content_body { + @_ == 2 || croak(q/Usage: $handle->write_content_body(request)/); + my ($self, $request) = @_; + + my ($len, $content_length) = (0, $request->{headers}{'content-length'}); + while () { + my $data = $request->{cb}->(); + + defined $data && length $data + or last; + + if ( $] ge '5.008' ) { + utf8::downgrade($data, 1) + or croak(q/Wide character in write_content()/); + } + + $len += $self->write($data); + } + + $len == $content_length + or croak(qq/Content-Length missmatch (got: $len expected: $content_length)/); + + return $len; + } + + sub read_chunked_body { + @_ == 3 || croak(q/Usage: $handle->read_chunked_body(callback, $response)/); + my ($self, $cb, $response) = @_; + + while () { + my $head = $self->readline; + + $head =~ /\A ([A-Fa-f0-9]+)/x + or croak(q/Malformed chunk head: / . $Printable->($head)); + + my $len = hex($1) + or last; + + $self->read_content_body($cb, $response, $len); + + $self->read(2) eq "\x0D\x0A" + or croak(q/Malformed chunk: missing CRLF after chunk data/); + } + $self->read_header_lines($response->{headers}); + return; + } + + sub write_chunked_body { + @_ == 2 || croak(q/Usage: $handle->write_chunked_body(request)/); + my ($self, $request) = @_; + + my $len = 0; + while () { + my $data = $request->{cb}->(); + + defined $data && length $data + or last; + + if ( $] ge '5.008' ) { + utf8::downgrade($data, 1) + or croak(q/Wide character in write_chunked_body()/); + } + + $len += length $data; + + my $chunk = sprintf '%X', length $data; + $chunk .= "\x0D\x0A"; + $chunk .= $data; + $chunk .= "\x0D\x0A"; + + $self->write($chunk); + } + $self->write("0\x0D\x0A"); + $self->write_header_lines($request->{trailer_cb}->()) + if ref $request->{trailer_cb} eq 'CODE'; + return $len; + } + + sub read_response_header { + @_ == 1 || croak(q/Usage: $handle->read_response_header()/); + my ($self) = @_; + + my $line = $self->readline; + + $line =~ /\A (HTTP\/(0*\d+\.0*\d+)) [\x09\x20]+ ([0-9]{3}) [\x09\x20]+ ([^\x0D\x0A]*) \x0D?\x0A/x + or croak(q/Malformed Status-Line: / . $Printable->($line)); + + my ($protocol, $version, $status, $reason) = ($1, $2, $3, $4); + + croak (qq/Unsupported HTTP protocol: $protocol/) + unless $version =~ /0*1\.0*[01]/; + + return { + status => $status, + reason => $reason, + headers => $self->read_header_lines, + protocol => $protocol, + }; + } + + sub write_request_header { + @_ == 4 || croak(q/Usage: $handle->write_request_header(method, request_uri, headers)/); + my ($self, $method, $request_uri, $headers) = @_; + + return $self->write("$method $request_uri HTTP/1.1\x0D\x0A") + + $self->write_header_lines($headers); + } + + sub _do_timeout { + my ($self, $type, $timeout) = @_; + $timeout = $self->{timeout} + unless defined $timeout && $timeout >= 0; + + my $fd = fileno $self->{fh}; + defined $fd && $fd >= 0 + or croak(q/select(2): 'Bad file descriptor'/); + + my $initial = time; + my $pending = $timeout; + my $nfound; + + vec(my $fdset = '', $fd, 1) = 1; + + while () { + $nfound = ($type eq 'read') + ? select($fdset, undef, undef, $pending) + : select(undef, $fdset, undef, $pending) ; + if ($nfound == -1) { + $! == EINTR + or croak(qq/select(2): '$!'/); + redo if !$timeout || ($pending = $timeout - (time - $initial)) > 0; + $nfound = 0; + } + last; + } + $! = 0; + return $nfound; + } + + sub can_read { + @_ == 1 || @_ == 2 || croak(q/Usage: $handle->can_read([timeout])/); + my $self = shift; + return $self->_do_timeout('read', @_) + } + + sub can_write { + @_ == 1 || @_ == 2 || croak(q/Usage: $handle->can_write([timeout])/); + my $self = shift; + return $self->_do_timeout('write', @_) + } + + 1; + + + + __END__ + =pod + +HTTP_TINY + +$fatpacked{"JSON/PP.pm"} = <<'JSON_PP'; + package JSON::PP; + + # JSON-2.0 + + use 5.005; + use strict; + use base qw(Exporter); + use overload (); + + use Carp (); + use B (); + #use Devel::Peek; + + $JSON::PP::VERSION = '2.27200'; + + @JSON::PP::EXPORT = qw(encode_json decode_json from_json to_json); + + # instead of hash-access, i tried index-access for speed. + # but this method is not faster than what i expected. so it will be changed. + + use constant P_ASCII => 0; + use constant P_LATIN1 => 1; + use constant P_UTF8 => 2; + use constant P_INDENT => 3; + use constant P_CANONICAL => 4; + use constant P_SPACE_BEFORE => 5; + use constant P_SPACE_AFTER => 6; + use constant P_ALLOW_NONREF => 7; + use constant P_SHRINK => 8; + use constant P_ALLOW_BLESSED => 9; + use constant P_CONVERT_BLESSED => 10; + use constant P_RELAXED => 11; + + use constant P_LOOSE => 12; + use constant P_ALLOW_BIGNUM => 13; + use constant P_ALLOW_BAREKEY => 14; + use constant P_ALLOW_SINGLEQUOTE => 15; + use constant P_ESCAPE_SLASH => 16; + use constant P_AS_NONBLESSED => 17; + + use constant P_ALLOW_UNKNOWN => 18; + + use constant OLD_PERL => $] < 5.008 ? 1 : 0; + + BEGIN { + my @xs_compati_bit_properties = qw( + latin1 ascii utf8 indent canonical space_before space_after allow_nonref shrink + allow_blessed convert_blessed relaxed allow_unknown + ); + my @pp_bit_properties = qw( + allow_singlequote allow_bignum loose + allow_barekey escape_slash as_nonblessed + ); + + # Perl version check, Unicode handling is enable? + # Helper module sets @JSON::PP::_properties. + if ($] < 5.008 ) { + my $helper = $] >= 5.006 ? 'JSON::PP::Compat5006' : 'JSON::PP::Compat5005'; + eval qq| require $helper |; + if ($@) { Carp::croak $@; } + } + + for my $name (@xs_compati_bit_properties, @pp_bit_properties) { + my $flag_name = 'P_' . uc($name); + + eval qq/ + sub $name { + my \$enable = defined \$_[1] ? \$_[1] : 1; + + if (\$enable) { + \$_[0]->{PROPS}->[$flag_name] = 1; + } + else { + \$_[0]->{PROPS}->[$flag_name] = 0; + } + + \$_[0]; + } + + sub get_$name { + \$_[0]->{PROPS}->[$flag_name] ? 1 : ''; + } + /; + } + + } + + + + # Functions + + my %encode_allow_method + = map {($_ => 1)} qw/utf8 pretty allow_nonref latin1 self_encode escape_slash + allow_blessed convert_blessed indent indent_length allow_bignum + as_nonblessed + /; + my %decode_allow_method + = map {($_ => 1)} qw/utf8 allow_nonref loose allow_singlequote allow_bignum + allow_barekey max_size relaxed/; + + + my $JSON; # cache + + sub encode_json ($) { # encode + ($JSON ||= __PACKAGE__->new->utf8)->encode(@_); + } + + + sub decode_json { # decode + ($JSON ||= __PACKAGE__->new->utf8)->decode(@_); + } + + # Obsoleted + + sub to_json($) { + Carp::croak ("JSON::PP::to_json has been renamed to encode_json."); + } + + + sub from_json($) { + Carp::croak ("JSON::PP::from_json has been renamed to decode_json."); + } + + + # Methods + + sub new { + my $class = shift; + my $self = { + max_depth => 512, + max_size => 0, + indent => 0, + FLAGS => 0, + fallback => sub { encode_error('Invalid value. JSON can only reference.') }, + indent_length => 3, + }; + + bless $self, $class; + } + + + sub encode { + return $_[0]->PP_encode_json($_[1]); + } + + + sub decode { + return $_[0]->PP_decode_json($_[1], 0x00000000); + } + + + sub decode_prefix { + return $_[0]->PP_decode_json($_[1], 0x00000001); + } + + + # accessor + + + # pretty printing + + sub pretty { + my ($self, $v) = @_; + my $enable = defined $v ? $v : 1; + + if ($enable) { # indent_length(3) for JSON::XS compatibility + $self->indent(1)->indent_length(3)->space_before(1)->space_after(1); + } + else { + $self->indent(0)->space_before(0)->space_after(0); + } + + $self; + } + + # etc + + sub max_depth { + my $max = defined $_[1] ? $_[1] : 0x80000000; + $_[0]->{max_depth} = $max; + $_[0]; + } + + + sub get_max_depth { $_[0]->{max_depth}; } + + + sub max_size { + my $max = defined $_[1] ? $_[1] : 0; + $_[0]->{max_size} = $max; + $_[0]; + } + + + sub get_max_size { $_[0]->{max_size}; } + + + sub filter_json_object { + $_[0]->{cb_object} = defined $_[1] ? $_[1] : 0; + $_[0]->{F_HOOK} = ($_[0]->{cb_object} or $_[0]->{cb_sk_object}) ? 1 : 0; + $_[0]; + } + + sub filter_json_single_key_object { + if (@_ > 1) { + $_[0]->{cb_sk_object}->{$_[1]} = $_[2]; + } + $_[0]->{F_HOOK} = ($_[0]->{cb_object} or $_[0]->{cb_sk_object}) ? 1 : 0; + $_[0]; + } + + sub indent_length { + if (!defined $_[1] or $_[1] > 15 or $_[1] < 0) { + Carp::carp "The acceptable range of indent_length() is 0 to 15."; + } + else { + $_[0]->{indent_length} = $_[1]; + } + $_[0]; + } + + sub get_indent_length { + $_[0]->{indent_length}; + } + + sub sort_by { + $_[0]->{sort_by} = defined $_[1] ? $_[1] : 1; + $_[0]; + } + + sub allow_bigint { + Carp::carp("allow_bigint() is obsoleted. use allow_bignum() insted."); + } + + ############################### + + ### + ### Perl => JSON + ### + + + { # Convert + + my $max_depth; + my $indent; + my $ascii; + my $latin1; + my $utf8; + my $space_before; + my $space_after; + my $canonical; + my $allow_blessed; + my $convert_blessed; + + my $indent_length; + my $escape_slash; + my $bignum; + my $as_nonblessed; + + my $depth; + my $indent_count; + my $keysort; + + + sub PP_encode_json { + my $self = shift; + my $obj = shift; + + $indent_count = 0; + $depth = 0; + + my $idx = $self->{PROPS}; + + ($ascii, $latin1, $utf8, $indent, $canonical, $space_before, $space_after, $allow_blessed, + $convert_blessed, $escape_slash, $bignum, $as_nonblessed) + = @{$idx}[P_ASCII .. P_SPACE_AFTER, P_ALLOW_BLESSED, P_CONVERT_BLESSED, + P_ESCAPE_SLASH, P_ALLOW_BIGNUM, P_AS_NONBLESSED]; + + ($max_depth, $indent_length) = @{$self}{qw/max_depth indent_length/}; + + $keysort = $canonical ? sub { $a cmp $b } : undef; + + if ($self->{sort_by}) { + $keysort = ref($self->{sort_by}) eq 'CODE' ? $self->{sort_by} + : $self->{sort_by} =~ /\D+/ ? $self->{sort_by} + : sub { $a cmp $b }; + } + + encode_error("hash- or arrayref expected (not a simple scalar, use allow_nonref to allow this)") + if(!ref $obj and !$idx->[ P_ALLOW_NONREF ]); + + my $str = $self->object_to_json($obj); + + $str .= "\n" if ( $indent ); # JSON::XS 2.26 compatible + + unless ($ascii or $latin1 or $utf8) { + utf8::upgrade($str); + } + + if ($idx->[ P_SHRINK ]) { + utf8::downgrade($str, 1); + } + + return $str; + } + + + sub object_to_json { + my ($self, $obj) = @_; + my $type = ref($obj); + + if($type eq 'HASH'){ + return $self->hash_to_json($obj); + } + elsif($type eq 'ARRAY'){ + return $self->array_to_json($obj); + } + elsif ($type) { # blessed object? + if (blessed($obj)) { + + return $self->value_to_json($obj) if ( $obj->isa('JSON::PP::Boolean') ); + + if ( $convert_blessed and $obj->can('TO_JSON') ) { + my $result = $obj->TO_JSON(); + if ( defined $result and ref( $result ) ) { + if ( refaddr( $obj ) eq refaddr( $result ) ) { + encode_error( sprintf( + "%s::TO_JSON method returned same object as was passed instead of a new one", + ref $obj + ) ); + } + } + + return $self->object_to_json( $result ); + } + + return "$obj" if ( $bignum and _is_bignum($obj) ); + return $self->blessed_to_json($obj) if ($allow_blessed and $as_nonblessed); # will be removed. + + encode_error( sprintf("encountered object '%s', but neither allow_blessed " + . "nor convert_blessed settings are enabled", $obj) + ) unless ($allow_blessed); + + return 'null'; + } + else { + return $self->value_to_json($obj); + } + } + else{ + return $self->value_to_json($obj); + } + } + + + sub hash_to_json { + my ($self, $obj) = @_; + my @res; + + encode_error("json text or perl structure exceeds maximum nesting level (max_depth set too low?)") + if (++$depth > $max_depth); + + my ($pre, $post) = $indent ? $self->_up_indent() : ('', ''); + my $del = ($space_before ? ' ' : '') . ':' . ($space_after ? ' ' : ''); + + for my $k ( _sort( $obj ) ) { + if ( OLD_PERL ) { utf8::decode($k) } # key for Perl 5.6 / be optimized + push @res, string_to_json( $self, $k ) + . $del + . ( $self->object_to_json( $obj->{$k} ) || $self->value_to_json( $obj->{$k} ) ); + } + + --$depth; + $self->_down_indent() if ($indent); + + return '{' . ( @res ? $pre : '' ) . ( @res ? join( ",$pre", @res ) . $post : '' ) . '}'; + } + + + sub array_to_json { + my ($self, $obj) = @_; + my @res; + + encode_error("json text or perl structure exceeds maximum nesting level (max_depth set too low?)") + if (++$depth > $max_depth); + + my ($pre, $post) = $indent ? $self->_up_indent() : ('', ''); + + for my $v (@$obj){ + push @res, $self->object_to_json($v) || $self->value_to_json($v); + } + + --$depth; + $self->_down_indent() if ($indent); + + return '[' . ( @res ? $pre : '' ) . ( @res ? join( ",$pre", @res ) . $post : '' ) . ']'; + } + + + sub value_to_json { + my ($self, $value) = @_; + + return 'null' if(!defined $value); + + my $b_obj = B::svref_2object(\$value); # for round trip problem + my $flags = $b_obj->FLAGS; + + return $value # as is + if $flags & ( B::SVp_IOK | B::SVp_NOK ) and !( $flags & B::SVp_POK ); # SvTYPE is IV or NV? + + my $type = ref($value); + + if(!$type){ + return string_to_json($self, $value); + } + elsif( blessed($value) and $value->isa('JSON::PP::Boolean') ){ + return $$value == 1 ? 'true' : 'false'; + } + elsif ($type) { + if ((overload::StrVal($value) =~ /=(\w+)/)[0]) { + return $self->value_to_json("$value"); + } + + if ($type eq 'SCALAR' and defined $$value) { + return $$value eq '1' ? 'true' + : $$value eq '0' ? 'false' + : $self->{PROPS}->[ P_ALLOW_UNKNOWN ] ? 'null' + : encode_error("cannot encode reference to scalar"); + } + + if ( $self->{PROPS}->[ P_ALLOW_UNKNOWN ] ) { + return 'null'; + } + else { + if ( $type eq 'SCALAR' or $type eq 'REF' ) { + encode_error("cannot encode reference to scalar"); + } + else { + encode_error("encountered $value, but JSON can only represent references to arrays or hashes"); + } + } + + } + else { + return $self->{fallback}->($value) + if ($self->{fallback} and ref($self->{fallback}) eq 'CODE'); + return 'null'; + } + + } + + + my %esc = ( + "\n" => '\n', + "\r" => '\r', + "\t" => '\t', + "\f" => '\f', + "\b" => '\b', + "\"" => '\"', + "\\" => '\\\\', + "\'" => '\\\'', + ); + + + sub string_to_json { + my ($self, $arg) = @_; + + $arg =~ s/([\x22\x5c\n\r\t\f\b])/$esc{$1}/g; + $arg =~ s/\//\\\//g if ($escape_slash); + $arg =~ s/([\x00-\x08\x0b\x0e-\x1f])/'\\u00' . unpack('H2', $1)/eg; + + if ($ascii) { + $arg = JSON_PP_encode_ascii($arg); + } + + if ($latin1) { + $arg = JSON_PP_encode_latin1($arg); + } + + if ($utf8) { + utf8::encode($arg); + } + + return '"' . $arg . '"'; + } + + + sub blessed_to_json { + my $reftype = reftype($_[1]) || ''; + if ($reftype eq 'HASH') { + return $_[0]->hash_to_json($_[1]); + } + elsif ($reftype eq 'ARRAY') { + return $_[0]->array_to_json($_[1]); + } + else { + return 'null'; + } + } + + + sub encode_error { + my $error = shift; + Carp::croak "$error"; + } + + + sub _sort { + defined $keysort ? (sort $keysort (keys %{$_[0]})) : keys %{$_[0]}; + } + + + sub _up_indent { + my $self = shift; + my $space = ' ' x $indent_length; + + my ($pre,$post) = ('',''); + + $post = "\n" . $space x $indent_count; + + $indent_count++; + + $pre = "\n" . $space x $indent_count; + + return ($pre,$post); + } + + + sub _down_indent { $indent_count--; } + + + sub PP_encode_box { + { + depth => $depth, + indent_count => $indent_count, + }; + } + + } # Convert + + + sub _encode_ascii { + join('', + map { + $_ <= 127 ? + chr($_) : + $_ <= 65535 ? + sprintf('\u%04x', $_) : sprintf('\u%x\u%x', _encode_surrogates($_)); + } unpack('U*', $_[0]) + ); + } + + + sub _encode_latin1 { + join('', + map { + $_ <= 255 ? + chr($_) : + $_ <= 65535 ? + sprintf('\u%04x', $_) : sprintf('\u%x\u%x', _encode_surrogates($_)); + } unpack('U*', $_[0]) + ); + } + + + sub _encode_surrogates { # from perlunicode + my $uni = $_[0] - 0x10000; + return ($uni / 0x400 + 0xD800, $uni % 0x400 + 0xDC00); + } + + + sub _is_bignum { + $_[0]->isa('Math::BigInt') or $_[0]->isa('Math::BigFloat'); + } + + + + # + # JSON => Perl + # + + my $max_intsize; + + BEGIN { + my $checkint = 1111; + for my $d (5..64) { + $checkint .= 1; + my $int = eval qq| $checkint |; + if ($int =~ /[eE]/) { + $max_intsize = $d - 1; + last; + } + } + } + + { # PARSE + + my %escapes = ( # by Jeremy Muhlich + b => "\x8", + t => "\x9", + n => "\xA", + f => "\xC", + r => "\xD", + '\\' => '\\', + '"' => '"', + '/' => '/', + ); + + my $text; # json data + my $at; # offset + my $ch; # 1chracter + my $len; # text length (changed according to UTF8 or NON UTF8) + # INTERNAL + my $depth; # nest counter + my $encoding; # json text encoding + my $is_valid_utf8; # temp variable + my $utf8_len; # utf8 byte length + # FLAGS + my $utf8; # must be utf8 + my $max_depth; # max nest nubmer of objects and arrays + my $max_size; + my $relaxed; + my $cb_object; + my $cb_sk_object; + + my $F_HOOK; + + my $allow_bigint; # using Math::BigInt + my $singlequote; # loosely quoting + my $loose; # + my $allow_barekey; # bareKey + + # $opt flag + # 0x00000001 .... decode_prefix + # 0x10000000 .... incr_parse + + sub PP_decode_json { + my ($self, $opt); # $opt is an effective flag during this decode_json. + + ($self, $text, $opt) = @_; + + ($at, $ch, $depth) = (0, '', 0); + + if ( !defined $text or ref $text ) { + decode_error("malformed JSON string, neither array, object, number, string or atom"); + } + + my $idx = $self->{PROPS}; + + ($utf8, $relaxed, $loose, $allow_bigint, $allow_barekey, $singlequote) + = @{$idx}[P_UTF8, P_RELAXED, P_LOOSE .. P_ALLOW_SINGLEQUOTE]; + + if ( $utf8 ) { + utf8::downgrade( $text, 1 ) or Carp::croak("Wide character in subroutine entry"); + } + else { + utf8::upgrade( $text ); + } + + $len = length $text; + + ($max_depth, $max_size, $cb_object, $cb_sk_object, $F_HOOK) + = @{$self}{qw/max_depth max_size cb_object cb_sk_object F_HOOK/}; + + if ($max_size > 1) { + use bytes; + my $bytes = length $text; + decode_error( + sprintf("attempted decode of JSON text of %s bytes size, but max_size is set to %s" + , $bytes, $max_size), 1 + ) if ($bytes > $max_size); + } + + # Currently no effect + # should use regexp + my @octets = unpack('C4', $text); + $encoding = ( $octets[0] and $octets[1]) ? 'UTF-8' + : (!$octets[0] and $octets[1]) ? 'UTF-16BE' + : (!$octets[0] and !$octets[1]) ? 'UTF-32BE' + : ( $octets[2] ) ? 'UTF-16LE' + : (!$octets[2] ) ? 'UTF-32LE' + : 'unknown'; + + white(); # remove head white space + + my $valid_start = defined $ch; # Is there a first character for JSON structure? + + my $result = value(); + + return undef if ( !$result && ( $opt & 0x10000000 ) ); # for incr_parse + + decode_error("malformed JSON string, neither array, object, number, string or atom") unless $valid_start; + + if ( !$idx->[ P_ALLOW_NONREF ] and !ref $result ) { + decode_error( + 'JSON text must be an object or array (but found number, string, true, false or null,' + . ' use allow_nonref to allow this)', 1); + } + + Carp::croak('something wrong.') if $len < $at; # we won't arrive here. + + my $consumed = defined $ch ? $at - 1 : $at; # consumed JSON text length + + white(); # remove tail white space + + if ( $ch ) { + return ( $result, $consumed ) if ($opt & 0x00000001); # all right if decode_prefix + decode_error("garbage after JSON object"); + } + + ( $opt & 0x00000001 ) ? ( $result, $consumed ) : $result; + } + + + sub next_chr { + return $ch = undef if($at >= $len); + $ch = substr($text, $at++, 1); + } + + + sub value { + white(); + return if(!defined $ch); + return object() if($ch eq '{'); + return array() if($ch eq '['); + return string() if($ch eq '"' or ($singlequote and $ch eq "'")); + return number() if($ch =~ /[0-9]/ or $ch eq '-'); + return word(); + } + + sub string { + my ($i, $s, $t, $u); + my $utf16; + my $is_utf8; + + ($is_valid_utf8, $utf8_len) = ('', 0); + + $s = ''; # basically UTF8 flag on + + if($ch eq '"' or ($singlequote and $ch eq "'")){ + my $boundChar = $ch; + + OUTER: while( defined(next_chr()) ){ + + if($ch eq $boundChar){ + next_chr(); + + if ($utf16) { + decode_error("missing low surrogate character in surrogate pair"); + } + + utf8::decode($s) if($is_utf8); + + return $s; + } + elsif($ch eq '\\'){ + next_chr(); + if(exists $escapes{$ch}){ + $s .= $escapes{$ch}; + } + elsif($ch eq 'u'){ # UNICODE handling + my $u = ''; + + for(1..4){ + $ch = next_chr(); + last OUTER if($ch !~ /[0-9a-fA-F]/); + $u .= $ch; + } + + # U+D800 - U+DBFF + if ($u =~ /^[dD][89abAB][0-9a-fA-F]{2}/) { # UTF-16 high surrogate? + $utf16 = $u; + } + # U+DC00 - U+DFFF + elsif ($u =~ /^[dD][c-fC-F][0-9a-fA-F]{2}/) { # UTF-16 low surrogate? + unless (defined $utf16) { + decode_error("missing high surrogate character in surrogate pair"); + } + $is_utf8 = 1; + $s .= JSON_PP_decode_surrogates($utf16, $u) || next; + $utf16 = undef; + } + else { + if (defined $utf16) { + decode_error("surrogate pair expected"); + } + + if ( ( my $hex = hex( $u ) ) > 127 ) { + $is_utf8 = 1; + $s .= JSON_PP_decode_unicode($u) || next; + } + else { + $s .= chr $hex; + } + } + + } + else{ + unless ($loose) { + $at -= 2; + decode_error('illegal backslash escape sequence in string'); + } + $s .= $ch; + } + } + else{ + + if ( ord $ch > 127 ) { + if ( $utf8 ) { + unless( $ch = is_valid_utf8($ch) ) { + $at -= 1; + decode_error("malformed UTF-8 character in JSON string"); + } + else { + $at += $utf8_len - 1; + } + } + else { + utf8::encode( $ch ); + } + + $is_utf8 = 1; + } + + if (!$loose) { + if ($ch =~ /[\x00-\x1f\x22\x5c]/) { # '/' ok + $at--; + decode_error('invalid character encountered while parsing JSON string'); + } + } + + $s .= $ch; + } + } + } + + decode_error("unexpected end of string while parsing JSON string"); + } + + + sub white { + while( defined $ch ){ + if($ch le ' '){ + next_chr(); + } + elsif($ch eq '/'){ + next_chr(); + if(defined $ch and $ch eq '/'){ + 1 while(defined(next_chr()) and $ch ne "\n" and $ch ne "\r"); + } + elsif(defined $ch and $ch eq '*'){ + next_chr(); + while(1){ + if(defined $ch){ + if($ch eq '*'){ + if(defined(next_chr()) and $ch eq '/'){ + next_chr(); + last; + } + } + else{ + next_chr(); + } + } + else{ + decode_error("Unterminated comment"); + } + } + next; + } + else{ + $at--; + decode_error("malformed JSON string, neither array, object, number, string or atom"); + } + } + else{ + if ($relaxed and $ch eq '#') { # correctly? + pos($text) = $at; + $text =~ /\G([^\n]*(?:\r\n|\r|\n|$))/g; + $at = pos($text); + next_chr; + next; + } + + last; + } + } + } + + + sub array { + my $a = $_[0] || []; # you can use this code to use another array ref object. + + decode_error('json text or perl structure exceeds maximum nesting level (max_depth set too low?)') + if (++$depth > $max_depth); + + next_chr(); + white(); + + if(defined $ch and $ch eq ']'){ + --$depth; + next_chr(); + return $a; + } + else { + while(defined($ch)){ + push @$a, value(); + + white(); + + if (!defined $ch) { + last; + } + + if($ch eq ']'){ + --$depth; + next_chr(); + return $a; + } + + if($ch ne ','){ + last; + } + + next_chr(); + white(); + + if ($relaxed and $ch eq ']') { + --$depth; + next_chr(); + return $a; + } + + } + } + + decode_error(", or ] expected while parsing array"); + } + + + sub object { + my $o = $_[0] || {}; # you can use this code to use another hash ref object. + my $k; + + decode_error('json text or perl structure exceeds maximum nesting level (max_depth set too low?)') + if (++$depth > $max_depth); + next_chr(); + white(); + + if(defined $ch and $ch eq '}'){ + --$depth; + next_chr(); + if ($F_HOOK) { + return _json_object_hook($o); + } + return $o; + } + else { + while (defined $ch) { + $k = ($allow_barekey and $ch ne '"' and $ch ne "'") ? bareKey() : string(); + white(); + + if(!defined $ch or $ch ne ':'){ + $at--; + decode_error("':' expected"); + } + + next_chr(); + $o->{$k} = value(); + white(); + + last if (!defined $ch); + + if($ch eq '}'){ + --$depth; + next_chr(); + if ($F_HOOK) { + return _json_object_hook($o); + } + return $o; + } + + if($ch ne ','){ + last; + } + + next_chr(); + white(); + + if ($relaxed and $ch eq '}') { + --$depth; + next_chr(); + if ($F_HOOK) { + return _json_object_hook($o); + } + return $o; + } + + } + + } + + $at--; + decode_error(", or } expected while parsing object/hash"); + } + + + sub bareKey { # doesn't strictly follow Standard ECMA-262 3rd Edition + my $key; + while($ch =~ /[^\x00-\x23\x25-\x2F\x3A-\x40\x5B-\x5E\x60\x7B-\x7F]/){ + $key .= $ch; + next_chr(); + } + return $key; + } + + + sub word { + my $word = substr($text,$at-1,4); + + if($word eq 'true'){ + $at += 3; + next_chr; + return $JSON::PP::true; + } + elsif($word eq 'null'){ + $at += 3; + next_chr; + return undef; + } + elsif($word eq 'fals'){ + $at += 3; + if(substr($text,$at,1) eq 'e'){ + $at++; + next_chr; + return $JSON::PP::false; + } + } + + $at--; # for decode_error report + + decode_error("'null' expected") if ($word =~ /^n/); + decode_error("'true' expected") if ($word =~ /^t/); + decode_error("'false' expected") if ($word =~ /^f/); + decode_error("malformed JSON string, neither array, object, number, string or atom"); + } + + + sub number { + my $n = ''; + my $v; + + # According to RFC4627, hex or oct digts are invalid. + if($ch eq '0'){ + my $peek = substr($text,$at,1); + my $hex = $peek =~ /[xX]/; # 0 or 1 + + if($hex){ + decode_error("malformed number (leading zero must not be followed by another digit)"); + ($n) = ( substr($text, $at+1) =~ /^([0-9a-fA-F]+)/); + } + else{ # oct + ($n) = ( substr($text, $at) =~ /^([0-7]+)/); + if (defined $n and length $n > 1) { + decode_error("malformed number (leading zero must not be followed by another digit)"); + } + } + + if(defined $n and length($n)){ + if (!$hex and length($n) == 1) { + decode_error("malformed number (leading zero must not be followed by another digit)"); + } + $at += length($n) + $hex; + next_chr; + return $hex ? hex($n) : oct($n); + } + } + + if($ch eq '-'){ + $n = '-'; + next_chr; + if (!defined $ch or $ch !~ /\d/) { + decode_error("malformed number (no digits after initial minus)"); + } + } + + while(defined $ch and $ch =~ /\d/){ + $n .= $ch; + next_chr; + } + + if(defined $ch and $ch eq '.'){ + $n .= '.'; + + next_chr; + if (!defined $ch or $ch !~ /\d/) { + decode_error("malformed number (no digits after decimal point)"); + } + else { + $n .= $ch; + } + + while(defined(next_chr) and $ch =~ /\d/){ + $n .= $ch; + } + } + + if(defined $ch and ($ch eq 'e' or $ch eq 'E')){ + $n .= $ch; + next_chr; + + if(defined($ch) and ($ch eq '+' or $ch eq '-')){ + $n .= $ch; + next_chr; + if (!defined $ch or $ch =~ /\D/) { + decode_error("malformed number (no digits after exp sign)"); + } + $n .= $ch; + } + elsif(defined($ch) and $ch =~ /\d/){ + $n .= $ch; + } + else { + decode_error("malformed number (no digits after exp sign)"); + } + + while(defined(next_chr) and $ch =~ /\d/){ + $n .= $ch; + } + + } + + $v .= $n; + + if ($v !~ /[.eE]/ and length $v > $max_intsize) { + if ($allow_bigint) { # from Adam Sussman + require Math::BigInt; + return Math::BigInt->new($v); + } + else { + return "$v"; + } + } + elsif ($allow_bigint) { + require Math::BigFloat; + return Math::BigFloat->new($v); + } + + return 0+$v; + } + + + sub is_valid_utf8 { + + $utf8_len = $_[0] =~ /[\x00-\x7F]/ ? 1 + : $_[0] =~ /[\xC2-\xDF]/ ? 2 + : $_[0] =~ /[\xE0-\xEF]/ ? 3 + : $_[0] =~ /[\xF0-\xF4]/ ? 4 + : 0 + ; + + return unless $utf8_len; + + my $is_valid_utf8 = substr($text, $at - 1, $utf8_len); + + return ( $is_valid_utf8 =~ /^(?: + [\x00-\x7F] + |[\xC2-\xDF][\x80-\xBF] + |[\xE0][\xA0-\xBF][\x80-\xBF] + |[\xE1-\xEC][\x80-\xBF][\x80-\xBF] + |[\xED][\x80-\x9F][\x80-\xBF] + |[\xEE-\xEF][\x80-\xBF][\x80-\xBF] + |[\xF0][\x90-\xBF][\x80-\xBF][\x80-\xBF] + |[\xF1-\xF3][\x80-\xBF][\x80-\xBF][\x80-\xBF] + |[\xF4][\x80-\x8F][\x80-\xBF][\x80-\xBF] + )$/x ) ? $is_valid_utf8 : ''; + } + + + sub decode_error { + my $error = shift; + my $no_rep = shift; + my $str = defined $text ? substr($text, $at) : ''; + my $mess = ''; + my $type = $] >= 5.008 ? 'U*' + : $] < 5.006 ? 'C*' + : utf8::is_utf8( $str ) ? 'U*' # 5.6 + : 'C*' + ; + + for my $c ( unpack( $type, $str ) ) { # emulate pv_uni_display() ? + $mess .= $c == 0x07 ? '\a' + : $c == 0x09 ? '\t' + : $c == 0x0a ? '\n' + : $c == 0x0d ? '\r' + : $c == 0x0c ? '\f' + : $c < 0x20 ? sprintf('\x{%x}', $c) + : $c == 0x5c ? '\\\\' + : $c < 0x80 ? chr($c) + : sprintf('\x{%x}', $c) + ; + if ( length $mess >= 20 ) { + $mess .= '...'; + last; + } + } + + unless ( length $mess ) { + $mess = '(end of string)'; + } + + Carp::croak ( + $no_rep ? "$error" : "$error, at character offset $at (before \"$mess\")" + ); + + } + + + sub _json_object_hook { + my $o = $_[0]; + my @ks = keys %{$o}; + + if ( $cb_sk_object and @ks == 1 and exists $cb_sk_object->{ $ks[0] } and ref $cb_sk_object->{ $ks[0] } ) { + my @val = $cb_sk_object->{ $ks[0] }->( $o->{$ks[0]} ); + if (@val == 1) { + return $val[0]; + } + } + + my @val = $cb_object->($o) if ($cb_object); + if (@val == 0 or @val > 1) { + return $o; + } + else { + return $val[0]; + } + } + + + sub PP_decode_box { + { + text => $text, + at => $at, + ch => $ch, + len => $len, + depth => $depth, + encoding => $encoding, + is_valid_utf8 => $is_valid_utf8, + }; + } + + } # PARSE + + + sub _decode_surrogates { # from perlunicode + my $uni = 0x10000 + (hex($_[0]) - 0xD800) * 0x400 + (hex($_[1]) - 0xDC00); + my $un = pack('U*', $uni); + utf8::encode( $un ); + return $un; + } + + + sub _decode_unicode { + my $un = pack('U', hex shift); + utf8::encode( $un ); + return $un; + } + + # + # Setup for various Perl versions (the code from JSON::PP58) + # + + BEGIN { + + unless ( defined &utf8::is_utf8 ) { + require Encode; + *utf8::is_utf8 = *Encode::is_utf8; + } + + if ( $] >= 5.008 ) { + *JSON::PP::JSON_PP_encode_ascii = \&_encode_ascii; + *JSON::PP::JSON_PP_encode_latin1 = \&_encode_latin1; + *JSON::PP::JSON_PP_decode_surrogates = \&_decode_surrogates; + *JSON::PP::JSON_PP_decode_unicode = \&_decode_unicode; + } + + if ($] >= 5.008 and $] < 5.008003) { # join() in 5.8.0 - 5.8.2 is broken. + package JSON::PP; + require subs; + subs->import('join'); + eval q| + sub join { + return '' if (@_ < 2); + my $j = shift; + my $str = shift; + for (@_) { $str .= $j . $_; } + return $str; + } + |; + } + + + sub JSON::PP::incr_parse { + local $Carp::CarpLevel = 1; + ( $_[0]->{_incr_parser} ||= JSON::PP::IncrParser->new )->incr_parse( @_ ); + } + + + sub JSON::PP::incr_skip { + ( $_[0]->{_incr_parser} ||= JSON::PP::IncrParser->new )->incr_skip; + } + + + sub JSON::PP::incr_reset { + ( $_[0]->{_incr_parser} ||= JSON::PP::IncrParser->new )->incr_reset; + } + + eval q{ + sub JSON::PP::incr_text : lvalue { + $_[0]->{_incr_parser} ||= JSON::PP::IncrParser->new; + + if ( $_[0]->{_incr_parser}->{incr_parsing} ) { + Carp::croak("incr_text can not be called when the incremental parser already started parsing"); + } + $_[0]->{_incr_parser}->{incr_text}; + } + } if ( $] >= 5.006 ); + + } # Setup for various Perl versions (the code from JSON::PP58) + + + ############################### + # Utilities + # + + BEGIN { + eval 'require Scalar::Util'; + unless($@){ + *JSON::PP::blessed = \&Scalar::Util::blessed; + *JSON::PP::reftype = \&Scalar::Util::reftype; + *JSON::PP::refaddr = \&Scalar::Util::refaddr; + } + else{ # This code is from Sclar::Util. + # warn $@; + eval 'sub UNIVERSAL::a_sub_not_likely_to_be_here { ref($_[0]) }'; + *JSON::PP::blessed = sub { + local($@, $SIG{__DIE__}, $SIG{__WARN__}); + ref($_[0]) ? eval { $_[0]->a_sub_not_likely_to_be_here } : undef; + }; + my %tmap = qw( + B::NULL SCALAR + B::HV HASH + B::AV ARRAY + B::CV CODE + B::IO IO + B::GV GLOB + B::REGEXP REGEXP + ); + *JSON::PP::reftype = sub { + my $r = shift; + + return undef unless length(ref($r)); + + my $t = ref(B::svref_2object($r)); + + return + exists $tmap{$t} ? $tmap{$t} + : length(ref($$r)) ? 'REF' + : 'SCALAR'; + }; + *JSON::PP::refaddr = sub { + return undef unless length(ref($_[0])); + + my $addr; + if(defined(my $pkg = blessed($_[0]))) { + $addr .= bless $_[0], 'Scalar::Util::Fake'; + bless $_[0], $pkg; + } + else { + $addr .= $_[0] + } + + $addr =~ /0x(\w+)/; + local $^W; + #no warnings 'portable'; + hex($1); + } + } + } + + + # shamely copied and modified from JSON::XS code. + + $JSON::PP::true = do { bless \(my $dummy = 1), "JSON::PP::Boolean" }; + $JSON::PP::false = do { bless \(my $dummy = 0), "JSON::PP::Boolean" }; + + sub is_bool { defined $_[0] and UNIVERSAL::isa($_[0], "JSON::PP::Boolean"); } + + sub true { $JSON::PP::true } + sub false { $JSON::PP::false } + sub null { undef; } + + ############################### + + package JSON::PP::Boolean; + + use overload ( + "0+" => sub { ${$_[0]} }, + "++" => sub { $_[0] = ${$_[0]} + 1 }, + "--" => sub { $_[0] = ${$_[0]} - 1 }, + fallback => 1, + ); + + + ############################### + + package JSON::PP::IncrParser; + + use strict; + + use constant INCR_M_WS => 0; # initial whitespace skipping + use constant INCR_M_STR => 1; # inside string + use constant INCR_M_BS => 2; # inside backslash + use constant INCR_M_JSON => 3; # outside anything, count nesting + use constant INCR_M_C0 => 4; + use constant INCR_M_C1 => 5; + + $JSON::PP::IncrParser::VERSION = '1.01'; + + my $unpack_format = $] < 5.006 ? 'C*' : 'U*'; + + sub new { + my ( $class ) = @_; + + bless { + incr_nest => 0, + incr_text => undef, + incr_parsing => 0, + incr_p => 0, + }, $class; + } + + + sub incr_parse { + my ( $self, $coder, $text ) = @_; + + $self->{incr_text} = '' unless ( defined $self->{incr_text} ); + + if ( defined $text ) { + if ( utf8::is_utf8( $text ) and !utf8::is_utf8( $self->{incr_text} ) ) { + utf8::upgrade( $self->{incr_text} ) ; + utf8::decode( $self->{incr_text} ) ; + } + $self->{incr_text} .= $text; + } + + + my $max_size = $coder->get_max_size; + + if ( defined wantarray ) { + + $self->{incr_mode} = INCR_M_WS unless defined $self->{incr_mode}; + + if ( wantarray ) { + my @ret; + + $self->{incr_parsing} = 1; + + do { + push @ret, $self->_incr_parse( $coder, $self->{incr_text} ); + + unless ( !$self->{incr_nest} and $self->{incr_mode} == INCR_M_JSON ) { + $self->{incr_mode} = INCR_M_WS if $self->{incr_mode} != INCR_M_STR; + } + + } until ( length $self->{incr_text} >= $self->{incr_p} ); + + $self->{incr_parsing} = 0; + + return @ret; + } + else { # in scalar context + $self->{incr_parsing} = 1; + my $obj = $self->_incr_parse( $coder, $self->{incr_text} ); + $self->{incr_parsing} = 0 if defined $obj; # pointed by Martin J. Evans + return $obj ? $obj : undef; # $obj is an empty string, parsing was completed. + } + + } + + } + + + sub _incr_parse { + my ( $self, $coder, $text, $skip ) = @_; + my $p = $self->{incr_p}; + my $restore = $p; + + my @obj; + my $len = length $text; + + if ( $self->{incr_mode} == INCR_M_WS ) { + while ( $len > $p ) { + my $s = substr( $text, $p, 1 ); + $p++ and next if ( 0x20 >= unpack($unpack_format, $s) ); + $self->{incr_mode} = INCR_M_JSON; + last; + } + } + + while ( $len > $p ) { + my $s = substr( $text, $p++, 1 ); + + if ( $s eq '"' ) { + if (substr( $text, $p - 2, 1 ) eq '\\' ) { + next; + } + + if ( $self->{incr_mode} != INCR_M_STR ) { + $self->{incr_mode} = INCR_M_STR; + } + else { + $self->{incr_mode} = INCR_M_JSON; + unless ( $self->{incr_nest} ) { + last; + } + } + } + + if ( $self->{incr_mode} == INCR_M_JSON ) { + + if ( $s eq '[' or $s eq '{' ) { + if ( ++$self->{incr_nest} > $coder->get_max_depth ) { + Carp::croak('json text or perl structure exceeds maximum nesting level (max_depth set too low?)'); + } + } + elsif ( $s eq ']' or $s eq '}' ) { + last if ( --$self->{incr_nest} <= 0 ); + } + elsif ( $s eq '#' ) { + while ( $len > $p ) { + last if substr( $text, $p++, 1 ) eq "\n"; + } + } + + } + + } + + $self->{incr_p} = $p; + + return if ( $self->{incr_mode} == INCR_M_STR and not $self->{incr_nest} ); + return if ( $self->{incr_mode} == INCR_M_JSON and $self->{incr_nest} > 0 ); + + return '' unless ( length substr( $self->{incr_text}, 0, $p ) ); + + local $Carp::CarpLevel = 2; + + $self->{incr_p} = $restore; + $self->{incr_c} = $p; + + my ( $obj, $tail ) = $coder->PP_decode_json( substr( $self->{incr_text}, 0, $p ), 0x10000001 ); + + $self->{incr_text} = substr( $self->{incr_text}, $p ); + $self->{incr_p} = 0; + + return $obj or ''; + } + + + sub incr_text { + if ( $_[0]->{incr_parsing} ) { + Carp::croak("incr_text can not be called when the incremental parser already started parsing"); + } + $_[0]->{incr_text}; + } + + + sub incr_skip { + my $self = shift; + $self->{incr_text} = substr( $self->{incr_text}, $self->{incr_c} ); + $self->{incr_p} = 0; + } + + + sub incr_reset { + my $self = shift; + $self->{incr_text} = undef; + $self->{incr_p} = 0; + $self->{incr_mode} = 0; + $self->{incr_nest} = 0; + $self->{incr_parsing} = 0; + } + + ############################### + + + 1; + __END__ + =pod + +JSON_PP + +$fatpacked{"JSON/PP/Boolean.pm"} = <<'JSON_PP_BOOLEAN'; + use JSON::PP (); + use strict; + + 1; + +JSON_PP_BOOLEAN + +$fatpacked{"Module/Metadata.pm"} = <<'MODULE_METADATA'; + # -*- mode: cperl; tab-width: 8; indent-tabs-mode: nil; basic-offset: 2 -*- + # vim:ts=8:sw=2:et:sta:sts=2 + package Module::Metadata; + + # Adapted from Perl-licensed code originally distributed with + # Module-Build by Ken Williams + + # This module provides routines to gather information about + # perl modules (assuming this may be expanded in the distant + # parrot future to look at other types of modules). + + use strict; + use vars qw($VERSION); + $VERSION = '1.000007'; + $VERSION = eval $VERSION; + + use File::Spec; + use IO::File; + use version 0.87; + BEGIN { + if ($INC{'Log/Contextual.pm'}) { + Log::Contextual->import('log_info'); + } else { + *log_info = sub (&) { warn $_[0]->() }; + } + } + use File::Find qw(find); + + my $V_NUM_REGEXP = qr{v?[0-9._]+}; # crudely, a v-string or decimal + + my $PKG_REGEXP = qr{ # match a package declaration + ^[\s\{;]* # intro chars on a line + package # the word 'package' + \s+ # whitespace + ([\w:]+) # a package name + \s* # optional whitespace + ($V_NUM_REGEXP)? # optional version number + \s* # optional whitesapce + [;\{] # semicolon line terminator or block start (since 5.16) + }x; + + my $VARNAME_REGEXP = qr{ # match fully-qualified VERSION name + ([\$*]) # sigil - $ or * + ( + ( # optional leading package name + (?:::|\')? # possibly starting like just :: ( la $::VERSION) + (?:\w+(?:::|\'))* # Foo::Bar:: ... + )? + VERSION + )\b + }x; + + my $VERS_REGEXP = qr{ # match a VERSION definition + (?: + \(\s*$VARNAME_REGEXP\s*\) # with parens + | + $VARNAME_REGEXP # without parens + ) + \s* + =[^=~] # = but not ==, nor =~ + }x; + + + sub new_from_file { + my $class = shift; + my $filename = File::Spec->rel2abs( shift ); + + return undef unless defined( $filename ) && -f $filename; + return $class->_init(undef, $filename, @_); + } + + sub new_from_handle { + my $class = shift; + my $handle = shift; + my $filename = shift; + return undef unless defined($handle) && defined($filename); + $filename = File::Spec->rel2abs( $filename ); + + return $class->_init(undef, $filename, @_, handle => $handle); + + } + + + sub new_from_module { + my $class = shift; + my $module = shift; + my %props = @_; + + $props{inc} ||= \@INC; + my $filename = $class->find_module_by_name( $module, $props{inc} ); + return undef unless defined( $filename ) && -f $filename; + return $class->_init($module, $filename, %props); + } + + { + + my $compare_versions = sub { + my ($v1, $op, $v2) = @_; + $v1 = version->new($v1) + unless UNIVERSAL::isa($v1,'version'); + + my $eval_str = "\$v1 $op \$v2"; + my $result = eval $eval_str; + log_info { "error comparing versions: '$eval_str' $@" } if $@; + + return $result; + }; + + my $normalize_version = sub { + my ($version) = @_; + if ( $version =~ /[=<>!,]/ ) { # logic, not just version + # take as is without modification + } + elsif ( ref $version eq 'version' ) { # version objects + $version = $version->is_qv ? $version->normal : $version->stringify; + } + elsif ( $version =~ /^[^v][^.]*\.[^.]+\./ ) { # no leading v, multiple dots + # normalize string tuples without "v": "1.2.3" -> "v1.2.3" + $version = "v$version"; + } + else { + # leave alone + } + return $version; + }; + + # separate out some of the conflict resolution logic + + my $resolve_module_versions = sub { + my $packages = shift; + + my( $file, $version ); + my $err = ''; + foreach my $p ( @$packages ) { + if ( defined( $p->{version} ) ) { + if ( defined( $version ) ) { + if ( $compare_versions->( $version, '!=', $p->{version} ) ) { + $err .= " $p->{file} ($p->{version})\n"; + } else { + # same version declared multiple times, ignore + } + } else { + $file = $p->{file}; + $version = $p->{version}; + } + } + $file ||= $p->{file} if defined( $p->{file} ); + } + + if ( $err ) { + $err = " $file ($version)\n" . $err; + } + + my %result = ( + file => $file, + version => $version, + err => $err + ); + + return \%result; + }; + + sub package_versions_from_directory { + my ( $class, $dir, $files ) = @_; + + my @files; + + if ( $files ) { + @files = @$files; + } else { + find( { + wanted => sub { + push @files, $_ if -f $_ && /\.pm$/; + }, + no_chdir => 1, + }, $dir ); + } + + # First, we enumerate all packages & versions, + # separating into primary & alternative candidates + my( %prime, %alt ); + foreach my $file (@files) { + my $mapped_filename = File::Spec->abs2rel( $file, $dir ); + my @path = split( /\//, $mapped_filename ); + (my $prime_package = join( '::', @path )) =~ s/\.pm$//; + + my $pm_info = $class->new_from_file( $file ); + + foreach my $package ( $pm_info->packages_inside ) { + next if $package eq 'main'; # main can appear numerous times, ignore + next if $package eq 'DB'; # special debugging package, ignore + next if grep /^_/, split( /::/, $package ); # private package, ignore + + my $version = $pm_info->version( $package ); + + if ( $package eq $prime_package ) { + if ( exists( $prime{$package} ) ) { + die "Unexpected conflict in '$package'; multiple versions found.\n"; + } else { + $prime{$package}{file} = $mapped_filename; + $prime{$package}{version} = $version if defined( $version ); + } + } else { + push( @{$alt{$package}}, { + file => $mapped_filename, + version => $version, + } ); + } + } + } + + # Then we iterate over all the packages found above, identifying conflicts + # and selecting the "best" candidate for recording the file & version + # for each package. + foreach my $package ( keys( %alt ) ) { + my $result = $resolve_module_versions->( $alt{$package} ); + + if ( exists( $prime{$package} ) ) { # primary package selected + + if ( $result->{err} ) { + # Use the selected primary package, but there are conflicting + # errors among multiple alternative packages that need to be + # reported + log_info { + "Found conflicting versions for package '$package'\n" . + " $prime{$package}{file} ($prime{$package}{version})\n" . + $result->{err} + }; + + } elsif ( defined( $result->{version} ) ) { + # There is a primary package selected, and exactly one + # alternative package + + if ( exists( $prime{$package}{version} ) && + defined( $prime{$package}{version} ) ) { + # Unless the version of the primary package agrees with the + # version of the alternative package, report a conflict + if ( $compare_versions->( + $prime{$package}{version}, '!=', $result->{version} + ) + ) { + + log_info { + "Found conflicting versions for package '$package'\n" . + " $prime{$package}{file} ($prime{$package}{version})\n" . + " $result->{file} ($result->{version})\n" + }; + } + + } else { + # The prime package selected has no version so, we choose to + # use any alternative package that does have a version + $prime{$package}{file} = $result->{file}; + $prime{$package}{version} = $result->{version}; + } + + } else { + # no alt package found with a version, but we have a prime + # package so we use it whether it has a version or not + } + + } else { # No primary package was selected, use the best alternative + + if ( $result->{err} ) { + log_info { + "Found conflicting versions for package '$package'\n" . + $result->{err} + }; + } + + # Despite possible conflicting versions, we choose to record + # something rather than nothing + $prime{$package}{file} = $result->{file}; + $prime{$package}{version} = $result->{version} + if defined( $result->{version} ); + } + } + + # Normalize versions. Can't use exists() here because of bug in YAML::Node. + # XXX "bug in YAML::Node" comment seems irrelvant -- dagolden, 2009-05-18 + for (grep defined $_->{version}, values %prime) { + $_->{version} = $normalize_version->( $_->{version} ); + } + + return \%prime; + } + } + + + sub _init { + my $class = shift; + my $module = shift; + my $filename = shift; + my %props = @_; + + my $handle = delete $props{handle}; + my( %valid_props, @valid_props ); + @valid_props = qw( collect_pod inc ); + @valid_props{@valid_props} = delete( @props{@valid_props} ); + warn "Unknown properties: @{[keys %props]}\n" if scalar( %props ); + + my %data = ( + module => $module, + filename => $filename, + version => undef, + packages => [], + versions => {}, + pod => {}, + pod_headings => [], + collect_pod => 0, + + %valid_props, + ); + + my $self = bless(\%data, $class); + + if ( $handle ) { + $self->_parse_fh($handle); + } + else { + $self->_parse_file(); + } + + unless($self->{module} and length($self->{module})) { + my ($v, $d, $f) = File::Spec->splitpath($self->{filename}); + if($f =~ /\.pm$/) { + $f =~ s/\..+$//; + my @candidates = grep /$f$/, @{$self->{packages}}; + $self->{module} = shift(@candidates); # punt + } + else { + if(grep /main/, @{$self->{packages}}) { + $self->{module} = 'main'; + } + else { + $self->{module} = $self->{packages}[0] || ''; + } + } + } + + $self->{version} = $self->{versions}{$self->{module}} + if defined( $self->{module} ); + + return $self; + } + + # class method + sub _do_find_module { + my $class = shift; + my $module = shift || die 'find_module_by_name() requires a package name'; + my $dirs = shift || \@INC; + + my $file = File::Spec->catfile(split( /::/, $module)); + foreach my $dir ( @$dirs ) { + my $testfile = File::Spec->catfile($dir, $file); + return [ File::Spec->rel2abs( $testfile ), $dir ] + if -e $testfile and !-d _; # For stuff like ExtUtils::xsubpp + return [ File::Spec->rel2abs( "$testfile.pm" ), $dir ] + if -e "$testfile.pm"; + } + return; + } + + # class method + sub find_module_by_name { + my $found = shift()->_do_find_module(@_) or return; + return $found->[0]; + } + + # class method + sub find_module_dir_by_name { + my $found = shift()->_do_find_module(@_) or return; + return $found->[1]; + } + + + # given a line of perl code, attempt to parse it if it looks like a + # $VERSION assignment, returning sigil, full name, & package name + sub _parse_version_expression { + my $self = shift; + my $line = shift; + + my( $sig, $var, $pkg ); + if ( $line =~ $VERS_REGEXP ) { + ( $sig, $var, $pkg ) = $2 ? ( $1, $2, $3 ) : ( $4, $5, $6 ); + if ( $pkg ) { + $pkg = ($pkg eq '::') ? 'main' : $pkg; + $pkg =~ s/::$//; + } + } + + return ( $sig, $var, $pkg ); + } + + sub _parse_file { + my $self = shift; + + my $filename = $self->{filename}; + my $fh = IO::File->new( $filename ) + or die( "Can't open '$filename': $!" ); + + $self->_parse_fh($fh); + } + + sub _parse_fh { + my ($self, $fh) = @_; + + my( $in_pod, $seen_end, $need_vers ) = ( 0, 0, 0 ); + my( @pkgs, %vers, %pod, @pod ); + my $pkg = 'main'; + my $pod_sect = ''; + my $pod_data = ''; + + while (defined( my $line = <$fh> )) { + my $line_num = $.; + + chomp( $line ); + next if $line =~ /^\s*#/; + + $in_pod = ($line =~ /^=(?!cut)/) ? 1 : ($line =~ /^=cut/) ? 0 : $in_pod; + + # Would be nice if we could also check $in_string or something too + last if !$in_pod && $line =~ /^__(?:DATA|END)__$/; + + if ( $in_pod || $line =~ /^=cut/ ) { + + if ( $line =~ /^=head\d\s+(.+)\s*$/ ) { + push( @pod, $1 ); + if ( $self->{collect_pod} && length( $pod_data ) ) { + $pod{$pod_sect} = $pod_data; + $pod_data = ''; + } + $pod_sect = $1; + + + } elsif ( $self->{collect_pod} ) { + $pod_data .= "$line\n"; + + } + + } else { + + $pod_sect = ''; + $pod_data = ''; + + # parse $line to see if it's a $VERSION declaration + my( $vers_sig, $vers_fullname, $vers_pkg ) = + $self->_parse_version_expression( $line ); + + if ( $line =~ $PKG_REGEXP ) { + $pkg = $1; + push( @pkgs, $pkg ) unless grep( $pkg eq $_, @pkgs ); + $vers{$pkg} = (defined $2 ? $2 : undef) unless exists( $vers{$pkg} ); + $need_vers = defined $2 ? 0 : 1; + + # VERSION defined with full package spec, i.e. $Module::VERSION + } elsif ( $vers_fullname && $vers_pkg ) { + push( @pkgs, $vers_pkg ) unless grep( $vers_pkg eq $_, @pkgs ); + $need_vers = 0 if $vers_pkg eq $pkg; + + unless ( defined $vers{$vers_pkg} && length $vers{$vers_pkg} ) { + $vers{$vers_pkg} = + $self->_evaluate_version_line( $vers_sig, $vers_fullname, $line ); + } else { + # Warn unless the user is using the "$VERSION = eval + # $VERSION" idiom (though there are probably other idioms + # that we should watch out for...) + warn <<"EOM" unless $line =~ /=\s*eval/; + Package '$vers_pkg' already declared with version '$vers{$vers_pkg}', + ignoring subsequent declaration on line $line_num. + EOM + } + + # first non-comment line in undeclared package main is VERSION + } elsif ( !exists($vers{main}) && $pkg eq 'main' && $vers_fullname ) { + $need_vers = 0; + my $v = + $self->_evaluate_version_line( $vers_sig, $vers_fullname, $line ); + $vers{$pkg} = $v; + push( @pkgs, 'main' ); + + # first non-comment line in undeclared package defines package main + } elsif ( !exists($vers{main}) && $pkg eq 'main' && $line =~ /\w+/ ) { + $need_vers = 1; + $vers{main} = ''; + push( @pkgs, 'main' ); + + # only keep if this is the first $VERSION seen + } elsif ( $vers_fullname && $need_vers ) { + $need_vers = 0; + my $v = + $self->_evaluate_version_line( $vers_sig, $vers_fullname, $line ); + + + unless ( defined $vers{$pkg} && length $vers{$pkg} ) { + $vers{$pkg} = $v; + } else { + warn <<"EOM"; + Package '$pkg' already declared with version '$vers{$pkg}' + ignoring new version '$v' on line $line_num. + EOM + } + + } + + } + + } + + if ( $self->{collect_pod} && length($pod_data) ) { + $pod{$pod_sect} = $pod_data; + } + + $self->{versions} = \%vers; + $self->{packages} = \@pkgs; + $self->{pod} = \%pod; + $self->{pod_headings} = \@pod; + } + + { + my $pn = 0; + sub _evaluate_version_line { + my $self = shift; + my( $sigil, $var, $line ) = @_; + + # Some of this code came from the ExtUtils:: hierarchy. + + # We compile into $vsub because 'use version' would cause + # compiletime/runtime issues with local() + my $vsub; + $pn++; # everybody gets their own package + my $eval = qq{BEGIN { q# Hide from _packages_inside() + #; package Module::Metadata::_version::p$pn; + use version; + no strict; + + \$vsub = sub { + local $sigil$var; + \$$var=undef; + $line; + \$$var + }; + }}; + + local $^W; + # Try to get the $VERSION + eval $eval; + # some modules say $VERSION = $Foo::Bar::VERSION, but Foo::Bar isn't + # installed, so we need to hunt in ./lib for it + if ( $@ =~ /Can't locate/ && -d 'lib' ) { + local @INC = ('lib',@INC); + eval $eval; + } + warn "Error evaling version line '$eval' in $self->{filename}: $@\n" + if $@; + (ref($vsub) eq 'CODE') or + die "failed to build version sub for $self->{filename}"; + my $result = eval { $vsub->() }; + die "Could not get version from $self->{filename} by executing:\n$eval\n\nThe fatal error was: $@\n" + if $@; + + # Upgrade it into a version object + my $version = eval { _dwim_version($result) }; + + die "Version '$result' from $self->{filename} does not appear to be valid:\n$eval\n\nThe fatal error was: $@\n" + unless defined $version; # "0" is OK! + + return $version; + } + } + + # Try to DWIM when things fail the lax version test in obvious ways + { + my @version_prep = ( + # Best case, it just works + sub { return shift }, + + # If we still don't have a version, try stripping any + # trailing junk that is prohibited by lax rules + sub { + my $v = shift; + $v =~ s{([0-9])[a-z-].*$}{$1}i; # 1.23-alpha or 1.23b + return $v; + }, + + # Activestate apparently creates custom versions like '1.23_45_01', which + # cause version.pm to think it's an invalid alpha. So check for that + # and strip them + sub { + my $v = shift; + my $num_dots = () = $v =~ m{(\.)}g; + my $num_unders = () = $v =~ m{(_)}g; + my $leading_v = substr($v,0,1) eq 'v'; + if ( ! $leading_v && $num_dots < 2 && $num_unders > 1 ) { + $v =~ s{_}{}g; + $num_unders = () = $v =~ m{(_)}g; + } + return $v; + }, + + # Worst case, try numifying it like we would have before version objects + sub { + my $v = shift; + no warnings 'numeric'; + return 0 + $v; + }, + + ); + + sub _dwim_version { + my ($result) = shift; + + return $result if ref($result) eq 'version'; + + my ($version, $error); + for my $f (@version_prep) { + $result = $f->($result); + $version = eval { version->new($result) }; + $error ||= $@ if $@; # capture first failure + last if defined $version; + } + + die $error unless defined $version; + + return $version; + } + } + + ############################################################ + + # accessors + sub name { $_[0]->{module} } + + sub filename { $_[0]->{filename} } + sub packages_inside { @{$_[0]->{packages}} } + sub pod_inside { @{$_[0]->{pod_headings}} } + sub contains_pod { $#{$_[0]->{pod_headings}} } + + sub version { + my $self = shift; + my $mod = shift || $self->{module}; + my $vers; + if ( defined( $mod ) && length( $mod ) && + exists( $self->{versions}{$mod} ) ) { + return $self->{versions}{$mod}; + } else { + return undef; + } + } + + sub pod { + my $self = shift; + my $sect = shift; + if ( defined( $sect ) && length( $sect ) && + exists( $self->{pod}{$sect} ) ) { + return $self->{pod}{$sect}; + } else { + return undef; + } + } + + 1; + +MODULE_METADATA + +$fatpacked{"Parse/CPAN/Meta.pm"} = <<'PARSE_CPAN_META'; + package Parse::CPAN::Meta; + + use strict; + use Carp 'croak'; + + # UTF Support? + sub HAVE_UTF8 () { $] >= 5.007003 } + sub IO_LAYER () { $] >= 5.008001 ? ":utf8" : "" } + + BEGIN { + if ( HAVE_UTF8 ) { + # The string eval helps hide this from Test::MinimumVersion + eval "require utf8;"; + die "Failed to load UTF-8 support" if $@; + } + + # Class structure + require 5.004; + require Exporter; + $Parse::CPAN::Meta::VERSION = '1.4401'; + @Parse::CPAN::Meta::ISA = qw{ Exporter }; + @Parse::CPAN::Meta::EXPORT_OK = qw{ Load LoadFile }; + } + + sub load_file { + my ($class, $filename) = @_; + + if ($filename =~ /\.ya?ml$/) { + return $class->load_yaml_string(_slurp($filename)); + } + + if ($filename =~ /\.json$/) { + return $class->load_json_string(_slurp($filename)); + } + + croak("file type cannot be determined by filename"); + } + + sub load_yaml_string { + my ($class, $string) = @_; + my $backend = $class->yaml_backend(); + my $data = eval { no strict 'refs'; &{"$backend\::Load"}($string) }; + if ( $@ ) { + croak $backend->can('errstr') ? $backend->errstr : $@ + } + return $data || {}; # in case document was valid but empty + } + + sub load_json_string { + my ($class, $string) = @_; + return $class->json_backend()->new->decode($string); + } + + sub yaml_backend { + local $Module::Load::Conditional::CHECK_INC_HASH = 1; + if (! defined $ENV{PERL_YAML_BACKEND} ) { + _can_load( 'CPAN::Meta::YAML', 0.002 ) + or croak "CPAN::Meta::YAML 0.002 is not available\n"; + return "CPAN::Meta::YAML"; + } + else { + my $backend = $ENV{PERL_YAML_BACKEND}; + _can_load( $backend ) + or croak "Could not load PERL_YAML_BACKEND '$backend'\n"; + $backend->can("Load") + or croak "PERL_YAML_BACKEND '$backend' does not implement Load()\n"; + return $backend; + } + } + + sub json_backend { + local $Module::Load::Conditional::CHECK_INC_HASH = 1; + if (! $ENV{PERL_JSON_BACKEND} or $ENV{PERL_JSON_BACKEND} eq 'JSON::PP') { + _can_load( 'JSON::PP' => 2.27103 ) + or croak "JSON::PP 2.27103 is not available\n"; + return 'JSON::PP'; + } + else { + _can_load( 'JSON' => 2.5 ) + or croak "JSON 2.5 is required for " . + "\$ENV{PERL_JSON_BACKEND} = '$ENV{PERL_JSON_BACKEND}'\n"; + return "JSON"; + } + } + + sub _slurp { + open my $fh, "<" . IO_LAYER, "$_[0]" + or die "can't open $_[0] for reading: $!"; + return do { local $/; <$fh> }; + } + + sub _can_load { + my ($module, $version) = @_; + (my $file = $module) =~ s{::}{/}g; + $file .= ".pm"; + return 1 if $INC{$file}; + return 0 if exists $INC{$file}; # prior load failed + eval { require $file; 1 } + or return 0; + if ( defined $version ) { + eval { $module->VERSION($version); 1 } + or return 0; + } + return 1; + } + + # Kept for backwards compatibility only + # Create an object from a file + sub LoadFile ($) { + require CPAN::Meta::YAML; + return CPAN::Meta::YAML::LoadFile(shift) + or die CPAN::Meta::YAML->errstr; + } + + # Parse a document from a string. + sub Load ($) { + require CPAN::Meta::YAML; + return CPAN::Meta::YAML::Load(shift) + or die CPAN::Meta::YAML->errstr; + } + + 1; + + __END__ + +PARSE_CPAN_META + +$fatpacked{"Try/Tiny.pm"} = <<'TRY_TINY'; + package Try::Tiny; + + use strict; + #use warnings; + + use vars qw(@EXPORT @EXPORT_OK $VERSION @ISA); + + BEGIN { + require Exporter; + @ISA = qw(Exporter); + } + + $VERSION = "0.09"; + + $VERSION = eval $VERSION; + + @EXPORT = @EXPORT_OK = qw(try catch finally); + + $Carp::Internal{+__PACKAGE__}++; + + # Need to prototype as @ not $$ because of the way Perl evaluates the prototype. + # Keeping it at $$ means you only ever get 1 sub because we need to eval in a list + # context & not a scalar one + + sub try (&;@) { + my ( $try, @code_refs ) = @_; + + # we need to save this here, the eval block will be in scalar context due + # to $failed + my $wantarray = wantarray; + + my ( $catch, @finally ); + + # find labeled blocks in the argument list. + # catch and finally tag the blocks by blessing a scalar reference to them. + foreach my $code_ref (@code_refs) { + next unless $code_ref; + + my $ref = ref($code_ref); + + if ( $ref eq 'Try::Tiny::Catch' ) { + $catch = ${$code_ref}; + } elsif ( $ref eq 'Try::Tiny::Finally' ) { + push @finally, ${$code_ref}; + } else { + use Carp; + confess("Unknown code ref type given '${ref}'. Check your usage & try again"); + } + } + + # save the value of $@ so we can set $@ back to it in the beginning of the eval + my $prev_error = $@; + + my ( @ret, $error, $failed ); + + # FIXME consider using local $SIG{__DIE__} to accumulate all errors. It's + # not perfect, but we could provide a list of additional errors for + # $catch->(); + + { + # localize $@ to prevent clobbering of previous value by a successful + # eval. + local $@; + + # failed will be true if the eval dies, because 1 will not be returned + # from the eval body + $failed = not eval { + $@ = $prev_error; + + # evaluate the try block in the correct context + if ( $wantarray ) { + @ret = $try->(); + } elsif ( defined $wantarray ) { + $ret[0] = $try->(); + } else { + $try->(); + }; + + return 1; # properly set $fail to false + }; + + # copy $@ to $error; when we leave this scope, local $@ will revert $@ + # back to its previous value + $error = $@; + } + + # set up a scope guard to invoke the finally block at the end + my @guards = + map { Try::Tiny::ScopeGuard->_new($_, $failed ? $error : ()) } + @finally; + + # at this point $failed contains a true value if the eval died, even if some + # destructor overwrote $@ as the eval was unwinding. + if ( $failed ) { + # if we got an error, invoke the catch block. + if ( $catch ) { + # This works like given($error), but is backwards compatible and + # sets $_ in the dynamic scope for the body of C<$catch> + for ($error) { + return $catch->($error); + } + + # in case when() was used without an explicit return, the C + # loop will be aborted and there's no useful return value + } + + return; + } else { + # no failure, $@ is back to what it was, everything is fine + return $wantarray ? @ret : $ret[0]; + } + } + + sub catch (&;@) { + my ( $block, @rest ) = @_; + + return ( + bless(\$block, 'Try::Tiny::Catch'), + @rest, + ); + } + + sub finally (&;@) { + my ( $block, @rest ) = @_; + + return ( + bless(\$block, 'Try::Tiny::Finally'), + @rest, + ); + } + + { + package # hide from PAUSE + Try::Tiny::ScopeGuard; + + sub _new { + shift; + bless [ @_ ]; + } + + sub DESTROY { + my @guts = @{ shift() }; + my $code = shift @guts; + $code->(@guts); + } + } + + __PACKAGE__ + + __END__ + +TRY_TINY + +$fatpacked{"lib/core/only.pm"} = <<'LIB_CORE_ONLY'; + package lib::core::only; + + use strict; + use warnings FATAL => 'all'; + use Config; + + sub import { + @INC = @Config{qw(privlibexp archlibexp)}; + return + } + + 1; +LIB_CORE_ONLY + +$fatpacked{"local/lib.pm"} = <<'LOCAL_LIB'; + use strict; + use warnings; + + package local::lib; + + use 5.008001; # probably works with earlier versions but I'm not supporting them + # (patches would, of course, be welcome) + + use File::Spec (); + use File::Path (); + use Carp (); + use Config; + + our $VERSION = '1.008001'; # 1.8.1 + + our @KNOWN_FLAGS = qw(--self-contained); + + sub import { + my ($class, @args) = @_; + + # Remember what PERL5LIB was when we started + my $perl5lib = $ENV{PERL5LIB} || ''; + + my %arg_store; + for my $arg (@args) { + # check for lethal dash first to stop processing before causing problems + if ($arg =~ /−/) { + die <<'DEATH'; + WHOA THERE! It looks like you've got some fancy dashes in your commandline! + These are *not* the traditional -- dashes that software recognizes. You + probably got these by copy-pasting from the perldoc for this module as + rendered by a UTF8-capable formatter. This most typically happens on an OS X + terminal, but can happen elsewhere too. Please try again after replacing the + dashes with normal minus signs. + DEATH + } + elsif(grep { $arg eq $_ } @KNOWN_FLAGS) { + (my $flag = $arg) =~ s/--//; + $arg_store{$flag} = 1; + } + elsif($arg =~ /^--/) { + die "Unknown import argument: $arg"; + } + else { + # assume that what's left is a path + $arg_store{path} = $arg; + } + } + + if($arg_store{'self-contained'}) { + die "FATAL: The local::lib --self-contained flag has never worked reliably and the original author, Mark Stosberg, was unable or unwilling to maintain it. As such, this flag has been removed from the local::lib codebase in order to prevent misunderstandings and potentially broken builds. The local::lib authors recommend that you look at the lib::core::only module shipped with this distribution in order to create a more robust environment that is equivalent to what --self-contained provided (although quite possibly not what you originally thought it provided due to the poor quality of the documentation, for which we apologise).\n"; + } + + $arg_store{path} = $class->resolve_path($arg_store{path}); + $class->setup_local_lib_for($arg_store{path}); + + for (@INC) { # Untaint @INC + next if ref; # Skip entry if it is an ARRAY, CODE, blessed, etc. + m/(.*)/ and $_ = $1; + } + } + + sub pipeline; + + sub pipeline { + my @methods = @_; + my $last = pop(@methods); + if (@methods) { + \sub { + my ($obj, @args) = @_; + $obj->${pipeline @methods}( + $obj->$last(@args) + ); + }; + } else { + \sub { + shift->$last(@_); + }; + } + } + + sub _uniq { + my %seen; + grep { ! $seen{$_}++ } @_; + } + + sub resolve_path { + my ($class, $path) = @_; + $class->${pipeline qw( + resolve_relative_path + resolve_home_path + resolve_empty_path + )}($path); + } + + sub resolve_empty_path { + my ($class, $path) = @_; + if (defined $path) { + $path; + } else { + '~/perl5'; + } + } + + sub resolve_home_path { + my ($class, $path) = @_; + return $path unless ($path =~ /^~/); + my ($user) = ($path =~ /^~([^\/]+)/); # can assume ^~ so undef for 'us' + my $tried_file_homedir; + my $homedir = do { + if (eval { require File::HomeDir } && $File::HomeDir::VERSION >= 0.65) { + $tried_file_homedir = 1; + if (defined $user) { + File::HomeDir->users_home($user); + } else { + File::HomeDir->my_home; + } + } else { + if (defined $user) { + (getpwnam $user)[7]; + } else { + if (defined $ENV{HOME}) { + $ENV{HOME}; + } else { + (getpwuid $<)[7]; + } + } + } + }; + unless (defined $homedir) { + Carp::croak( + "Couldn't resolve homedir for " + .(defined $user ? $user : 'current user') + .($tried_file_homedir ? '' : ' - consider installing File::HomeDir') + ); + } + $path =~ s/^~[^\/]*/$homedir/; + $path; + } + + sub resolve_relative_path { + my ($class, $path) = @_; + $path = File::Spec->rel2abs($path); + } + + sub setup_local_lib_for { + my ($class, $path) = @_; + $path = $class->ensure_dir_structure_for($path); + if ($0 eq '-') { + $class->print_environment_vars_for($path); + exit 0; + } else { + $class->setup_env_hash_for($path); + @INC = _uniq(split($Config{path_sep}, $ENV{PERL5LIB}), @INC); + } + } + + sub install_base_bin_path { + my ($class, $path) = @_; + File::Spec->catdir($path, 'bin'); + } + + sub install_base_perl_path { + my ($class, $path) = @_; + File::Spec->catdir($path, 'lib', 'perl5'); + } + + sub install_base_arch_path { + my ($class, $path) = @_; + File::Spec->catdir($class->install_base_perl_path($path), $Config{archname}); + } + + sub ensure_dir_structure_for { + my ($class, $path) = @_; + unless (-d $path) { + warn "Attempting to create directory ${path}\n"; + } + File::Path::mkpath($path); + # Need to have the path exist to make a short name for it, so + # converting to a short name here. + $path = Win32::GetShortPathName($path) if $^O eq 'MSWin32'; + + return $path; + } + + sub INTERPOLATE_ENV () { 1 } + sub LITERAL_ENV () { 0 } + + sub guess_shelltype { + my $shellbin = 'sh'; + if(defined $ENV{'SHELL'}) { + my @shell_bin_path_parts = File::Spec->splitpath($ENV{'SHELL'}); + $shellbin = $shell_bin_path_parts[-1]; + } + my $shelltype = do { + local $_ = $shellbin; + if(/csh/) { + 'csh' + } else { + 'bourne' + } + }; + + # Both Win32 and Cygwin have $ENV{COMSPEC} set. + if (defined $ENV{'COMSPEC'} && $^O ne 'cygwin') { + my @shell_bin_path_parts = File::Spec->splitpath($ENV{'COMSPEC'}); + $shellbin = $shell_bin_path_parts[-1]; + $shelltype = do { + local $_ = $shellbin; + if(/command\.com/) { + 'win32' + } elsif(/cmd\.exe/) { + 'win32' + } elsif(/4nt\.exe/) { + 'win32' + } else { + $shelltype + } + }; + } + return $shelltype; + } + + sub print_environment_vars_for { + my ($class, $path) = @_; + print $class->environment_vars_string_for($path); + } + + sub environment_vars_string_for { + my ($class, $path) = @_; + my @envs = $class->build_environment_vars_for($path, LITERAL_ENV); + my $out = ''; + + # rather basic csh detection, goes on the assumption that something won't + # call itself csh unless it really is. also, default to bourne in the + # pathological situation where a user doesn't have $ENV{SHELL} defined. + # note also that shells with funny names, like zoid, are assumed to be + # bourne. + + my $shelltype = $class->guess_shelltype; + + while (@envs) { + my ($name, $value) = (shift(@envs), shift(@envs)); + $value =~ s/(\\")/\\$1/g; + $out .= $class->${\"build_${shelltype}_env_declaration"}($name, $value); + } + return $out; + } + + # simple routines that take two arguments: an %ENV key and a value. return + # strings that are suitable for passing directly to the relevant shell to set + # said key to said value. + sub build_bourne_env_declaration { + my $class = shift; + my($name, $value) = @_; + return qq{export ${name}="${value}"\n}; + } + + sub build_csh_env_declaration { + my $class = shift; + my($name, $value) = @_; + return qq{setenv ${name} "${value}"\n}; + } + + sub build_win32_env_declaration { + my $class = shift; + my($name, $value) = @_; + return qq{set ${name}=${value}\n}; + } + + sub setup_env_hash_for { + my ($class, $path) = @_; + my %envs = $class->build_environment_vars_for($path, INTERPOLATE_ENV); + @ENV{keys %envs} = values %envs; + } + + sub build_environment_vars_for { + my ($class, $path, $interpolate) = @_; + return ( + PERL_LOCAL_LIB_ROOT => $path, + PERL_MB_OPT => "--install_base ${path}", + PERL_MM_OPT => "INSTALL_BASE=${path}", + PERL5LIB => join($Config{path_sep}, + $class->install_base_arch_path($path), + $class->install_base_perl_path($path), + (($ENV{PERL5LIB}||()) ? + ($interpolate == INTERPOLATE_ENV + ? ($ENV{PERL5LIB}) + : (($^O ne 'MSWin32') ? '$PERL5LIB' : '%PERL5LIB%' )) + : ()) + ), + PATH => join($Config{path_sep}, + $class->install_base_bin_path($path), + ($interpolate == INTERPOLATE_ENV + ? ($ENV{PATH}||()) + : (($^O ne 'MSWin32') ? '$PATH' : '%PATH%' )) + ), + ) + } + + 1; +LOCAL_LIB + +$fatpacked{"parent.pm"} = <<'PARENT'; + package parent; + use strict; + use vars qw($VERSION); + $VERSION = '0.225'; + + sub import { + my $class = shift; + + my $inheritor = caller(0); + + if ( @_ and $_[0] eq '-norequire' ) { + shift @_; + } else { + for ( my @filename = @_ ) { + if ( $_ eq $inheritor ) { + warn "Class '$inheritor' tried to inherit from itself\n"; + }; + + s{::|'}{/}g; + require "$_.pm"; # dies if the file is not found + } + } + + { + no strict 'refs'; + push @{"$inheritor\::ISA"}, @_; + }; + }; + + "All your base are belong to us" + + __END__ + +PARENT + +$fatpacked{"version.pm"} = <<'VERSION'; + #!perl -w + package version; + + use 5.005_04; + use strict; + + use vars qw(@ISA $VERSION $CLASS $STRICT $LAX *declare *qv); + + $VERSION = 0.88; + + $CLASS = 'version'; + + #--------------------------------------------------------------------------# + # Version regexp components + #--------------------------------------------------------------------------# + + # Fraction part of a decimal version number. This is a common part of + # both strict and lax decimal versions + + my $FRACTION_PART = qr/\.[0-9]+/; + + # First part of either decimal or dotted-decimal strict version number. + # Unsigned integer with no leading zeroes (except for zero itself) to + # avoid confusion with octal. + + my $STRICT_INTEGER_PART = qr/0|[1-9][0-9]*/; + + # First part of either decimal or dotted-decimal lax version number. + # Unsigned integer, but allowing leading zeros. Always interpreted + # as decimal. However, some forms of the resulting syntax give odd + # results if used as ordinary Perl expressions, due to how perl treats + # octals. E.g. + # version->new("010" ) == 10 + # version->new( 010 ) == 8 + # version->new( 010.2) == 82 # "8" . "2" + + my $LAX_INTEGER_PART = qr/[0-9]+/; + + # Second and subsequent part of a strict dotted-decimal version number. + # Leading zeroes are permitted, and the number is always decimal. + # Limited to three digits to avoid overflow when converting to decimal + # form and also avoid problematic style with excessive leading zeroes. + + my $STRICT_DOTTED_DECIMAL_PART = qr/\.[0-9]{1,3}/; + + # Second and subsequent part of a lax dotted-decimal version number. + # Leading zeroes are permitted, and the number is always decimal. No + # limit on the numerical value or number of digits, so there is the + # possibility of overflow when converting to decimal form. + + my $LAX_DOTTED_DECIMAL_PART = qr/\.[0-9]+/; + + # Alpha suffix part of lax version number syntax. Acts like a + # dotted-decimal part. + + my $LAX_ALPHA_PART = qr/_[0-9]+/; + + #--------------------------------------------------------------------------# + # Strict version regexp definitions + #--------------------------------------------------------------------------# + + # Strict decimal version number. + + my $STRICT_DECIMAL_VERSION = + qr/ $STRICT_INTEGER_PART $FRACTION_PART? /x; + + # Strict dotted-decimal version number. Must have both leading "v" and + # at least three parts, to avoid confusion with decimal syntax. + + my $STRICT_DOTTED_DECIMAL_VERSION = + qr/ v $STRICT_INTEGER_PART $STRICT_DOTTED_DECIMAL_PART{2,} /x; + + # Complete strict version number syntax -- should generally be used + # anchored: qr/ \A $STRICT \z /x + + $STRICT = + qr/ $STRICT_DECIMAL_VERSION | $STRICT_DOTTED_DECIMAL_VERSION /x; + + #--------------------------------------------------------------------------# + # Lax version regexp definitions + #--------------------------------------------------------------------------# + + # Lax decimal version number. Just like the strict one except for + # allowing an alpha suffix or allowing a leading or trailing + # decimal-point + + my $LAX_DECIMAL_VERSION = + qr/ $LAX_INTEGER_PART (?: \. | $FRACTION_PART $LAX_ALPHA_PART? )? + | + $FRACTION_PART $LAX_ALPHA_PART? + /x; + + # Lax dotted-decimal version number. Distinguished by having either + # leading "v" or at least three non-alpha parts. Alpha part is only + # permitted if there are at least two non-alpha parts. Strangely + # enough, without the leading "v", Perl takes .1.2 to mean v0.1.2, + # so when there is no "v", the leading part is optional + + my $LAX_DOTTED_DECIMAL_VERSION = + qr/ + v $LAX_INTEGER_PART (?: $LAX_DOTTED_DECIMAL_PART+ $LAX_ALPHA_PART? )? + | + $LAX_INTEGER_PART? $LAX_DOTTED_DECIMAL_PART{2,} $LAX_ALPHA_PART? + /x; + + # Complete lax version number syntax -- should generally be used + # anchored: qr/ \A $LAX \z /x + # + # The string 'undef' is a special case to make for easier handling + # of return values from ExtUtils::MM->parse_version + + $LAX = + qr/ undef | $LAX_DECIMAL_VERSION | $LAX_DOTTED_DECIMAL_VERSION /x; + + #--------------------------------------------------------------------------# + + eval "use version::vxs $VERSION"; + if ( $@ ) { # don't have the XS version installed + eval "use version::vpp $VERSION"; # don't tempt fate + die "$@" if ( $@ ); + push @ISA, "version::vpp"; + local $^W; + *version::qv = \&version::vpp::qv; + *version::declare = \&version::vpp::declare; + *version::_VERSION = \&version::vpp::_VERSION; + if ($] >= 5.009000 && $] < 5.011004) { + no strict 'refs'; + *version::stringify = \&version::vpp::stringify; + *{'version::(""'} = \&version::vpp::stringify; + *version::new = \&version::vpp::new; + *version::parse = \&version::vpp::parse; + } + } + else { # use XS module + push @ISA, "version::vxs"; + local $^W; + *version::declare = \&version::vxs::declare; + *version::qv = \&version::vxs::qv; + *version::_VERSION = \&version::vxs::_VERSION; + *version::vcmp = \&version::vxs::VCMP; + if ($] >= 5.009000 && $] < 5.011004) { + no strict 'refs'; + *version::stringify = \&version::vxs::stringify; + *{'version::(""'} = \&version::vxs::stringify; + *version::new = \&version::vxs::new; + *version::parse = \&version::vxs::parse; + } + + } + + # Preloaded methods go here. + sub import { + no strict 'refs'; + my ($class) = shift; + + # Set up any derived class + unless ($class eq 'version') { + local $^W; + *{$class.'::declare'} = \&version::declare; + *{$class.'::qv'} = \&version::qv; + } + + my %args; + if (@_) { # any remaining terms are arguments + map { $args{$_} = 1 } @_ + } + else { # no parameters at all on use line + %args = + ( + qv => 1, + 'UNIVERSAL::VERSION' => 1, + ); + } + + my $callpkg = caller(); + + if (exists($args{declare})) { + *{$callpkg.'::declare'} = + sub {return $class->declare(shift) } + unless defined(&{$callpkg.'::declare'}); + } + + if (exists($args{qv})) { + *{$callpkg.'::qv'} = + sub {return $class->qv(shift) } + unless defined(&{$callpkg.'::qv'}); + } + + if (exists($args{'UNIVERSAL::VERSION'})) { + local $^W; + *UNIVERSAL::VERSION + = \&version::_VERSION; + } + + if (exists($args{'VERSION'})) { + *{$callpkg.'::VERSION'} = \&version::_VERSION; + } + + if (exists($args{'is_strict'})) { + *{$callpkg.'::is_strict'} = \&version::is_strict + unless defined(&{$callpkg.'::is_strict'}); + } + + if (exists($args{'is_lax'})) { + *{$callpkg.'::is_lax'} = \&version::is_lax + unless defined(&{$callpkg.'::is_lax'}); + } + } + + sub is_strict { defined $_[0] && $_[0] =~ qr/ \A $STRICT \z /x } + sub is_lax { defined $_[0] && $_[0] =~ qr/ \A $LAX \z /x } + + 1; +VERSION + +$fatpacked{"Version/Requirements.pm"} = <<'VERSION_REQUIREMENTS'; + use strict; + use warnings; + package Version::Requirements; + BEGIN { + $Version::Requirements::VERSION = '0.101020'; + } + # ABSTRACT: a set of version requirements for a CPAN dist + + + use Carp (); + use Scalar::Util (); + use version 0.77 (); # the ->parse method + + + sub new { + my ($class) = @_; + return bless {} => $class; + } + + sub _version_object { + my ($self, $version) = @_; + + $version = (! defined $version) ? version->parse(0) + : (! Scalar::Util::blessed($version)) ? version->parse($version) + : $version; + + return $version; + } + + + BEGIN { + for my $type (qw(minimum maximum exclusion exact_version)) { + my $method = "with_$type"; + my $to_add = $type eq 'exact_version' ? $type : "add_$type"; + + my $code = sub { + my ($self, $name, $version) = @_; + + $version = $self->_version_object( $version ); + + $self->__modify_entry_for($name, $method, $version); + + return $self; + }; + + no strict 'refs'; + *$to_add = $code; + } + } + + + sub add_requirements { + my ($self, $req) = @_; + + for my $module ($req->required_modules) { + my $modifiers = $req->__entry_for($module)->as_modifiers; + for my $modifier (@$modifiers) { + my ($method, @args) = @$modifier; + $self->$method($module => @args); + }; + } + + return $self; + } + + + sub accepts_module { + my ($self, $module, $version) = @_; + + $version = $self->_version_object( $version ); + + return 1 unless my $range = $self->__entry_for($module); + return $range->_accepts($version); + } + + + sub clear_requirement { + my ($self, $module) = @_; + + return $self unless $self->__entry_for($module); + + Carp::confess("can't clear requirements on finalized requirements") + if $self->is_finalized; + + delete $self->{requirements}{ $module }; + + return $self; + } + + + sub required_modules { keys %{ $_[0]{requirements} } } + + + sub clone { + my ($self) = @_; + my $new = (ref $self)->new; + + return $new->add_requirements($self); + } + + sub __entry_for { $_[0]{requirements}{ $_[1] } } + + sub __modify_entry_for { + my ($self, $name, $method, $version) = @_; + + my $fin = $self->is_finalized; + my $old = $self->__entry_for($name); + + Carp::confess("can't add new requirements to finalized requirements") + if $fin and not $old; + + my $new = ($old || 'Version::Requirements::_Range::Range') + ->$method($version); + + Carp::confess("can't modify finalized requirements") + if $fin and $old->as_string ne $new->as_string; + + $self->{requirements}{ $name } = $new; + } + + + sub is_simple { + my ($self) = @_; + for my $module ($self->required_modules) { + # XXX: This is a complete hack, but also entirely correct. + return if $self->__entry_for($module)->as_string =~ /\s/; + } + + return 1; + } + + + sub is_finalized { $_[0]{finalized} } + + + sub finalize { $_[0]{finalized} = 1 } + + + sub as_string_hash { + my ($self) = @_; + + my %hash = map {; $_ => $self->{requirements}{$_}->as_string } + $self->required_modules; + + return \%hash; + } + + + my %methods_for_op = ( + '==' => [ qw(exact_version) ], + '!=' => [ qw(add_exclusion) ], + '>=' => [ qw(add_minimum) ], + '<=' => [ qw(add_maximum) ], + '>' => [ qw(add_minimum add_exclusion) ], + '<' => [ qw(add_maximum add_exclusion) ], + ); + + sub from_string_hash { + my ($class, $hash) = @_; + + my $self = $class->new; + + for my $module (keys %$hash) { + my @parts = split qr{\s*,\s*}, $hash->{ $module }; + for my $part (@parts) { + my ($op, $ver) = split /\s+/, $part, 2; + + if (! defined $ver) { + $self->add_minimum($module => $op); + } else { + Carp::confess("illegal requirement string: $hash->{ $module }") + unless my $methods = $methods_for_op{ $op }; + + $self->$_($module => $ver) for @$methods; + } + } + } + + return $self; + } + + ############################################################## + + { + package + Version::Requirements::_Range::Exact; + BEGIN { + $Version::Requirements::_Range::Exact::VERSION = '0.101020'; + } + sub _new { bless { version => $_[1] } => $_[0] } + + sub _accepts { return $_[0]{version} == $_[1] } + + sub as_string { return "== $_[0]{version}" } + + sub as_modifiers { return [ [ exact_version => $_[0]{version} ] ] } + + sub _clone { + (ref $_[0])->_new( version->new( $_[0]{version} ) ) + } + + sub with_exact_version { + my ($self, $version) = @_; + + return $self->_clone if $self->_accepts($version); + + Carp::confess("illegal requirements: unequal exact version specified"); + } + + sub with_minimum { + my ($self, $minimum) = @_; + return $self->_clone if $self->{version} >= $minimum; + Carp::confess("illegal requirements: minimum above exact specification"); + } + + sub with_maximum { + my ($self, $maximum) = @_; + return $self->_clone if $self->{version} <= $maximum; + Carp::confess("illegal requirements: maximum below exact specification"); + } + + sub with_exclusion { + my ($self, $exclusion) = @_; + return $self->_clone unless $exclusion == $self->{version}; + Carp::confess("illegal requirements: excluded exact specification"); + } + } + + ############################################################## + + { + package + Version::Requirements::_Range::Range; + BEGIN { + $Version::Requirements::_Range::Range::VERSION = '0.101020'; + } + + sub _self { ref($_[0]) ? $_[0] : (bless { } => $_[0]) } + + sub _clone { + return (bless { } => $_[0]) unless ref $_[0]; + + my ($s) = @_; + my %guts = ( + (exists $s->{minimum} ? (minimum => version->new($s->{minimum})) : ()), + (exists $s->{maximum} ? (maximum => version->new($s->{maximum})) : ()), + + (exists $s->{exclusions} + ? (exclusions => [ map { version->new($_) } @{ $s->{exclusions} } ]) + : ()), + ); + + bless \%guts => ref($s); + } + + sub as_modifiers { + my ($self) = @_; + my @mods; + push @mods, [ add_minimum => $self->{minimum} ] if exists $self->{minimum}; + push @mods, [ add_maximum => $self->{maximum} ] if exists $self->{maximum}; + push @mods, map {; [ add_exclusion => $_ ] } @{$self->{exclusions} || []}; + return \@mods; + } + + sub as_string { + my ($self) = @_; + + return 0 if ! keys %$self; + + return "$self->{minimum}" if (keys %$self) == 1 and exists $self->{minimum}; + + my @exclusions = @{ $self->{exclusions} || [] }; + + my @parts; + + for my $pair ( + [ qw( >= > minimum ) ], + [ qw( <= < maximum ) ], + ) { + my ($op, $e_op, $k) = @$pair; + if (exists $self->{$k}) { + my @new_exclusions = grep { $_ != $self->{ $k } } @exclusions; + if (@new_exclusions == @exclusions) { + push @parts, "$op $self->{ $k }"; + } else { + push @parts, "$e_op $self->{ $k }"; + @exclusions = @new_exclusions; + } + } + } + + push @parts, map {; "!= $_" } @exclusions; + + return join q{, }, @parts; + } + + sub with_exact_version { + my ($self, $version) = @_; + $self = $self->_clone; + + Carp::confess("illegal requirements: exact specification outside of range") + unless $self->_accepts($version); + + return Version::Requirements::_Range::Exact->_new($version); + } + + sub _simplify { + my ($self) = @_; + + if (defined $self->{minimum} and defined $self->{maximum}) { + if ($self->{minimum} == $self->{maximum}) { + Carp::confess("illegal requirements: excluded all values") + if grep { $_ == $self->{minimum} } @{ $self->{exclusions} || [] }; + + return Version::Requirements::_Range::Exact->_new($self->{minimum}) + } + + Carp::confess("illegal requirements: minimum exceeds maximum") + if $self->{minimum} > $self->{maximum}; + } + + # eliminate irrelevant exclusions + if ($self->{exclusions}) { + my %seen; + @{ $self->{exclusions} } = grep { + (! defined $self->{minimum} or $_ >= $self->{minimum}) + and + (! defined $self->{maximum} or $_ <= $self->{maximum}) + and + ! $seen{$_}++ + } @{ $self->{exclusions} }; + } + + return $self; + } + + sub with_minimum { + my ($self, $minimum) = @_; + $self = $self->_clone; + + if (defined (my $old_min = $self->{minimum})) { + $self->{minimum} = (sort { $b cmp $a } ($minimum, $old_min))[0]; + } else { + $self->{minimum} = $minimum; + } + + return $self->_simplify; + } + + sub with_maximum { + my ($self, $maximum) = @_; + $self = $self->_clone; + + if (defined (my $old_max = $self->{maximum})) { + $self->{maximum} = (sort { $a cmp $b } ($maximum, $old_max))[0]; + } else { + $self->{maximum} = $maximum; + } + + return $self->_simplify; + } + + sub with_exclusion { + my ($self, $exclusion) = @_; + $self = $self->_clone; + + push @{ $self->{exclusions} ||= [] }, $exclusion; + + return $self->_simplify; + } + + sub _accepts { + my ($self, $version) = @_; + + return if defined $self->{minimum} and $version < $self->{minimum}; + return if defined $self->{maximum} and $version > $self->{maximum}; + return if defined $self->{exclusions} + and grep { $version == $_ } @{ $self->{exclusions} }; + + return 1; + } + } + + 1; + + __END__ + =pod + +VERSION_REQUIREMENTS + +$fatpacked{"version/vpp.pm"} = <<'VERSION_VPP'; + package charstar; + # a little helper class to emulate C char* semantics in Perl + # so that prescan_version can use the same code as in C + + use overload ( + '""' => \&thischar, + '0+' => \&thischar, + '++' => \&increment, + '--' => \&decrement, + '+' => \&plus, + '-' => \&minus, + '*' => \&multiply, + 'cmp' => \&cmp, + '<=>' => \&spaceship, + 'bool' => \&thischar, + '=' => \&clone, + ); + + sub new { + my ($self, $string) = @_; + my $class = ref($self) || $self; + + my $obj = { + string => [split(//,$string)], + current => 0, + }; + return bless $obj, $class; + } + + sub thischar { + my ($self) = @_; + my $last = $#{$self->{string}}; + my $curr = $self->{current}; + if ($curr >= 0 && $curr <= $last) { + return $self->{string}->[$curr]; + } + else { + return ''; + } + } + + sub increment { + my ($self) = @_; + $self->{current}++; + } + + sub decrement { + my ($self) = @_; + $self->{current}--; + } + + sub plus { + my ($self, $offset) = @_; + my $rself = $self->clone; + $rself->{current} += $offset; + return $rself; + } + + sub minus { + my ($self, $offset) = @_; + my $rself = $self->clone; + $rself->{current} -= $offset; + return $rself; + } + + sub multiply { + my ($left, $right, $swapped) = @_; + my $char = $left->thischar(); + return $char * $right; + } + + sub spaceship { + my ($left, $right, $swapped) = @_; + unless (ref($right)) { # not an object already + $right = $left->new($right); + } + return $left->{current} <=> $right->{current}; + } + + sub cmp { + my ($left, $right, $swapped) = @_; + unless (ref($right)) { # not an object already + if (length($right) == 1) { # comparing single character only + return $left->thischar cmp $right; + } + $right = $left->new($right); + } + return $left->currstr cmp $right->currstr; + } + + sub bool { + my ($self) = @_; + my $char = $self->thischar; + return ($char ne ''); + } + + sub clone { + my ($left, $right, $swapped) = @_; + $right = { + string => [@{$left->{string}}], + current => $left->{current}, + }; + return bless $right, ref($left); + } + + sub currstr { + my ($self, $s) = @_; + my $curr = $self->{current}; + my $last = $#{$self->{string}}; + if (defined($s) && $s->{current} < $last) { + $last = $s->{current}; + } + + my $string = join('', @{$self->{string}}[$curr..$last]); + return $string; + } + + package version::vpp; + use strict; + + use POSIX qw/locale_h/; + use locale; + use vars qw ($VERSION @ISA @REGEXS); + $VERSION = 0.88; + + use overload ( + '""' => \&stringify, + '0+' => \&numify, + 'cmp' => \&vcmp, + '<=>' => \&vcmp, + 'bool' => \&vbool, + 'nomethod' => \&vnoop, + ); + + eval "use warnings"; + if ($@) { + eval ' + package warnings; + sub enabled {return $^W;} + 1; + '; + } + + my $VERSION_MAX = 0x7FFFFFFF; + + # implement prescan_version as closely to the C version as possible + use constant TRUE => 1; + use constant FALSE => 0; + + sub isDIGIT { + my ($char) = shift->thischar(); + return ($char =~ /\d/); + } + + sub isALPHA { + my ($char) = shift->thischar(); + return ($char =~ /[a-zA-Z]/); + } + + sub isSPACE { + my ($char) = shift->thischar(); + return ($char =~ /\s/); + } + + sub BADVERSION { + my ($s, $errstr, $error) = @_; + if ($errstr) { + $$errstr = $error; + } + return $s; + } + + sub prescan_version { + my ($s, $strict, $errstr, $sqv, $ssaw_decimal, $swidth, $salpha) = @_; + my $qv = defined $sqv ? $$sqv : FALSE; + my $saw_decimal = defined $ssaw_decimal ? $$ssaw_decimal : 0; + my $width = defined $swidth ? $$swidth : 3; + my $alpha = defined $salpha ? $$salpha : FALSE; + + my $d = $s; + + if ($qv && isDIGIT($d)) { + goto dotted_decimal_version; + } + + if ($d eq 'v') { # explicit v-string + $d++; + if (isDIGIT($d)) { + $qv = TRUE; + } + else { # degenerate v-string + # requires v1.2.3 + return BADVERSION($s,$errstr,"Invalid version format (dotted-decimal versions require at least three parts)"); + } + + dotted_decimal_version: + if ($strict && $d eq '0' && isDIGIT($d+1)) { + # no leading zeros allowed + return BADVERSION($s,$errstr,"Invalid version format (no leading zeros)"); + } + + while (isDIGIT($d)) { # integer part + $d++; + } + + if ($d eq '.') + { + $saw_decimal++; + $d++; # decimal point + } + else + { + if ($strict) { + # require v1.2.3 + return BADVERSION($s,$errstr,"Invalid version format (dotted-decimal versions require at least three parts)"); + } + else { + goto version_prescan_finish; + } + } + + { + my $i = 0; + my $j = 0; + while (isDIGIT($d)) { # just keep reading + $i++; + while (isDIGIT($d)) { + $d++; $j++; + # maximum 3 digits between decimal + if ($strict && $j > 3) { + return BADVERSION($s,$errstr,"Invalid version format (maximum 3 digits between decimals)"); + } + } + if ($d eq '_') { + if ($strict) { + return BADVERSION($s,$errstr,"Invalid version format (no underscores)"); + } + if ( $alpha ) { + return BADVERSION($s,$errstr,"Invalid version format (multiple underscores)"); + } + $d++; + $alpha = TRUE; + } + elsif ($d eq '.') { + if ($alpha) { + return BADVERSION($s,$errstr,"Invalid version format (underscores before decimal)"); + } + $saw_decimal++; + $d++; + } + elsif (!isDIGIT($d)) { + last; + } + $j = 0; + } + + if ($strict && $i < 2) { + # requires v1.2.3 + return BADVERSION($s,$errstr,"Invalid version format (dotted-decimal versions require at least three parts)"); + } + } + } # end if dotted-decimal + else + { # decimal versions + # special $strict case for leading '.' or '0' + if ($strict) { + if ($d eq '.') { + return BADVERSION($s,$errstr,"Invalid version format (0 before decimal required)"); + } + if ($d eq '0' && isDIGIT($d+1)) { + return BADVERSION($s,$errstr,"Invalid version format (no leading zeros)"); + } + } + + # consume all of the integer part + while (isDIGIT($d)) { + $d++; + } + + # look for a fractional part + if ($d eq '.') { + # we found it, so consume it + $saw_decimal++; + $d++; + } + elsif (!$d || $d eq ';' || isSPACE($d) || $d eq '}') { + if ( $d == $s ) { + # found nothing + return BADVERSION($s,$errstr,"Invalid version format (version required)"); + } + # found just an integer + goto version_prescan_finish; + } + elsif ( $d == $s ) { + # didn't find either integer or period + return BADVERSION($s,$errstr,"Invalid version format (non-numeric data)"); + } + elsif ($d eq '_') { + # underscore can't come after integer part + if ($strict) { + return BADVERSION($s,$errstr,"Invalid version format (no underscores)"); + } + elsif (isDIGIT($d+1)) { + return BADVERSION($s,$errstr,"Invalid version format (alpha without decimal)"); + } + else { + return BADVERSION($s,$errstr,"Invalid version format (misplaced underscore)"); + } + } + elsif ($d) { + # anything else after integer part is just invalid data + return BADVERSION($s,$errstr,"Invalid version format (non-numeric data)"); + } + + # scan the fractional part after the decimal point + if ($d && !isDIGIT($d) && ($strict || ! ($d eq ';' || isSPACE($d) || $d eq '}') )) { + # $strict or lax-but-not-the-end + return BADVERSION($s,$errstr,"Invalid version format (fractional part required)"); + } + + while (isDIGIT($d)) { + $d++; + if ($d eq '.' && isDIGIT($d-1)) { + if ($alpha) { + return BADVERSION($s,$errstr,"Invalid version format (underscores before decimal)"); + } + if ($strict) { + return BADVERSION($s,$errstr,"Invalid version format (dotted-decimal versions must begin with 'v')"); + } + $d = $s; # start all over again + $qv = TRUE; + goto dotted_decimal_version; + } + if ($d eq '_') { + if ($strict) { + return BADVERSION($s,$errstr,"Invalid version format (no underscores)"); + } + if ( $alpha ) { + return BADVERSION($s,$errstr,"Invalid version format (multiple underscores)"); + } + if ( ! isDIGIT($d+1) ) { + return BADVERSION($s,$errstr,"Invalid version format (misplaced underscore)"); + } + $d++; + $alpha = TRUE; + } + } + } + + version_prescan_finish: + while (isSPACE($d)) { + $d++; + } + + if ($d && !isDIGIT($d) && (! ($d eq ';' || $d eq '}') )) { + # trailing non-numeric data + return BADVERSION($s,$errstr,"Invalid version format (non-numeric data)"); + } + + if (defined $sqv) { + $$sqv = $qv; + } + if (defined $swidth) { + $$swidth = $width; + } + if (defined $ssaw_decimal) { + $$ssaw_decimal = $saw_decimal; + } + if (defined $salpha) { + $$salpha = $alpha; + } + return $d; + } + + sub scan_version { + my ($s, $rv, $qv) = @_; + my $start; + my $pos; + my $last; + my $errstr; + my $saw_decimal = 0; + my $width = 3; + my $alpha = FALSE; + my $vinf = FALSE; + my @av; + + $s = new charstar $s; + + while (isSPACE($s)) { # leading whitespace is OK + $s++; + } + + $last = prescan_version($s, FALSE, \$errstr, \$qv, \$saw_decimal, + \$width, \$alpha); + + if ($errstr) { + # 'undef' is a special case and not an error + if ( $s ne 'undef') { + use Carp; + Carp::croak($errstr); + } + } + + $start = $s; + if ($s eq 'v') { + $s++; + } + $pos = $s; + + if ( $qv ) { + $$rv->{qv} = $qv; + } + if ( $alpha ) { + $$rv->{alpha} = $alpha; + } + if ( !$qv && $width < 3 ) { + $$rv->{width} = $width; + } + + while (isDIGIT($pos)) { + $pos++; + } + if (!isALPHA($pos)) { + my $rev; + + for (;;) { + $rev = 0; + { + # this is atoi() that delimits on underscores + my $end = $pos; + my $mult = 1; + my $orev; + + # the following if() will only be true after the decimal + # point of a version originally created with a bare + # floating point number, i.e. not quoted in any way + # + if ( !$qv && $s > $start && $saw_decimal == 1 ) { + $mult *= 100; + while ( $s < $end ) { + $orev = $rev; + $rev += $s * $mult; + $mult /= 10; + if ( (abs($orev) > abs($rev)) + || (abs($rev) > $VERSION_MAX )) { + warn("Integer overflow in version %d", + $VERSION_MAX); + $s = $end - 1; + $rev = $VERSION_MAX; + $vinf = 1; + } + $s++; + if ( $s eq '_' ) { + $s++; + } + } + } + else { + while (--$end >= $s) { + $orev = $rev; + $rev += $end * $mult; + $mult *= 10; + if ( (abs($orev) > abs($rev)) + || (abs($rev) > $VERSION_MAX )) { + warn("Integer overflow in version"); + $end = $s - 1; + $rev = $VERSION_MAX; + $vinf = 1; + } + } + } + } + + # Append revision + push @av, $rev; + if ( $vinf ) { + $s = $last; + last; + } + elsif ( $pos eq '.' ) { + $s = ++$pos; + } + elsif ( $pos eq '_' && isDIGIT($pos+1) ) { + $s = ++$pos; + } + elsif ( $pos eq ',' && isDIGIT($pos+1) ) { + $s = ++$pos; + } + elsif ( isDIGIT($pos) ) { + $s = $pos; + } + else { + $s = $pos; + last; + } + if ( $qv ) { + while ( isDIGIT($pos) ) { + $pos++; + } + } + else { + my $digits = 0; + while ( ( isDIGIT($pos) || $pos eq '_' ) && $digits < 3 ) { + if ( $pos ne '_' ) { + $digits++; + } + $pos++; + } + } + } + } + if ( $qv ) { # quoted versions always get at least three terms + my $len = $#av; + # This for loop appears to trigger a compiler bug on OS X, as it + # loops infinitely. Yes, len is negative. No, it makes no sense. + # Compiler in question is: + # gcc version 3.3 20030304 (Apple Computer, Inc. build 1640) + # for ( len = 2 - len; len > 0; len-- ) + # av_push(MUTABLE_AV(sv), newSViv(0)); + # + $len = 2 - $len; + while ($len-- > 0) { + push @av, 0; + } + } + + # need to save off the current version string for later + if ( $vinf ) { + $$rv->{original} = "v.Inf"; + $$rv->{vinf} = 1; + } + elsif ( $s > $start ) { + $$rv->{original} = $start->currstr($s); + if ( $qv && $saw_decimal == 1 && $start ne 'v' ) { + # need to insert a v to be consistent + $$rv->{original} = 'v' . $$rv->{original}; + } + } + else { + $$rv->{original} = '0'; + push(@av, 0); + } + + # And finally, store the AV in the hash + $$rv->{version} = \@av; + + # fix RT#19517 - special case 'undef' as string + if ($s eq 'undef') { + $s += 5; + } + + return $s; + } + + sub new + { + my ($class, $value) = @_; + my $self = bless ({}, ref ($class) || $class); + my $qv = FALSE; + + if ( ref($value) && eval('$value->isa("version")') ) { + # Can copy the elements directly + $self->{version} = [ @{$value->{version} } ]; + $self->{qv} = 1 if $value->{qv}; + $self->{alpha} = 1 if $value->{alpha}; + $self->{original} = ''.$value->{original}; + return $self; + } + + my $currlocale = setlocale(LC_ALL); + + # if the current locale uses commas for decimal points, we + # just replace commas with decimal places, rather than changing + # locales + if ( localeconv()->{decimal_point} eq ',' ) { + $value =~ tr/,/./; + } + + if ( not defined $value or $value =~ /^undef$/ ) { + # RT #19517 - special case for undef comparison + # or someone forgot to pass a value + push @{$self->{version}}, 0; + $self->{original} = "0"; + return ($self); + } + + if ( $#_ == 2 ) { # must be CVS-style + $value = $_[2]; + $qv = TRUE; + } + + $value = _un_vstring($value); + + # exponential notation + if ( $value =~ /\d+.?\d*e[-+]?\d+/ ) { + $value = sprintf("%.9f",$value); + $value =~ s/(0+)$//; # trim trailing zeros + } + + my $s = scan_version($value, \$self, $qv); + + if ($s) { # must be something left over + warn("Version string '%s' contains invalid data; " + ."ignoring: '%s'", $value, $s); + } + + return ($self); + } + + *parse = \&new; + + sub numify + { + my ($self) = @_; + unless (_verify($self)) { + require Carp; + Carp::croak("Invalid version object"); + } + my $width = $self->{width} || 3; + my $alpha = $self->{alpha} || ""; + my $len = $#{$self->{version}}; + my $digit = $self->{version}[0]; + my $string = sprintf("%d.", $digit ); + + for ( my $i = 1 ; $i < $len ; $i++ ) { + $digit = $self->{version}[$i]; + if ( $width < 3 ) { + my $denom = 10**(3-$width); + my $quot = int($digit/$denom); + my $rem = $digit - ($quot * $denom); + $string .= sprintf("%0".$width."d_%d", $quot, $rem); + } + else { + $string .= sprintf("%03d", $digit); + } + } + + if ( $len > 0 ) { + $digit = $self->{version}[$len]; + if ( $alpha && $width == 3 ) { + $string .= "_"; + } + $string .= sprintf("%0".$width."d", $digit); + } + else # $len = 0 + { + $string .= sprintf("000"); + } + + return $string; + } + + sub normal + { + my ($self) = @_; + unless (_verify($self)) { + require Carp; + Carp::croak("Invalid version object"); + } + my $alpha = $self->{alpha} || ""; + my $len = $#{$self->{version}}; + my $digit = $self->{version}[0]; + my $string = sprintf("v%d", $digit ); + + for ( my $i = 1 ; $i < $len ; $i++ ) { + $digit = $self->{version}[$i]; + $string .= sprintf(".%d", $digit); + } + + if ( $len > 0 ) { + $digit = $self->{version}[$len]; + if ( $alpha ) { + $string .= sprintf("_%0d", $digit); + } + else { + $string .= sprintf(".%0d", $digit); + } + } + + if ( $len <= 2 ) { + for ( $len = 2 - $len; $len != 0; $len-- ) { + $string .= sprintf(".%0d", 0); + } + } + + return $string; + } + + sub stringify + { + my ($self) = @_; + unless (_verify($self)) { + require Carp; + Carp::croak("Invalid version object"); + } + return exists $self->{original} + ? $self->{original} + : exists $self->{qv} + ? $self->normal + : $self->numify; + } + + sub vcmp + { + require UNIVERSAL; + my ($left,$right,$swap) = @_; + my $class = ref($left); + unless ( UNIVERSAL::isa($right, $class) ) { + $right = $class->new($right); + } + + if ( $swap ) { + ($left, $right) = ($right, $left); + } + unless (_verify($left)) { + require Carp; + Carp::croak("Invalid version object"); + } + unless (_verify($right)) { + require Carp; + Carp::croak("Invalid version object"); + } + my $l = $#{$left->{version}}; + my $r = $#{$right->{version}}; + my $m = $l < $r ? $l : $r; + my $lalpha = $left->is_alpha; + my $ralpha = $right->is_alpha; + my $retval = 0; + my $i = 0; + while ( $i <= $m && $retval == 0 ) { + $retval = $left->{version}[$i] <=> $right->{version}[$i]; + $i++; + } + + # tiebreaker for alpha with identical terms + if ( $retval == 0 + && $l == $r + && $left->{version}[$m] == $right->{version}[$m] + && ( $lalpha || $ralpha ) ) { + + if ( $lalpha && !$ralpha ) { + $retval = -1; + } + elsif ( $ralpha && !$lalpha) { + $retval = +1; + } + } + + # possible match except for trailing 0's + if ( $retval == 0 && $l != $r ) { + if ( $l < $r ) { + while ( $i <= $r && $retval == 0 ) { + if ( $right->{version}[$i] != 0 ) { + $retval = -1; # not a match after all + } + $i++; + } + } + else { + while ( $i <= $l && $retval == 0 ) { + if ( $left->{version}[$i] != 0 ) { + $retval = +1; # not a match after all + } + $i++; + } + } + } + + return $retval; + } + + sub vbool { + my ($self) = @_; + return vcmp($self,$self->new("0"),1); + } + + sub vnoop { + require Carp; + Carp::croak("operation not supported with version object"); + } + + sub is_alpha { + my ($self) = @_; + return (exists $self->{alpha}); + } + + sub qv { + my $value = shift; + my $class = 'version'; + if (@_) { + $class = ref($value) || $value; + $value = shift; + } + + $value = _un_vstring($value); + $value = 'v'.$value unless $value =~ /(^v|\d+\.\d+\.\d)/; + my $version = $class->new($value); + return $version; + } + + *declare = \&qv; + + sub is_qv { + my ($self) = @_; + return (exists $self->{qv}); + } + + + sub _verify { + my ($self) = @_; + if ( ref($self) + && eval { exists $self->{version} } + && ref($self->{version}) eq 'ARRAY' + ) { + return 1; + } + else { + return 0; + } + } + + sub _is_non_alphanumeric { + my $s = shift; + $s = new charstar $s; + while ($s) { + return 0 if isSPACE($s); # early out + return 1 unless (isALPHA($s) || isDIGIT($s) || $s =~ /[.-]/); + $s++; + } + return 0; + } + + sub _un_vstring { + my $value = shift; + # may be a v-string + if ( length($value) >= 3 && $value !~ /[._]/ + && _is_non_alphanumeric($value)) { + my $tvalue; + if ( $] ge 5.008_001 ) { + $tvalue = _find_magic_vstring($value); + $value = $tvalue if length $tvalue; + } + elsif ( $] ge 5.006_000 ) { + $tvalue = sprintf("v%vd",$value); + if ( $tvalue =~ /^v\d+(\.\d+){2,}$/ ) { + # must be a v-string + $value = $tvalue; + } + } + } + return $value; + } + + sub _find_magic_vstring { + my $value = shift; + my $tvalue = ''; + require B; + my $sv = B::svref_2object(\$value); + my $magic = ref($sv) eq 'B::PVMG' ? $sv->MAGIC : undef; + while ( $magic ) { + if ( $magic->TYPE eq 'V' ) { + $tvalue = $magic->PTR; + $tvalue =~ s/^v?(.+)$/v$1/; + last; + } + else { + $magic = $magic->MOREMAGIC; + } + } + return $tvalue; + } + + sub _VERSION { + my ($obj, $req) = @_; + my $class = ref($obj) || $obj; + + no strict 'refs'; + if ( exists $INC{"$class.pm"} and not %{"$class\::"} and $] >= 5.008) { + # file but no package + require Carp; + Carp::croak( "$class defines neither package nor VERSION" + ."--version check failed"); + } + + my $version = eval "\$$class\::VERSION"; + if ( defined $version ) { + local $^W if $] <= 5.008; + $version = version::vpp->new($version); + } + + if ( defined $req ) { + unless ( defined $version ) { + require Carp; + my $msg = $] < 5.006 + ? "$class version $req required--this is only version " + : "$class does not define \$$class\::VERSION" + ."--version check failed"; + + if ( $ENV{VERSION_DEBUG} ) { + Carp::confess($msg); + } + else { + Carp::croak($msg); + } + } + + $req = version::vpp->new($req); + + if ( $req > $version ) { + require Carp; + if ( $req->is_qv ) { + Carp::croak( + sprintf ("%s version %s required--". + "this is only version %s", $class, + $req->normal, $version->normal) + ); + } + else { + Carp::croak( + sprintf ("%s version %s required--". + "this is only version %s", $class, + $req->stringify, $version->stringify) + ); + } + } + } + + return defined $version ? $version->stringify : undef; + } + + 1; #this line is important and will help the module return a true value +VERSION_VPP + +s/^ //mg for values %fatpacked; + +unshift @INC, sub { + if (my $fat = $fatpacked{$_[1]}) { + open my $fh, '<', \$fat + or die "FatPacker error loading $_[1] (could be a perl installation issue?)"; + return $fh; + } + return +}; + +} # END OF FATPACK CODE + +use strict; +use App::cpanminus::script; + +unless (caller) { + my $app = App::cpanminus::script->new; + $app->parse_options(@ARGV); + $app->doit or exit(1); +} + +__END__ + +=head1 NAME + +cpanm - get, unpack build and install modules from CPAN + +=head1 SYNOPSIS + + cpanm Test::More # install Test::More + cpanm MIYAGAWA/Plack-0.99_05.tar.gz # full distribution path + cpanm http://example.org/LDS/CGI.pm-3.20.tar.gz # install from URL + cpanm ~/dists/MyCompany-Enterprise-1.00.tar.gz # install from a local file + cpanm --interactive Task::Kensho # Configure interactively + cpanm . # install from local directory + cpanm --installdeps . # install all the deps for the current directory + cpanm -L extlib Plack # install Plack and all non-core deps into extlib + cpanm --mirror http://cpan.cpantesters.org/ DBI # use the fast-syncing mirror + cpanm --scandeps Moose # See what modules will be installed for Moose + +=head1 COMMANDS + +=over 4 + +=item -i, --install + +Installs the modules. This is a default behavior and this is just a +compatibility option to make it work like L or L. + +=item --self-upgrade + +Upgrades itself. It's just an alias for: + + cpanm App::cpanminus + +=item --info + +Displays the distribution information in +C format in the standard out. + +=item --installdeps + +Installs the dependencies of the target distribution but won't build +itself. Handy if you want to try the application from a version +controlled repository such as git. + + cpanm --installdeps . + +=item --look + +Download and unpack the distribution and then open the directory with +your shell. Handy to poke around the source code or do manual +testing. + +=item -h, --help + +Displays the help message. + +=item -V, --version + +Displays the version number. + +=back + +=head1 OPTIONS + +You can specify the default options in C environment variable. + +=over 4 + +=item -f, --force + +Force install modules even when testing failed. + +=item -n, --notest + +Skip the testing of modules. Use this only when you just want to save +time for installing hundreds of distributions to the same perl and +architecture you've already tested to make sure it builds fine. + +Defaults to false, and you can say C<--no-notest> to override when it +is set in the default options in C. + +=item -S, --sudo + +Switch to the root user with C when installing modules. Use this +if you want to install modules to the system perl include path. + +Defaults to false, and you can say C<--no-sudo> to override when it is +set in the default options in C. + +=item -v, --verbose + +Makes the output verbose. It also enables the interactive +configuration. (See --interactive) + +=item -q, --quiet + +Makes the output even more quiet than the default. It doesn't print +anything to the STDERR. + +=item -l, --local-lib + +Sets the L compatible path to install modules to. You +don't need to set this if you already configure the shell environment +variables using L, but this can be used to override that +as well. + +=item -L, --local-lib-contained + +Same with C<--local-lib> but when examining the dependencies, it +assumes no non-core modules are installed on the system. It's handy if +you want to bundle application dependencies in one directory so you +can distribute to other machines. + +For instance, + + cpanm -L extlib Plack + +would install Plack and all of its non-core dependencies into the +directory C, which can be loaded from your application with: + + use local::lib '/path/to/extlib'; + +=item --mirror + +Specifies the base URL for the CPAN mirror to use, such as +C (you can omit the trailing slash). You +can specify multiple mirror URLs by repeating the command line option. + +Defaults to C which is a geo location +aware redirector. + +=item --mirror-only + +Download the mirror's 02packages.details.txt.gz index file instead of +querying the CPAN Meta DB. + +Select this option if you are using a local mirror of CPAN, such as +minicpan when you're offline, or your own CPAN index (a.k.a darkpan). + +B It might be useful if you name these mirror options with your +shell aliases, like: + + alias minicpanm='cpanm --mirror ~/minicpan --mirror-only' + alias darkpan='cpanm --mirror http://mycompany.example.com/DPAN --mirror-only' + +=item --mirror-index + +B: Specifies the file path to C<02packages.details.txt> +for module search index. + +=item --metacpan + +B: Use L API for module lookup instead of +L. + +=item --prompt + +Prompts when a test fails so that you can skip, force install, retry +or look in the shell to see what's going wrong. It also prompts when +one of the dependency failed if you want to proceed the installation. + +Defaults to false, and you can say C<--no-prompt> to override if it's +set in the default options in C. + +=item --reinstall + +cpanm, when given a module name in the command line (i.e. C), checks the locally installed version first and skips if it is +already installed. This option makes it skip the check, so: + + cpanm --reinstall Plack + +would reinstall L even if your locally installed version is +latest, or even newer (which would happen if you install a developer +release from version control repositories). + +Defaults to false. + +=item --interactive + +Makes the configuration (such as C and C) +interactive, so you can answer questions in the distribution that +requires custom configuration or Task:: distributions. + +Defaults to false, and you can say C<--no-interactive> to override +when it's set in the default options in C. + +=item --scandeps + +Scans the depencencies of given modules and output the tree in a text +format. (See C<--format> below for more options) + +Because this command doesn't actually install any distributions, it +will be useful that by typing: + + cpanm --scandeps Catalyst::Runtime + +you can make sure what modules will be installed. + +This command takes into account which modules you already have +installed in your system. If you want to see what modules will be +installed against a vanilla perl installation, you might want to +combine it with C<-L> option. + +=item --format + +Determines what format to display the scanned dependency +tree. Available options are C, C, C and C. + +=over 8 + +=item tree + +Displays the tree in a plain text format. This is the default value. + +=item json, yaml + +Outputs the tree in a JSON or YAML format. L and L modules +need to be installed respectively. The output tree is represented as a +recursive tuple of: + + [ distribution, dependencies ] + +and the container is an array containing the root elements. Note that +there may be multiple root nodes, since you can give multiple modules +to the C<--scandeps> command. + +=item dists + +C is a special output format, where it prints the distribution +filename in the I after the dependency resolution, +like: + + GAAS/MIME-Base64-3.13.tar.gz + GAAS/URI-1.58.tar.gz + PETDANCE/HTML-Tagset-3.20.tar.gz + GAAS/HTML-Parser-3.68.tar.gz + GAAS/libwww-perl-5.837.tar.gz + +which means you can install these distributions in this order without +extra dependencies. When combined with C<-L> option, it will be useful +to replay installations on other machines. + +=back + +=item --save-dists + +Specifies the optional directory path to copy downloaded tarballs in +the CPAN mirror compatible directory structure +i.e. I + +=item --uninst-shadows + +Uninstalls the shadow files of the distribution that you're +installing. This eliminates the confusion if you're trying to install +core (dual-life) modules from CPAN against perl 5.10 or older, or +modules that used to be XS-based but switched to pure perl at some +version. + +If you run cpanm as root and use C or equivalent to +specify custom installation path, you SHOULD disable this option so +you won't accidentally uninstall dual-life modules from the core +include path. + +Defaults to true if your perl version is smaller than 5.12, and you +can disable that with C<--no-uninst-shadows>. + +B: Since version 1.3000 this flag is turned off by default for +perl newer than 5.12, since with 5.12 @INC contains site_perl directory +I the perl core library path, and uninstalling shadows is not +necessary anymore and does more harm by deleting files from the core +library path. + +=item --cascade-search + +B: Specifies whether to cascade search when you specify +multiple mirrors and a mirror has a lower version of the module than +requested. Defaults to false. + +=item --skip-installed + +Specifies whether a module given in the command line is skipped if its latest +version is already installed. Defaults to true. + +B: The C environment variable have to be correctly set for this +to work with modules installed using L. + +=item --skip-satisfied + +B: Specifies whether a module (and version) given in the +command line is skipped if it's already installed. + +If you run: + + cpanm --skip-satisfied CGI DBI~1.2 + +cpanm won't install them if you already have CGI (for whatever +versions) or have DBI with version higher than 1.2. It is similar to +C<--skip-installed> but while C<--skip-installed> checks if the +I version of CPAN is installed, C<--skip-satisfied> checks if +a requested version (or not, which means any version) is installed. + +Defaults to false for bare module names, but if you specify versions +with C<~>, it will always skip satisfied requirements. + +=item --auto-cleanup + +Specifies the number of days in which cpanm's work directories +expire. Defaults to 7, which means old work directories will be +cleaned up in one week. + +You can set the value to C<0> to make cpan never cleanup those +directories. + +=item --man-pages + +Generates man pages for executables (man1) and libraries (man3). + +Defaults to false (no man pages generated) if +C<-L|--local-lib-contained> option is supplied. Otherwise, defaults to +true, and you can disable it with C<--no-man-pages>. + +=item --lwp + +Uses L module to download stuff over HTTP. Defaults to true, and +you can say C<--no-lwp> to disable using LWP, when you want to upgrade +LWP from CPAN on some broken perl systems. + +=item --wget + +Uses GNU Wget (if available) to download stuff. Defaults to true, and +you can say C<--no-wget> to disable using Wget (versions of Wget older +than 1.9 don't support the C<--retry-connrefused> option used by cpanm). + +=item --curl + +Uses cURL (if available) to download stuff. Defaults to true, and +you can say C<--no-curl> to disable using cURL. + +Normally with C<--lwp>, C<--wget> and C<--curl> options set to true +(which is the default) cpanm tries L, Wget, cURL and L +(in that order) and uses the first one available. + +=back + +=head1 SEE ALSO + +L + +=head1 COPYRIGHT + +Copyright 2010 Tatsuhiko Miyagawa. + +=head1 AUTHOR + +Tatsuhiko Miyagawa + +=cut diff --git a/bin/draw-basepair-track.pl b/bin/draw-basepair-track.pl new file mode 100755 index 00000000..9050ebfa --- /dev/null +++ b/bin/draw-basepair-track.pl @@ -0,0 +1,201 @@ +#!/usr/bin/env perl + +=head1 NAME + +draw-basepair-track.pl - make a track that draws semicircular diagrams of DNA base pairing + +=head1 USAGE + + bin/draw-basepair-track.pl \ + --gff \ + [ --out ] \ + [ --tracklabel ] \ + [ --key ] \ + [ --bgcolor ] \ + [ --fgcolor ] \ + [ --thickness ] \ + [ --width ] \ + [ --height ] \ + [ --nolinks ] + +=head1 OPTIONS + +=over 4 + +=item --out + +Data directory to write to. Defaults to C. + +=item --trackLabel + +Unique name for the track. Defaults to the wiggle filename. + +=item --key + +Human-readable name for the track. Defaults to be the same as the +C<--trackLabel>. + +=item --bgcolor + +Background color for the image track. Defaults to C<255,255,255>, +which is white. + +=item --fgcolor + +Foreground color for the track, i.e. the color of the lines that are +drawn. Defaults to C<0,255,0>, which is bright green. + +=item --thickness + +Thickness in pixels of the drawn lines. Defaults to 2. + +=item --width + +Width in pixels of each image tile. Defaults to 2000. + +=item --height + +Height in pixels of the image track. Defaults to 100. + +=item --nolinks + +If passed, do not use filesystem hardlinks to compress duplicate +tiles. + +=back + +=cut + +use strict; +use warnings; + +use FindBin qw($Bin); +use lib "$Bin/../"; +use JBlibs; + +use File::Basename; +use Getopt::Long; +use List::Util 'max'; +use Pod::Usage; +use POSIX qw (abs ceil); + +use ImageTrackRenderer; + +my ($path, $trackLabel, $key, $cssClass); +my $outdir = "data"; +my $tiledir = "tiles"; +my $fgColor = "0,255,0"; +my $bgColor = "255,255,255"; +my $tileWidth = 2000; +my $trackHeight = 100; +my $thickness = 2; +my $nolinks = 0; +my $help; + +GetOptions( "gff=s" => \$path, + "out=s" => \$outdir, + "tracklabel|trackLabel=s" => \$trackLabel, + "key=s" => \$key, + "bgcolor=s" => \$bgColor, + "fgcolor=s" => \$fgColor, + "width=s" => \$tileWidth, + "height=s" => \$trackHeight, + "thickness=s" => \$thickness, + "nolinks" => \$nolinks, + "help|h|?" => \$help, +) or pod2usage(); + +pod2usage( -verbose => 2 ) if $help; +pod2usage( 'must provide a --gff file' ) unless defined $path; + +unless( defined $trackLabel ) { + $trackLabel = $path; + $trackLabel =~ s/\//_/g; # get rid of directory separators +} + +# create color ranges +my @fg = split (/,/, $fgColor); +my @bg = split (/,/, $bgColor); + +# make ( [R,G,B], [R,G,B], ... ) color triplets for each color index +# that interpolate between the foreground and background colors +my $range = max map abs($fg[$_] - $bg[$_]), 0..2; +my @rgb = map { + my $n = $_; + [ map { + $bg[$_] + $n/$range * ( $fg[$_] - $bg[$_] ) + } 0..2 + ] +} 0..$range; + +my ( $gff, $maxscore, $minscore, $maxlen ) = read_gff( $path ); + +# convert GFF scores into color indices, then sort each sequence's GFF +# features by increasing color index +while (my ($seqname, $gffArray) = each %$gff) { + @$gffArray = + sort { $a->[2] <=> $b->[2] } + map [ $_->[0], + $_->[1], + $_->[2] =~ /\d/ ? int( 0.5 + $range * ($_->[2] - $minscore) / ($maxscore - $minscore) ) + : $range + ], + @$gffArray; +} + +# create the renderer +my $renderer = ImageTrackRenderer->new( + "datadir" => $outdir, + "tilewidth" => $tileWidth, + "trackheight" => $trackHeight, + "tracklabel" => $trackLabel, + "key" => $key, + "link" => !$nolinks, + "drawsub" => sub { + my ($im, $seqInfo) = @_; + my $seqname = $seqInfo->{"name"}; + my @color; + for my $rgb (@rgb) { + push @color, $im->colorAllocate (@$rgb); + } + $im->setThickness ($thickness); + for my $gff (@{ $gff->{$seqname} || [] }) { + my $start = $im->base_xpos ($gff->[0]) + $im->pixels_per_base / 2; + my $end = $im->base_xpos ($gff->[1]) + $im->pixels_per_base / 2; + my $arcMidX = ($start + $end) / 2; + my $arcWidth = $end - $start; + my $arcHeight = 2 * $trackHeight * ($gff->[1] - $gff->[0]) / $maxlen; + # warn "Drawing arc from $start to $end, height $arcHeight"; + $im->arc ($arcMidX, 0, $arcWidth, $arcHeight, 0, 180, $color[$gff->[2]]); + } + }); + +# run the renderer +$renderer->render; + +# all done +exit; + +############################# + +# load GFF describing basepair locations & intensities; sort by seqname +sub read_gff { + my %gff; + open my $gff, "<", $path or die "$! reading $path"; + my ($maxscore, $minscore, $maxlen); + while (my $gffLine = <$gff>) { + next if $gffLine =~ /^\s*\#/; + next unless $gffLine =~ /\S/; + my ($seqname, $source, $feature, $start, $end, $score, $strand, $frame, $group) = split /\t/, $gffLine, 9; + next if grep (!defined(), $seqname, $start, $end, $score); + $gff{$seqname} = [] unless exists $gff{$seqname}; + push @{$gff{$seqname}}, [$start, $end, $score]; + if ($score =~ /\d/) { + $maxscore = $score if !defined($maxscore) || $score > $maxscore; + $minscore = $score if !defined($minscore) || $score < $minscore; + } + my $len = $end - $start; + $maxlen = $len if !defined($maxlen) || $len > $maxlen; + } + return ( \%gff,$maxscore, $minscore, $maxlen ); +} diff --git a/bin/flatfile-to-json.pl b/bin/flatfile-to-json.pl new file mode 100755 index 00000000..81b443d6 --- /dev/null +++ b/bin/flatfile-to-json.pl @@ -0,0 +1,161 @@ +#!/usr/bin/env perl +use strict; +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Bio::JBrowse::Cmd::FlatFileToJson; + +exit Bio::JBrowse::Cmd::FlatFileToJson->new(@ARGV)->run; + +__END__ + +=head1 NAME + +flatfile-to-json.pl - format data into JBrowse JSON format from an annotation file + +=head1 USAGE + + flatfile-to-json.pl \ + ( --gff | --bed | --gbk ) \ + --trackLabel \ + [ --trackType ] \ + [ --out ] \ + [ --key ] \ + [ --className ] \ + [ --urltemplate "http://example.com/idlookup?id={id}" ] \ + [ --arrowheadClass ] \ + [ --noSubfeatures ] \ + [ --subfeatureClasses '{ JSON-format subfeature class map }' ] \ + [ --clientConfig '{ JSON-format extra configuration for this track }' ] \ + [ --thinType ] \ + [ --thicktype ] \ + [ --type ] \ + [ --nclChunk ] \ + [ --compress ] \ + [ --sortMem ] \ + +=head1 ARGUMENTS + +=head2 Required + +=over 4 + +=item --gff + +=item --bed + +=item --gbk + +Process a GFF3, BED, or GenBank file containing annotation data. + +This script does not support GFF version 2 or GTF (GFF 2.5) input. +The GenBank input adaptor is limited to handling records for single +genes. + +=item --trackLabel + +Unique identifier for this track. Required. + +=back + +=head2 Optional + +=over 4 + +=item --help | -h | -? + +Display an extended help screen. + +=item --key '' + +Human-readable track name. + +=item --out + +Output directory to write to. Defaults to "data/". + +=item --trackType JBrowse/View/Track/HTMLFeatures + +Optional JavaScript class to use to display this track. Defaults to +JBrowse/View/Track/HTMLFeatures. + +=item --className + +CSS class for features. Defaults to "feature". + +=item --urltemplate "http://example.com/idlookup?id={id}" + +Template for a URL to be visited when features are clicked on. + +=item --noSubfeatures + +Do not format subfeature data. + +=item --arrowheadClass + +CSS class for arrowheads. + +=item --subfeatureClasses '{ JSON-format subfeature class map }' + +CSS classes for each subfeature type, in JSON syntax. Example: + + --subfeatureClasses '{"CDS": "transcript-CDS", "exon": "transcript-exon"}' + +=item --clientConfig '{ JSON-format extra configuration for this track }' + +Extra configuration for the client, in JSON syntax. Example: + + --clientConfig '{"featureCss": "background-color: #668; height: 8px;", "histScale": 2}' + +=item --type + +Only process features of the given type. Can take either single type +names, e.g. "mRNA", or type names qualified by "source" name, for +whatever definition of "source" your data file might have. For +example, "mRNA:exonerate" will filter for only mRNA features that have +a source of "exonerate". + +Multiple type names can be specified by separating the type names with +commas, e.g. C<--type mRNA:exonerate,ncRNA>. + +=item --nclChunk + +NCList chunk size; if you get "json text or perl structure exceeds +maximum nesting level" errors, try setting this lower (default: +50,000). + +=item --compress + +Compress the output, making .jsonz (gzipped) JSON files. This can +save a lot of disk space, but note that web servers require some +additional configuration to serve these correctly. + +=item --sortMem + +Bytes of RAM to use for sorting features. Default 512MB. + +=back + +=head2 BED-specific + +=over 4 + +=item --thinType + +=item --thickType + +Correspond to C<<-thin_type>> and C<<-thick_type>> in +L. Do C<> for +details. + +=back + +=head1 MEMORY USAGE + +For efficient memory usage, it is very important that large GFF3 files +have C<###> lines in them periodically. For details of what C<###> is +and how it is used, see the GFF3 specification at +L. + +=cut diff --git a/bin/generate-names.pl b/bin/generate-names.pl new file mode 100755 index 00000000..d4ac2c42 --- /dev/null +++ b/bin/generate-names.pl @@ -0,0 +1,389 @@ +#!/usr/bin/env perl + +=head1 NAME + +generate-names.pl - generate a global index of feature names + +=head1 USAGE + + generate-names.pl \ + [ --out ] \ + [ --verbose ] + +=head1 OPTIONS + +=over 4 + +=item --out + +Data directory to process. Default 'data/'. + +=item --tracks [,...] + +Comma-separated list of which tracks to include in the names index. If +not passed, all tracks are indexed. + +=item --locationLimit + +Maximum number of distinct locations to store for a single name. Default 100. + +=item --sortMem + +Number of bytes of RAM we are allowed to use for sorting memory. +Default 256000000 (256 MiB). If you machine has enough RAM, +increasing this amount can speed up this script's running time +significantly. + +=item --workdir + +Use the given location for building the names index, copying the index +over to the destination location when fully built. By default, builds +the index in the output location. + +Name indexing is a very I/O intensive operation, because the +filesystem is used to store intermediate results in order to keep the +RAM usage reasonable. If a fast filesystem (e.g. tmpfs) is available +and large enough, indexing can be speeded up by using it to store the +intermediate results of name indexing. + +=item --completionLimit + +Maximum number of name completions to store for a given prefix. +Default 20. Set to 0 to disable auto-completion of feature names. +Note that the name index always contains exact matches for feature +names; this setting only disables autocompletion based on incomplete +names. + +=item --totalNames + +Optional estimate of the total number of names that will go into this +names index. Used to choose some parameters for how the name index is +built. If not passed, tries to estimate this based on the size of the +input names files. + +=item --verbose + +Print more progress messages. + +=item --help | -h | -? + +Print a usage message. + +=back + +=cut + +use strict; +use warnings; + +use FindBin qw($Bin); +use lib "$Bin/../"; +use JBlibs; + +use Fcntl ":flock"; +use File::Spec::Functions; +use Getopt::Long; +use Pod::Usage; +use List::Util qw/ sum min max /; + +use PerlIO::gzip; + +use JSON 2; + +use Bio::JBrowse::HashStore; + +use GenomeDB; + +my @includedTrackNames; + +my $outDir = "data"; +my $workDir; +my $verbose = 0; +my $help; +my $max_completions = 20; +my $max_locations = 100; +my $thresh; +my $sort_mem = 256 * 2**20; +my $est_total_name_records; +my $hash_bits; +GetOptions("dir|out=s" => \$outDir, + "completionLimit=i" => \$max_completions, + "locationLimit=i" => \$max_locations, + "verbose+" => \$verbose, + "thresh=i" => \$thresh, + "sortMem=i" => \$sort_mem, + "workdir=s" => \$workDir, + "totalNames=i" => \$est_total_name_records, + 'tracks=s' => \@includedTrackNames, + 'hashBits=i' => \$hash_bits, + "help|h|?" => \$help) or pod2usage(); + +my %includedTrackNames = map { $_ => 1 } + map { split ',', $_ } + @includedTrackNames; + +pod2usage( -verbose => 2 ) if $help; + +unless (-d $outDir) { + die <new( $outDir ); + +my @refSeqs = @{ $gdb->refSeqs }; +unless( @refSeqs ) { + die "No reference sequences defined in configuration, nothing to do.\n"; +} +my @tracks = grep { !%includedTrackNames || $includedTrackNames{ $_->{label} } } + @{ $gdb->trackList || [] }; +unless( @tracks ) { + die "No tracks defined in configuration, nothing to do.\n"; +} + +if( $verbose ) { + print STDERR "Tracks:\n".join('', map " $_->{label}\n", @tracks ); +} + +# read the name list for each track that has one +my $trackNum = 0; + +# find the names files we will be working with +my @names_files = find_names_files(); +if( ! @names_files ) { + warn "WARNING: No feature names found for indexing, only reference sequence names will be indexed.\n"; +} + +#print STDERR "Names files:\n", map " $_->{fullpath}\n", @names_files; + +# estimate the total number of name records we probably have based on the input file sizes +$est_total_name_records ||= int( (sum( map { -s $_->{fullpath} } @names_files )||0) / 70 ); +if( $verbose ) { + print STDERR "Estimated $est_total_name_records total name records to index.\n"; +} + +my $nameStore = Bio::JBrowse::HashStore->open( + dir => catdir( $outDir, "names" ), + work_dir => $workDir, + empty => 1, + sort_mem => $sort_mem, + + # set the hash size to try to get about 50KB per file, at an + # average of about 500 bytes per name record, for about 100 + # records per file. if the store has existing data in it, this + # will be ignored + hash_bits => $hash_bits || ( + $est_total_name_records + ? sprintf('%0.0f',max( 4, min( 32, 4*int( log( ($est_total_name_records||0) / 100 )/ 4 / log(2)) ))) + : 12 + ), +); + +if( $verbose ) { + print STDERR "Using ".$nameStore->{hash_bits}."-bit hashing.\n"; +} + +# insert a name record for all of the reference sequences + +my $name_records_iterator = sub {}; +my @namerecord_buffer; +for my $ref ( @refSeqs ) { + push @namerecord_buffer, [ @{$ref}{ qw/ name length name seqDir start end seqChunkSize/ }]; +} + + +my %trackHash; +my @tracksWithNames; +my $record_stream = sub { + while( ! @namerecord_buffer ) { + my $nameinfo = $name_records_iterator->() || do { + my $file = shift @names_files; + return unless $file; + #print STDERR "Processing $file->{fullpath}\n"; + $name_records_iterator = make_names_iterator( $file ); + $name_records_iterator->(); + } or return; + foreach my $alias ( @{$nameinfo->[0]} ) { + my $track = $nameinfo->[1]; + unless ( defined $trackHash{$track} ) { + $trackHash{$track} = $trackNum++; + push @tracksWithNames, $track; + } + push @namerecord_buffer, [ + $alias, + $trackHash{$track}, + @{$nameinfo}[2..$#{$nameinfo}] + ]; + } + } + return shift @namerecord_buffer; +}; + +# convert the stream of name records into a stream of operations to do +# on the data in the hash store +my @operation_buffer; +my $operation_stream = sub { + unless( @operation_buffer ) { + if( my $name_record = $record_stream->() ) { + push @operation_buffer, make_operations( $name_record ); + } + } + return shift @operation_buffer; +}; + +# sort the stream by hash key to improve cache locality (very +# important for performance) +my $entry_stream = $nameStore->sort_stream( $operation_stream ); + +# now write it to the store +while( my $entry = $entry_stream->() ) { + do_operation( $entry, $entry->data ); +} + +# store the list of tracks that have names +$nameStore->{meta}{track_names} = \@tracksWithNames; +# record the fact that all the keys are lowercased +$nameStore->{meta}{lowercase_keys} = 1; + +# set up the name store in the trackList.json +$gdb->modifyTrackList( sub { + my ( $data ) = @_; + $data->{names}{type} = 'Hash'; + $data->{names}{url} = 'names/'; + return $data; +}); + +exit; + +################ HELPER SUBROUTINES ############################## + +sub find_names_files { + my @files; + for my $track (@tracks) { + for my $ref (@refSeqs) { + my $dir = catdir( $outDir, + "tracks", + $track->{label}, + $ref->{name} + ); + + # read either names.txt or names.json files + my $name_records_iterator; + my $names_txt = catfile( $dir, 'names.txt' ); + if( -f $names_txt ) { + push @files, { fullpath => $names_txt, type => 'txt' }; + } + else { + my $names_json = catfile( $dir, 'names.json' ); + if( -f $names_json ) { + push @files, { fullpath => $names_json, type => 'json', namestxt => $names_txt }; + } + } + } + } + return @files; +} + +use constant OP_ADD_EXACT => 1; +use constant OP_ADD_PREFIX => 2; + +sub make_operations { + my ( $record ) = @_; + + my $lc_name = lc $record->[0]; + + my @ops = ( [ $lc_name, OP_ADD_EXACT, $record ] ); + + if( $max_completions > 0 ) { + # generate all the prefixes + my $l = $lc_name; + chop $l; + while ( $l ) { + push @ops, [ $l, OP_ADD_PREFIX, $record->[0] ]; + chop $l; + } + } + + return @ops; +} + + +sub do_operation { + my ( $store_entry, $op ) = @_; + + my ( $lc_name, $op_name, $record ) = @$op; + + my $r = $store_entry->get || { exact => [], prefix => [] }; + + if( $op_name == OP_ADD_EXACT ) { + if( $max_locations && @{ $r->{exact} } < $max_locations ) { + push @{ $r->{exact} }, $record; + $store_entry->set( $r ); + } + # elsif( $verbose ) { + # print STDERR "Warning: $name has more than --locationLimit ($max_locations) distinct locations, not all of them will be indexed.\n"; + # } + } + elsif( $op_name == OP_ADD_PREFIX ) { + my $name = $record; + + my $p = $r->{prefix}; + if( @$p < $max_completions ) { + if( ! grep $name eq $_, @$p ) { + push @{ $r->{prefix} }, $name; + $store_entry->set( $r ); + } + } + elsif( @{ $r->{prefix} } == $max_completions ) { + push @{ $r->{prefix} }, { name => 'too many matches', hitLimit => 1 }; + $store_entry->set( $r ); + } + } +} + + +# each of these takes an input filename and returns a subroutine that +# returns name records until there are no more, for either names.txt +# files or old-style names.json files +sub make_names_iterator { + my ( $file_record ) = @_; + if( $file_record->{type} eq 'txt' ) { + my $input_fh = open_names_file( $file_record->{fullpath} ); + # read the input json partly with low-level parsing so that we + # can parse incrementally from the filehandle. names list + # files can be very big. + return sub { + my $t = <$input_fh>; + return $t ? eval { JSON::from_json( $t ) } : undef; + }; + } + elsif( $file_record->{type} eq 'json' ) { + # read old-style names.json files all from memory + my $input_fh = open_names_file( $file_record->{fullpath} ); + + my $data = JSON::from_json(do { + local $/; + scalar <$input_fh> + }); + + open my $nt, '>', $file_record->{namestxt} or die; + return sub { + my $rec = shift @$data; + if( $rec ) { + $nt->print(JSON::to_json($rec)."\n"); + } + return $rec; + }; + } +} + +sub open_names_file { + my ( $infile ) = @_; + my $gzip = $infile =~ /\.(txt|json)z$/ ? ':gzip' : ''; + open my $fh, "<$gzip", $infile or die "$! reading $infile"; + return $fh; +} diff --git a/bin/gff2jbrowse.pl b/bin/gff2jbrowse.pl new file mode 100755 index 00000000..dfe085c6 --- /dev/null +++ b/bin/gff2jbrowse.pl @@ -0,0 +1,297 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Getopt::Long; +use File::Basename; +use File::Spec::Functions; +use File::Temp; +use Pod::Usage; +use URI::Escape; + +=head1 USAGE + + maker2jbrowse [OPTION] ... + maker2jbrowse [OPTION] -d + + This script takes MAKER produced GFF3 files and dumps them into a + JBrowse for you using pre-configured JSON tracks. + +=head1 OPTIONS + +=over 4 + +=item --out , -o + +Output dir for formatted data. Defaults to './data'. + +=item --ds_index , -d + +Take filenames from a MAKER master datastore index file +(e.g. my_genome_master_datastore_index.log). + +=item --help, -h, -? + +Displays full help information. + +=cut + +my $dstore; +my $help; +my $outdir = 'data'; +GetOptions( + "ds_index|d=s" => \$dstore, + "help|?" => \$help, + "out|o=s" => \$outdir + ) + or pod2usage( verbose => 2 ); +pod2usage( verbose => 2 ) if $help; + +my @files; + +if( $dstore ){ + + my $base = dirname( $dstore ); + open my $dstore_fh, '<', $dstore or die "$! reading $dstore"; + + #uniq the entries + my %seen; + while( my $e = <$dstore_fh> ) { + next if $seen{$e}++; + chomp $e; + my ( $id, $dir, $status ) = split("\t", $e); + next unless $status =~ /FINISHED/; + $dir =~ s/\/$//; + push( @files, $dir ); + } + + for my $file ( @files ){ + my ($name) = $file =~ /([^\/]+)$/; + my $gff = $base ? catfile( $base, $file, "$name.gff" ) : catfile( $file, "$name.gff" ); + + unless( -f $gff ){ + $name = uri_escape( $name, '.' ); + $gff = $base ? catfile( $base, $file, "$name.gff" ) : catfile( $file, "$name.gff" ); + } + + $file = $gff; + } +} +else { + @files = @ARGV; +} + +@files or pod2usage( verbose => 1 ); + +{ # check for missing files + my $error; + for my $file (@files){ + unless( -f $file ) { + $error .= "ERROR: GFF3 file '$file' does not exist\n"; + } + } + die $error if $error; +} + +#--build command lines +my %commands = ( + + #MAKER anotations + gene => [ '--key' => "Gene spans", + '--className' => 'feature5', + '--type' => 'gene', + '--noSubfeatures' + ], + maker => [ '--key' => "MAKER", + '--className' => 'transcript', + '--subfeatureClasses' => '{"exon": "exon", "CDS": "CDS", "five_prime_UTR": "five_prime_UTR", "three_prime_UTR": "three_prime_UTR"}', + '--type' => 'mRNA' + ], + + #ab initio gene predictions + snap_masked => [ '--key' => "SNAP", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:snap_masked', + ], + augustus => [ '--key' => "Augustus", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:augustus', + ], + augustus_masked => [ '--key' => "Augustus", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:augustus_masked', + ], + genemark => [ '--key' => "GeneMark", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:genemark', + ], + genemark_masked => [ '--key' => "GeneMark", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:genemark_masked', + ], + fgenesh => [ '--key' => "FGENESH", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:fgenesh', + ], + fgenesh_masked => [ '--key' => "FGENESH", + '--className' => 'transcript', + '--subfeatureClasses' => '{"match_part": "CDS"}', + '--type' => 'match:fgenesh_masked', + ], + + #evidence alignments + blastn => [ '--key' => "BLASTN", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'expressed_sequence_match:blastn', + ], + blastx => [ '--key' => "BLASTX", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'protein_match:blastx', + ], + tblastx => [ '--key' => "TBLASTX", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'translated_nucleotide_match:tblastx', + ], + est2genome => [ '--key' => "est2genome", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'expressed_sequence_match:est2genome', + ], + protein2genome => [ '--key' => "protein2genome", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'protein_match:protein2genome', + ], + cdna2genome => [ '--key' => "cdna2genome", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'expressed_sequence_match:cdna2genome', + ], + + #repeats + repeatmasker => [ '--key' => "RepeatMasker", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'match:repeatmasker', + ], + repeatrunner => [ '--key' => "RepeatRunner", + '--className' => 'generic_parent', + '--subfeatureClasses' => '{"match_part": "match_part"}', + '--type' => 'protein_match:repeatrunner', + ], + + # derived from GFF of Acromyrmex echinatior/3.8.gff from antgenomes.org + AUGUSTUS => [ + '--key' => 'AUGUSTUS', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:AUGUSTUS', + ], + + Cufflinks => [ + '--key' => 'Cufflinks', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:Cufflinks', + ], + + GeneWise => [ + '--key' => 'GeneWise', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:GeneWise', + ], + + SNAP => [ + '--key' => 'SNAP', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:SNAP', + ], + + GLEAN => [ + '--key' => 'GLEAN', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:GLEAN', + ], + + blat => [ + '--key' => 'blat', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:blat', + ], + + cegma => [ + '--key' => 'cegma', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:cegma', + ], + + Genewise => [ + '--key' => 'Genewise', + '--className' => 'transcript', + '--subfeatureClasses' => '{"CDS": "CDS"}', + '--type' => 'mRNA:Genewise', + ], +); + +for my $gff3_file (@files){ + my @tracks_to_make = do { + my %t; + open my $gff3, '<', $gff3_file or die "$! reading $gff3_file"; + while( <$gff3> ) { + next if /^#/; + my ( $source, $type ) = /[^\t]*\t([^\t]*)\t([^\t]*)/ or next; + next if $source eq '.'; + $t{$source} = 1; + $t{gene} = 1 if $source eq 'maker'; + } + keys %t + }; + + my @outdir = ( '--out' => $outdir ); + + system 'bin/prepare-refseqs.pl', '--key' => 'DNA', '--gff' => $gff3_file, @outdir, + and die "prepare-refseqs.pl failed with exit status $?"; + + for my $track ( @tracks_to_make ) { + + if(!$commands{$track} && $track =~ /^([^\:]+)/ && $commands{$1}){ + @{$commands{$track}} = @{$commands{$1}}; #makes deep copy + $commands{$track}[-1] =~ s/^([^\:]+)\:.*$/$1:$track/; + } + + unless( $commands{$track} ) { + warn "Don't know how to format $track tracks, skipping.\n"; + next; + } + + my @command = ( + 'bin/flatfile-to-json.pl', + '--trackLabel' => $track, + '--gff' => $gff3_file, + @outdir, + @{$commands{$track}} + ); + + #print join(" ",@command)."\n"; + system @command and die "flatfile-to-json.pl failed with exit status $?"; + } +} diff --git a/bin/prepare-refseqs.pl b/bin/prepare-refseqs.pl new file mode 100755 index 00000000..f917f8ca --- /dev/null +++ b/bin/prepare-refseqs.pl @@ -0,0 +1,123 @@ +#!/usr/bin/env perl +use strict; +use FindBin qw($RealBin); +use lib "$RealBin/../"; +use JBlibs; + +use Bio::JBrowse::Cmd::FormatSequences; + +exit Bio::JBrowse::Cmd::FormatSequences->new(@ARGV)->run; + +__END__ + +=head1 NAME + +prepare-refseqs.pl - format reference sequences for use by JBrowse + +=head1 USAGE + + prepare-refseqs.pl --gff [options] + # OR: + prepare-refseqs.pl --fasta --fasta [options] + # OR: + prepare-refseqs.pl --conf [options] + # OR: + prepare-refseqs.pl --sizes [options] + +=head1 DESCRIPTION + +Formats reference sequence data for use with JBrowse. + +This tool can also read fasta files compressed with gzip, if they end +in .gz or .gzip. + +You can use a GFF file to describe the reference sequences; or you can +use a JBrowse config file (pointing to a BioPerl database) or a FASTA +file, together with a list of refseq names or a list of refseq IDs. +If you use a GFF file, it should contain ##sequence-region lines as +described in the GFF specs, and/or it should be GFF version 3 with an +embedded FASTA section. + +If you use a JBrowse config file or FASTA file, you can either provide +a (comma-separated) list of refseq names, or (if the names aren't +globally unique) a list of refseq IDs; or (for FASTA files only) you +can omit the list of refseqs, in which case every sequence in the +database will be used. + +=head1 OPTIONS + +=over 4 + +=item --gff + +Get reference sequences from a GFF3 file with embedded sequence +information. + +=item --fasta + +A FASTA file, optionally gzipped from which to load reference +sequences. + +=item --conf + +biodb-to-json.pl configuration file that defines a database from which +to get reference sequence information. + +=item --out + +Optional directory to write to. Defaults to data/. + +=item --noseq + +Do not store the actual sequence bases, just the sequence metadata +(name, length, and so forth). + +=item --refs | --refids + +Output only sequences with the given names or (database-dependent) IDs. + +=item --compress + +If passed, compress the reference sequences with gzip, making the +chunks be .txt.gz. NOTE: this requires a bit of additional web server +configuration to be served correctly. + +=item --chunksize + +Size of sequence chunks to make, in base pairs. Default 20kb. This +is multiplied by 4 if --compress is passed, so that the compressed +sequence files are still approximately this size. + +=item --nohash + +Store sequences in a flat seq/$seqname/$chunk.txt structure, instead +of the new (more scalable) /seq/hash/hash/hash/$seqname-$chunk.txt +structure. + +=item --trackLabel http://bioinformatics.oxfordjournals.org/cgi/content/abstract/btl647v1 + + */ + +function NCList() { + this.topList = []; +} + +NCList.prototype.importExisting = function(nclist, attrs, baseURL, + lazyUrlTemplate, lazyClass) { + this.topList = nclist; + this.attrs = attrs; + this.start = attrs.makeFastGetter("Start"); + this.end = attrs.makeFastGetter("End"); + this.lazyClass = lazyClass; + this.baseURL = baseURL; + this.lazyUrlTemplate = lazyUrlTemplate; + this.lazyChunks = {}; +}; + +/** + * + * Given an array of features, creates the nested containment list data structure + * WARNING: DO NOT USE directly for adding additional intervals! + * completely replaces existing nested containment structure + * (erases current topList and subarrays, repopulates from intervals) + * currently assumes each feature is array as described above + */ +NCList.prototype.fill = function(intervals, attrs) { + //intervals: array of arrays of [start, end, ...] + //attrs: an ArrayRepr object + //half-open? + if (intervals.length == 0) { + this.topList = []; + return; + } + + this.attrs = attrs; + this.start = attrs.makeFastGetter("Start"); + this.end = attrs.makeFastGetter("End"); + var sublist = attrs.makeSetter("Sublist"); + var start = this.start; + var end = this.end; + var myIntervals = intervals; + //sort by OL + myIntervals.sort(function(a, b) { + if (start(a) != start(b)) + return start(a) - start(b); + else + return end(b) - end(a); + }); + var sublistStack = new Array(); + var curList = new Array(); + this.topList = curList; + curList.push(myIntervals[0]); + if (myIntervals.length == 1) return; + var curInterval, topSublist; + for (var i = 1, len = myIntervals.length; i < len; i++) { + curInterval = myIntervals[i]; + //if this interval is contained in the previous interval, + if (end(curInterval) < end(myIntervals[i - 1])) { + //create a new sublist starting with this interval + sublistStack.push(curList); + curList = new Array(curInterval); + sublist(myIntervals[i - 1], curList); + } else { + //find the right sublist for this interval + while (true) { + if (0 == sublistStack.length) { + curList.push(curInterval); + break; + } else { + topSublist = sublistStack[sublistStack.length - 1]; + if (end(topSublist[topSublist.length - 1]) + > end(curInterval)) { + //curList is the first (deepest) sublist that + //curInterval fits into + curList.push(curInterval); + break; + } else { + curList = sublistStack.pop(); + } + } + } + } + } +}; + +NCList.prototype.binarySearch = function(arr, item, getter) { + var low = -1; + var high = arr.length; + var mid; + + while (high - low > 1) { + mid = (low + high) >>> 1; + if (getter(arr[mid]) >= item) + high = mid; + else + low = mid; + } + + //if we're iterating rightward, return the high index; + //if leftward, the low index + if (getter === this.end) return high; else return low; +}; + +NCList.prototype.iterHelper = function(arr, from, to, fun, + inc, searchGet, testGet, path) { + var len = arr.length; + var i = this.binarySearch(arr, from, searchGet); + var getChunk = this.attrs.makeGetter("Chunk"); + var getSublist = this.attrs.makeGetter("Sublist"); + + var promises = []; + + while ((i < len) + && (i >= 0) + && ((inc * testGet(arr[i])) < (inc * to)) ) { + + if( arr[i][0] == this.lazyClass ) { + // this is a lazily-loaded chunk of the nclist + (function() { + var thisB = this; + var chunkNum = getChunk(arr[i]); + if( !(chunkNum in this.lazyChunks) ) { + this.lazyChunks[chunkNum] = {}; + } + + var getDone = new Deferred(); + promises.push( getDone.promise ); + + request.get( + Util.resolveUrl( + this.baseURL, + this.lazyUrlTemplate.replace( + /\{Chunk\}/ig, chunkNum + ) + ), + { handleAs: 'json' } + ).then( + function( sublist ) { + return thisB.iterHelper( + sublist, from, to, fun, + inc, searchGet, testGet, + [chunkNum] + ).then( function() { getDone.resolve(); } ); + }, + function( error ) { + if( error.response.status != 404 ) + throw new Error( error ); + else + getDone.resolve(); + } + ); + }).call(this); + + } else { + // this is just a regular feature + + fun(arr[i], path.concat(i)); + } + + // if this node has a contained sublist, process that too + var sublist = getSublist(arr[i]); + if (sublist) + promises.push( this.iterHelper(sublist, from, to, + fun, inc, searchGet, testGet, + path.concat(i)) + ); + i += inc; + } + + return all( promises ); +}; + + +NCList.prototype.iterate = function(from, to, fun, postFun) { + // calls the given function once for each of the + // intervals that overlap the given interval + //if from <= to, iterates left-to-right, otherwise iterates right-to-left + + //inc: iterate leftward or rightward + var inc = (from > to) ? -1 : 1; + //searchGet: search on start or end + var searchGet = (from > to) ? this.start : this.end; + //testGet: test on start or end + var testGet = (from > to) ? this.end : this.start; + + if (this.topList.length > 0) { + this.iterHelper( this.topList, from, to, fun, + inc, searchGet, testGet, [0]) + .then( postFun ); + } +}; + +NCList.prototype.histogram = function(from, to, numBins, callback) { + //calls callback with a histogram of the feature density + //in the given interval + + var result = new Array(numBins); + var binWidth = (to - from) / numBins; + var start = this.start; + var end = this.end; + for (var i = 0; i < numBins; i++) result[i] = 0; + this.iterate(from, to, + function(feat) { + var firstBin = + Math.max(0, ((start(feat) - from) / binWidth) | 0); + var lastBin = + Math.min(numBins, ((end(feat) - from) / binWidth) | 0); + for (var bin = firstBin; bin <= lastBin; bin++) + result[bin]++; + }, + function() { + callback(result); + } + ); +}; + +/* + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +Created by Mitchell Skinner + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +*/ +return NCList; + +}); + diff --git a/www/JBrowse/Store/NCList_v0.js b/www/JBrowse/Store/NCList_v0.js new file mode 100644 index 00000000..6f47fcca --- /dev/null +++ b/www/JBrowse/Store/NCList_v0.js @@ -0,0 +1,225 @@ +define([ 'JBrowse/Finisher', 'JBrowse/Util'], + function( Finisher, Util ) { + +/** + * Legacy-compatible NCList for 1.2.1 backward compatibility. + * @lends JBrowse.Store.NCList_v0 + * @constructs + */ +function NCList_v0() {} + +NCList_v0.prototype.importExisting = function(nclist, sublistIndex, + lazyIndex, baseURL, + lazyUrlTemplate) { + this.topList = nclist; + this.sublistIndex = sublistIndex; + this.lazyIndex = lazyIndex; + this.baseURL = baseURL; + this.lazyUrlTemplate = lazyUrlTemplate; +}; + +NCList_v0.prototype.fill = function(intervals, sublistIndex) { + //intervals: array of arrays of [start, end, ...] + //sublistIndex: index into a [start, end] array for storing a sublist + // array. this is so you can use those arrays for something + // else, and keep the NCList_v0 bookkeeping from interfering. + // That's hacky, but keeping a separate copy of the intervals + // in the NCList_v0 seems like a waste (TODO: measure that waste). + //half-open? + this.sublistIndex = sublistIndex; + var myIntervals = intervals;//.concat(); + //sort by OL + myIntervals.sort(function(a, b) { + if (a[0] != b[0]) + return a[0] - b[0]; + else + return b[1] - a[1]; + }); + var sublistStack = new Array(); + var curList = new Array(); + this.topList = curList; + curList.push(myIntervals[0]); + var curInterval, topSublist; + for (var i = 1, len = myIntervals.length; i < len; i++) { + curInterval = myIntervals[i]; + //if this interval is contained in the previous interval, + if (curInterval[1] < myIntervals[i - 1][1]) { + //create a new sublist starting with this interval + sublistStack.push(curList); + curList = new Array(curInterval); + myIntervals[i - 1][sublistIndex] = curList; + } else { + //find the right sublist for this interval + while (true) { + if (0 == sublistStack.length) { + curList.push(curInterval); + break; + } else { + topSublist = sublistStack[sublistStack.length - 1]; + if (topSublist[topSublist.length - 1][1] > curInterval[1]) { + //curList is the first (deepest) sublist that + //curInterval fits into + curList.push(curInterval); + break; + } else { + curList = sublistStack.pop(); + } + } + } + } + } +}; + +NCList_v0.prototype.binarySearch = function(arr, item, itemIndex) { + var low = -1; + var high = arr.length; + var mid; + + while (high - low > 1) { + mid = (low + high) >>> 1; + if (arr[mid][itemIndex] > item) + high = mid; + else + low = mid; + } + + //if we're iterating rightward, return the high index; + //if leftward, the low index + if (1 == itemIndex) return high; else return low; +}; + +NCList_v0.prototype.iterHelper = function(arr, from, to, fun, finish, + inc, searchIndex, testIndex, path) { + var len = arr.length; + var i = this.binarySearch(arr, from, searchIndex); + while ((i < len) + && (i >= 0) + && ((inc * arr[i][testIndex]) < (inc * to)) ) { + + if ("object" == typeof arr[i][this.lazyIndex]) { + var ncl = this; + // lazy node + if (arr[i][this.lazyIndex].state) { + if ("loading" == arr[i][this.lazyIndex].state) { + // node is currenly loading; finish this query once it + // has been loaded + finish.inc(); + arr[i][this.lazyIndex].callbacks.push( + function(parentIndex) { + return function(o) { + ncl.iterHelper(o, from, to, fun, finish, inc, + searchIndex, testIndex, + path.concat(parentIndex)); + finish.dec(); + }; + }(i) + ); + } else if ("loaded" == arr[i][this.lazyIndex].state) { + // just continue below + } else { + console.log("unknown lazy type: " + arr[i]); + } + } else { + // no "state" property means this node hasn't been loaded, + // start loading + arr[i][this.lazyIndex].state = "loading"; + arr[i][this.lazyIndex].callbacks = []; + finish.inc(); + dojo.xhrGet( + { + url: Util.resolveUrl( + this.baseURL, + this.lazyUrlTemplate.replace( + /\{chunk\}/g, + arr[i][this.lazyIndex].chunk + ) + ), + handleAs: "json", + load: function(lazyFeat, lazyObj, + sublistIndex, parentIndex) { + return function(o) { + lazyObj.state = "loaded"; + lazyFeat[sublistIndex] = o; + ncl.iterHelper(o, from, to, + fun, finish, inc, + searchIndex, testIndex, + path.concat(parentIndex)); + for (var c = 0; + c < lazyObj.callbacks.length; + c++) + lazyObj.callbacks[c](o); + finish.dec(); + }; + }(arr[i], arr[i][this.lazyIndex], this.sublistIndex, i), + error: function() { + finish.dec(); + } + }); + } + } else { + fun(arr[i], path.concat(i)); + } + + if (arr[i][this.sublistIndex]) + this.iterHelper(arr[i][this.sublistIndex], from, to, + fun, finish, inc, searchIndex, testIndex, + path.concat(i)); + i += inc; + } +}; + +NCList_v0.prototype.iterate = function(from, to, fun, postFun) { + // calls the given function once for each of the + // intervals that overlap the given interval + //if from <= to, iterates left-to-right, otherwise iterates right-to-left + + //inc: iterate leftward or rightward + var inc = (from > to) ? -1 : 1; + //searchIndex: search on start or end + var searchIndex = (from > to) ? 0 : 1; + //testIndex: test on start or end + var testIndex = (from > to) ? 1 : 0; + var finish = new Finisher(postFun); + this.iterHelper(this.topList, from, to, fun, finish, + inc, searchIndex, testIndex, []); + finish.finish(); +}; + +NCList_v0.prototype.histogram = function(from, to, numBins, callback) { + //calls callback with a histogram of the feature density + //in the given interval + + var result = new Array(numBins); + var binWidth = (to - from) / numBins; + for (var i = 0; i < numBins; i++) result[i] = 0; + //this.histHelper(this.topList, from, to, result, numBins, (to - from) / numBins); + this.iterate(from, to, + function(feat) { + var firstBin = + Math.max(0, ((feat[0] - from) / binWidth) | 0); + var lastBin = + Math.min(numBins, ((feat[1] - from) / binWidth) | 0); + for (var bin = firstBin; bin <= lastBin; bin++) + result[bin]++; + }, + function() { + callback(result); + } + ); +}; + +/* + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +Created by Mitchell Skinner + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +*/ + +return NCList_v0; +}); \ No newline at end of file diff --git a/www/JBrowse/Store/Names/Hash.js b/www/JBrowse/Store/Names/Hash.js new file mode 100644 index 00000000..89007312 --- /dev/null +++ b/www/JBrowse/Store/Names/Hash.js @@ -0,0 +1,176 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/store/util/QueryResults', + 'JBrowse/Util', + 'JBrowse/Store/Hash', + 'JBrowse/Model/Location' + ], + function( + declare, + array, + QueryResults, + Util, + HashStore, + Location + ) { + +return declare( HashStore, +{ + + constructor: function( args ) { + + this.tooManyMatchesMessage = args.tooManyMatchesMessage || '(too many matches to display)'; + + // generate stopPrefixes + var stopPrefixes = this.stopPrefixes = {}; + // make our stopPrefixes an object as { prefix: true, ... } + // with all possible prefixes of our stop prefixes + if( args.stopPrefixes ) { + var prefixesInput = typeof args.stopPrefixes == 'string' + ? [ args.stopPrefixes ] : args.stopPrefixes; + + dojo.forEach( prefixesInput, function(prefix) { + while( prefix.length ) { + stopPrefixes[prefix] = true; + prefix = prefix.substr( 0, prefix.length - 1 ); + } + }); + } + }, + + _nameRecordToItem: function( nameRecord ) { + if( nameRecord.hitLimit ) { + // it's a too-many-matches marker + return { name: this.tooManyMatchesMessage, hitLimit: true }; + } + else { + // it's an actual name record + var item = {}; + if( typeof nameRecord == 'object' ) { + item.name = nameRecord[0]; + var trackConfig = this._findTrackConfig( ((this.meta||{}).track_names||{})[ nameRecord[1] ] ); + item.location = new Location({ + ref: nameRecord[3], + start: parseInt( nameRecord[4] ), + end: parseInt( nameRecord[5] ), + tracks: [ trackConfig ], + objectName: nameRecord[0] + }); + } else { + item.name = nameRecord; + } + return item; + } + }, + + // look in the browser's track configuration for the track with the given label + _findTrackConfig: function( trackLabel ) { + if( ! trackLabel ) + return null; + + var track = null; + var i = array.some( this.browser.config.tracks, function( t ) { + if( t.label == trackLabel ) { + track = t; + return true; + } + return false; + }); + + return track; + }, + + _makeResults: function( nameRecords ) { + // convert the name records into dojo.store-compliant data + // items, sort them by name and location + var results = array.map( nameRecords, dojo.hitch(this,'_nameRecordToItem') ) + .sort( function( a, b ) { + return a.name.localeCompare( b.name ) + || a.location.localeCompare( b.location ); + }); + + var last; + var hitLimit; + + // aggregate them and make labels for them. for names with + // multiple locations, make a multipleLocations member. + results = array.filter( results, function( i ) { + if( i.hitLimit ) { + hitLimit = i; + if( ! hitLimit.label ) + hitLimit.label = hitLimit.name || 'too many matches'; + return false; + } + else if( last && last.name == i.name ) { + last.label = last.name + ' multiple locations'; + if( last.multipleLocations ) { + last.multipleLocations.push( i.location ); + } else { + last.multipleLocations = [last.location,i.location]; + delete last.location; + } + return false; + } + last = i; + last.label = last.name + + ( last.location ? ' '+last.location+'' + : '' + ); + return true; + }); + + if( hitLimit ) + results.push( hitLimit ); + + return QueryResults( results ); + }, + + // case-insensitive, and supports prefix queries like 'foo*' + query: function( query, options ) { + // remove trailing asterisks from query.name + var thisB = this; + var name = ( query.name || '' ).toString(); + + // lowercase the name if the store is all-lowercase + if( this.meta.lowercase_keys ) + name = name.toLowerCase(); + + var trailingStar = /\*$/; + if( trailingStar.test( name ) ) { + name = name.replace( trailingStar, '' ); + return this._getEntry( name ) + .then( function( value ) { + value = value || {}; + return thisB._makeResults( ( value.exact || [] ).concat( value.prefix || [] ) ); + }); + } + else { + return this._getEntry( name ) + .then( function( value ) { + return thisB._makeResults( (value||{}).exact || [] ); + }); + } + }, + + get: function( id ) { + // lowercase the id if the store is all-lowercase + if( this.meta.lowercase_keys ) + id = id.toLowerCase(); + + return this._getEntry( id ) + .then( function( bucket ) { + var nameRec = (bucket.exact||[])[0]; + return nameRec ? this._nameRecordToItem( nameRec ) : null; + }); + }, + + _getEntry: function( key ) { + return this._getBucket(key) + .then( function( bucket ) { + return bucket[key]; + }); + } + +}); +}); diff --git a/www/JBrowse/Store/Names/LazyTrieDojoData.js b/www/JBrowse/Store/Names/LazyTrieDojoData.js new file mode 100644 index 00000000..a2c8f917 --- /dev/null +++ b/www/JBrowse/Store/Names/LazyTrieDojoData.js @@ -0,0 +1,211 @@ +/** + * dojo.data.api.Read-compatible store object that reads data from an + * encapsulated JBrowse/Store/LazyTrie. + */ + +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Util', + 'JBrowse/Model/Location' + ],function( + declare, + array, + Util, + Location + ) { +return declare( null, +/** + * @lends JBrowse.Store.Autocomplete.prototype + */ +{ + /** + * @constructs + * @param args.namesTrie + * @param args.stopPrefixes + * @param args.resultLimit + * @param args.tooManyMatchesMessage + */ + constructor: function( /**Object*/ args ) { + if( ! args.namesTrie ) + throw "must provide a namesTrie argument"; + + this.namesTrie = args.namesTrie; + + this.resultLimit = args.resultLimit || 15; + this.tooManyMatchesMessage = args.tooManyMatchesMessage || '(too many matches to display)'; + + // generate stopPrefixes + var stopPrefixes = this.stopPrefixes = {}; + // make our stopPrefixes an object as { prefix: true, ... } + // with all possible prefixes of our stop prefixes + if( args.stopPrefixes ) { + var prefixesInput = typeof args.stopPrefixes == 'string' + ? [ args.stopPrefixes ] : args.stopPrefixes; + + dojo.forEach( prefixesInput, function(prefix) { + while( prefix.length ) { + stopPrefixes[prefix] = true; + prefix = prefix.substr( 0, prefix.length - 1 ); + } + }); + } + + // make a self-modifying method for extracting the that + // detects whether the name store is formatted with tools + // pre-1.4 or post-1.4. for pre-1.4 formats, will just + // complete with the lower-case version of the name. for + // post-1.4, use the original-case version that's stored in + // the name record. + this.nodeText = function(node) { + if( typeof node[1][0][0] == 'number' ) { + // pre-1.4, for backcompat + this.nodeText = function(node) { return node[0]; }; + } else { + // post-1.4 + this.nodeText = function(node) { return node[1][0][0]; }; + } + return this.nodeText( node ); + }; + }, + + getFeatures: function() { + return { + 'dojo.data.api.Read': true, + 'dojo.data.api.Identity': true + }; + }, + + getIdentity: function( node ) { + console.log( node ); + }, + + // dojo.data.api.Read support + + fetch: function( /**Object*/ request ) { + var start = request.start || 0; + var matchLimit = Math.min( this.resultLimit, Math.max(0, request.count || Infinity ) ); + var matchesRemaining = matchLimit; + var scope = request.scope || dojo.global; + var aborted = false; + + // wrap our abort function to set a flag + request.abort = function() { + var oldabort = request.abort || function() {}; + return function() { + aborted = true; + oldabort.call( scope, request ); + }; + }.call(this); + + if( ! request.store ) + request.store = this; + + if( request.onBegin ) + request.onBegin.call( scope, 0, request ); + + var prefix = (request.query.name || '').toString().replace(/\*$/,''); + + if( ! this.stopPrefixes[ prefix ] ) { + this.namesTrie.mappingsFromPrefix( + prefix, + dojo.hitch( this, function(tree) { + var matches = []; + + if( aborted ) + return; + + // are we working with a post-JBrowse 1.4 data structure? + var post1_4 = tree[0] && tree[0][1] && tree[0][1][0] && typeof tree[0][1][0][0] == 'string'; + + // use dojo.some so that we can break out of the loop when we hit the limit + dojo.some( tree, function(node) { + if( matchesRemaining-- ) { + var name = this.nodeText(node); + array.forEach( node[1], function(n) { + var location = new Location({ + ref: n[ post1_4 ? 3 : 2 ], + start: parseInt( n[ post1_4 ? 4 : 3 ]), + end: parseInt( n[ post1_4 ? 5 : 4 ]), + tracks: [ this.namesTrie.extra[ n[ post1_4 ? 1 : 0 ] ] ], + objectName: name + }); + + matches.push({ + name: name, + location: location + }); + },this); + } + return matchesRemaining < 0; + },this); + + // if we found more than the match limit + if( matchesRemaining < 0 ) + matches.push({ name: this.tooManyMatchesMessage, hitLimit: true }); + + if( request.sort ) + matches.sort( dojo.data.util.sorter.createSortFunction(request.sort, this) ); + if( !aborted && request.onItem ) + dojo.forEach( matches, function( item ) { + if( !aborted ) + request.onItem.call( scope, item, request ); + }); + if( !aborted && request.onComplete ) + request.onComplete.call( scope, matches, request ); + })); + } + else if( request.onComplete ) { + request.onComplete.call( scope, [], request ); + } + + return request; + }, + + getValue: function( i, attr, defaultValue ) { + var v = i[attr]; + return typeof v == 'undefined' ? defaultValue : v; + }, + getValues: function( i, attr ) { + var a = [ i[attr] ]; + return typeof a[0] == 'undefined' ? [] : a; + }, + + getAttributes: function(item) { + return Util.dojof.keys( item ); + }, + + hasAttribute: function(item,attr) { + return item.hasOwnProperty(attr); + }, + + containsValue: function(item, attribute, value) { + return item[attribute] == value; + }, + + isItem: function(item) { + return typeof item == 'object' && typeof item.label == 'string'; + }, + + isItemLoaded: function() { + return true; + }, + + loadItem: function( args ) { + }, + + close: function() {}, + + getLabel: function(i) { + return this.getValue(i,'name',undefined); + }, + getLabelAttributes: function(i) { + return ['name']; + }, + + getIdentity: function(i) { + return this.getLabel(i); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/RemoteBinaryFile.js b/www/JBrowse/Store/RemoteBinaryFile.js new file mode 100644 index 00000000..a3f96a5c --- /dev/null +++ b/www/JBrowse/Store/RemoteBinaryFile.js @@ -0,0 +1,439 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'JBrowse/has', + 'JBrowse/Util', + 'JBrowse/Store/LRUCache', + 'jszlib/arrayCopy' + ], + function( declare, lang, array, has, Util, LRUCache, arrayCopy ) { + +var Chunk = Util.fastDeclare({ + constructor: function( values ) { + lang.mixin( this, values ); + }, + toString: function() { + return this.url+" (bytes "+this.start+".."+this.end+")"; + }, + toUniqueString: function() { + return this.url+" (bytes "+this.start+".."+this.end+")"; + } +}); + +// contains chunks of files, stitches them together if necessary, wraps, and returns them +// to satisfy requests +return declare( null, + +/** + * @lends JBrowse.Store.RemoteBinaryFile + */ +{ + constructor: function( args ) { + this.name = args.name; + + this._fetchCount = 0; + this._arrayCopyCount = 0; + + this.minChunkSize = 'minChunkSize' in args ? args.minChunkSize : 32768; + this.chunkCache = new LRUCache({ + name: args.name + ' chunk cache', + fillCallback: dojo.hitch( this, '_fetch' ), + maxSize: args.maxSize || 10000000 // 10MB max cache size + }); + + this.totalSizes = {}; + }, + + _escapeRegExp: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + + _relevantExistingChunks: function( url, start, end ) { + // we can't actually use any existing chunks if we don't have an + // end defined. not possible in the HTTP spec to ask for all except + // the first X bytes of a file + if( !end ) + return []; + + start = start || 0; + + var fileChunks = this.chunkCache + .query( new RegExp( '^'+this._escapeRegExp( url + ' (bytes' ) ) ); + + // set 'start' and 'end' on any records that don't have them, but should + array.forEach( fileChunks, function(c) { + if( c.size ) { + if( ! c.key.start ) + c.key.start = 0; + if( ! c.key.end ) + c.key.end = c.key.start + ( c.size || c.value.byteLength ); + } + }); + + // sort the records by start coordinate, then by length descending (so that we preferentially use big chunks) + fileChunks = fileChunks.sort( function( a, b ) { + return ( a.key.start - b.key.start ) || ((b.key.end - b.key.start) - ( a.key.end - a.key.start )); + }); + + // filter for the chunks that can actually be used for this request + return array.filter( fileChunks, + function( chunk ) { + return !( chunk.key.start > end || chunk.key.end < start ); + }, this); + }, + + _fetchChunks: function( url, start, end, callback, errorCallback ) { + start = start || 0; + + // if we already know how big the file is, use that information for the end + if( typeof end != 'number' && this.totalSizes[url] ) { + end = this.totalSizes[ url ]-1; + } + // if we know the size of the file, and end is beyond it, then clamp it + else if( end >= this.totalSizes[url] ) { + end = this.totalSizes[url] - 1; + } + // NOTE: if end is undefined, we take that to mean fetch all the way to the end of the file + + // what chunks do we already have in the chunk cache? + var existingChunks = this._relevantExistingChunks( url, start, end ); + this._log( 'existing', existingChunks ); + + // assemble a 'golden path' of chunks to use to fulfill this + // request, using existing chunks where we have them cached, + // and where we don't, making records for chunks to fetch + var goldenPath = []; + if( typeof end != 'number' ) { // if we don't have an end coordinate, we just have to fetch the whole file + goldenPath.push({ key: new Chunk( { url: url, start: 0, end: undefined } ) }); + } + else { + for( var currOffset = start; currOffset <= end; currOffset = goldenPath[goldenPath.length-1].key.end+1 ) { + if( existingChunks[0] && existingChunks[0].key.start <= currOffset ) { + goldenPath.push( existingChunks.shift() ); + } else { + goldenPath.push({ key: new Chunk({ + url: url, + start: currOffset, + end: existingChunks[0] ? existingChunks[0].key.start-1 : end + }) + }); + } + } + } + + // filter the blocks in the golden path that + // have not already been fetched to try to align them to chunk boundaries: multiples of minChunkSize + array.forEach( goldenPath, function( c ) { + if( c.value ) + return; + var k = c.key; + k.start = Math.floor( k.start / this.minChunkSize ) * this.minChunkSize; + if( k.end ) + k.end = Math.ceil( (k.end+1) / this.minChunkSize ) * this.minChunkSize - 1; + }, this ); + + // merge and filter request blocks in the golden path + goldenPath = this._optimizeGoldenPath( goldenPath ); + + var needed = array.filter( goldenPath, function(n) { return ! n.value; }); + + this._log( 'needed', needed ); + + // now fetch all the needed chunks + // remember that chunk records in the 'needed' array are also + // present in the 'goldenPath' array, so setting their value + // will affect both places + if( needed.length ) { + var fetchedCount = 0; + array.forEach( needed, function( c ) { + this.chunkCache.get( c.key, function( data, error ) { + c.value = data; + if( error ) { + errorCallback( error ); + } + else if( ++fetchedCount == needed.length ) + callback( goldenPath ); + }); + }, this ); + } + // or we might already have all the chunks we need + else { + callback( goldenPath ); + } + }, + + _optimizeGoldenPath: function( goldenPath ) { + var goldenPath2 = [ goldenPath[0] ]; + for( var i = 1; i lastGolden.key.end ) + goldenPath2.push( chunk ); + // else don't use this chunk + } + else { + // if the last thing on the golden path is also + // something we need to fetch, merge with it + if( ! lastGolden.value ) { + lastGolden.key.end = chunk.key.end; + } + // otherwise, use this fetch + else { + goldenPath2.push( chunk ); + } + } + } + return goldenPath2; + }, + + _fetch: function( request, callback, attempt, truncatedLength ) { + + this._log( 'fetch', request.url, request.start, request.end ); + this._fetchCount++; + + attempt = attempt || 1; + + var req = new XMLHttpRequest(); + var length; + var url = request.url; + + // Safari browsers cache XHRs to a single resource, regardless + // of the byte range. So, requesting the first 32K, then + // requesting second 32K, can result in getting the first 32K + // twice. Seen first-hand on Safari 6, and @dasmoth reports + // the same thing on mobile Safari on IOS. So, if running + // Safari, put the byte range in a query param at the end of + // the URL to force Safari to pay attention to it. + if( has('safari') && request.end ) { + url = url + ( url.indexOf('?') > -1 ? '&' : '?' ) + 'safari_range=' + request.start +'-'+request.end; + } + + req.open('GET', url, true ); + if( req.overrideMimeType ) + req.overrideMimeType('text/plain; charset=x-user-defined'); + if (request.end) { + req.setRequestHeader('Range', 'bytes=' + request.start + '-' + request.end); + length = request.end - request.start + 1; + } + req.responseType = 'arraybuffer'; + + var respond = function( response ) { + if( response ) { + if( ! request.start ) + request.start = 0; + if( ! request.end ) + request.end = request.start + response.byteLength; + } + var nocache = /no-cache/.test( req.getResponseHeader('Cache-Control') ) + || /no-cache/.test( req.getResponseHeader('Pragma') ); + callback( response, null, {nocache: nocache } ); + }; + + req.onreadystatechange = dojo.hitch( this, function() { + if (req.readyState == 4) { + if (req.status == 200 || req.status == 206) { + + // if this response tells us the file's total size, remember that + this.totalSizes[request.url] = (function() { + var contentRange = req.getResponseHeader('Content-Range'); + if( ! contentRange ) + return undefined; + var match = contentRange.match(/\/(\d+)$/); + return match ? parseInt(match[1]) : undefined; + })(); + + var response = req.response || req.mozResponseArrayBuffer || (function() { + try{ + var r = req.responseText; + if (length && length != r.length && (!truncatedLength || r.length != truncatedLength)) { + if( attempt == 3 ) { + callback( null, this._errorString( req, url ) ); + } else { + this._fetch( request, callback, attempt + 1, r.length ); + } + return; + } else { + respond( this._stringToBuffer(req.responseText) ); + return; + } + } catch (x) { + console.error(''+x, x.stack, x); + // the response must have successful but + // empty, so respond with a zero-length + // arraybuffer + respond( new ArrayBuffer() ); + return; + } + }).call(this); + if( response ) { + respond( response ); + } + } else if( attempt == 3 ) { + callback( null, this._errorString( req, url ) ); + return null; + } else { + return this._fetch( request, callback, attempt + 1); + } + } + return null; + }); + // if (this.opts.credentials) { + // req.withCredentials = true; + // } + req.send(''); + }, + + _errorString: function( req, url ) { + if( req.status ) + return req.status+' ('+req.statusText+') when attempting to fetch '+url; + else + return 'Unable to fetch '+url; + }, + + /** + * @param args.url {String} url to fetch + * @param args.start {Number|undefined} start byte offset + * @param args.end {Number|undefined} end byte offset + * @param args.success {Function} success callback + * @param args.failure {Function} failure callback + */ + get: function( args ) { + if( ! has('typed-arrays') ) { + (args.failure || function(m) { console.error(m); })('This web browser lacks support for JavaScript typed arrays.'); + return; + } + + + this._log( 'get', args.url, args.start, args.end ); + + var start = args.start || 0; + var end = args.end; + if( start && !end ) + throw "cannot specify a fetch start without a fetch end"; + + if( ! args.success ) + throw new Error('success callback required'); + if( ! args.failure ) + throw new Error('failure callback required'); + + this._fetchChunks( + args.url, + start, + end, + dojo.hitch( this, function( chunks ) { + + var totalSize = this.totalSizes[ args.url ]; + + this._assembleChunks( + start, + end, + function( resultBuffer ) { + if( typeof totalSize == 'number' ) + resultBuffer.fileSize = totalSize; + try { + args.success.call( this, resultBuffer ); + } catch( e ) { + console.error(''+e, e.stack, e); + if( args.failure ) + args.failure( e ); + } + }, + args.failure, + chunks + ); + }), + args.failure + ); + }, + + _assembleChunks: function( start, end, successCallback, failureCallback, chunks ) { + this._log( 'golden path', chunks); + + var returnBuffer; + + if( ! has('typed-arrays') ) { + failureCallback( 'Web browser does not support typed arrays'); + return; + } + + // if we just have one chunk, return either it, or a subarray of it. don't have to do any array copying + if( chunks.length == 1 && chunks[0].key.start == start && (!end || chunks[0].key.end == end) ) { + returnBuffer = chunks[0].value; + } else { + + // calculate the actual range end from the chunks we're + // using, can't always trust the `end` we're passed, + // because it might actually be beyond the end of the + // file. + var fetchEnd = Math.max.apply( + Math, + array.map( + chunks, + function(c) { + return c.key.start + ((c.value||{}).byteLength || 0 ) - 1; + }) + ); + + // if we have an end, we shouldn't go larger than it, though + if( end ) + fetchEnd = Math.min( fetchEnd, end ); + + var fetchLength = fetchEnd - start + 1; + + // stitch them together into one ArrayBuffer to return + returnBuffer = new Uint8Array( fetchLength ); + var cursor = 0; + array.forEach( chunks, function( chunk ) { + if( !( chunk.value && chunk.value.byteLength ) ) // skip if the chunk has no data + return; + + var b = new Uint8Array( chunk.value ); + var bOffset = (start+cursor) - chunk.key.start; if( bOffset < 0 ) this._error('chunking error'); + var length = Math.min( b.byteLength - bOffset, fetchLength - cursor ); + this._log( 'arrayCopy', b, bOffset, returnBuffer, cursor, length ); + arrayCopy( b, bOffset, returnBuffer, cursor, length ); + this._arrayCopyCount++; + cursor += length; + },this); + returnBuffer = returnBuffer.buffer; + } + + // return the data buffer + successCallback( returnBuffer ); + }, + + _stringToBuffer: function(result) { + if( ! result || typeof Uint8Array != 'function' ) + return null; + + var ba = new Uint8Array( result.length ); + for ( var i = 0; i < ba.length; i++ ) { + ba[i] = result.charCodeAt(i); + } + return ba.buffer; + }, + + _log: function() { + //console.log.apply( console, this._logf.apply(this,arguments) ); + }, + _warn: function() { + console.warn.apply( console, this._logf.apply(this,arguments) ); + }, + _error: function() { + console.error.apply( console, this._logf.apply(this,arguments) ); + throw 'file error'; + }, + _logf: function() { + arguments[0] = this.name+' '+arguments[0]; + if( typeof arguments[0] == 'string' ) + while( arguments[0].length < 15 ) + arguments[0] += ' '; + return arguments; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature.js b/www/JBrowse/Store/SeqFeature.js new file mode 100644 index 00000000..5cb3603e --- /dev/null +++ b/www/JBrowse/Store/SeqFeature.js @@ -0,0 +1,137 @@ +define( [ + 'dojo/_base/declare', + 'JBrowse/Store', + 'JBrowse/Store/LRUCache' + ], + function( declare, Store, LRUCache ) { + +/** + * Base class for JBrowse data backends that hold sequences and + * features. + * + * @class JBrowse.SeqFeatureStore + * @extends JBrowse.Store + * @constructor + */ + +return declare( Store, +{ + + constructor: function( args ) { + this.globalStats = {}; + }, + + /** + * Fetch global statistics the features in this store. + * + * @param {Function} successCallback(stats) callback to receive the + * statistics. called with one argument, an object containing + * attributes with various statistics. + * @param {Function} errorCallback(error) in the event of an error, this + * callback will be called with one argument, which is anything + * that can stringify to an error message. + */ + getGlobalStats: function( callback, errorCallback ) { + callback( this.globalStats || {} ); + }, + + /** + * Fetch statistics about the features in a specific region. + * + * @param {String} query.ref the name of the reference sequence + * @param {Number} query.start start of the region in interbase coordinates + * @param {Number} query.end end of the region in interbase coordinates + * @param {Function} successCallback(stats) callback to receive the + * statistics. called with one argument, an object containing + * attributes with various statistics. + * @param {Function} errorCallback(error) in the event of an error, this + * callback will be called with one argument, which is anything + * that can stringify to an error message. + */ + getRegionStats: function( query, successCallback, errorCallback ) { + return this._getRegionStats.apply( this, arguments ); + }, + + _getRegionStats: function( query, successCallback, errorCallback ) { + var thisB = this; + var cache = thisB._regionStatsCache = thisB._regionStatsCache || new LRUCache({ + name: 'regionStatsCache', + maxSize: 1000, // cache stats for up to 1000 different regions + sizeFunction: function( stats ) { return 1; }, + fillCallback: function( query, callback ) { + //console.log( '_getRegionStats', query ); + var s = { + scoreMax: -Infinity, + scoreMin: Infinity, + scoreSum: 0, + scoreSumSquares: 0, + basesCovered: query.end - query.start, + featureCount: 0 + }; + thisB.getFeatures( query, + function( feature ) { + var score = feature.get('score') || 0; + s.scoreMax = Math.max( score, s.scoreMax ); + s.scoreMin = Math.min( score, s.scoreMin ); + s.scoreSum += score; + s.scoreSumSquares += score*score; + s.featureCount++; + }, + function() { + s.scoreMean = s.featureCount ? s.scoreSum / s.featureCount : 0; + s.scoreStdDev = thisB._calcStdFromSums( s.scoreSum, s.scoreSumSquares, s.featureCount ); + s.featureDensity = s.featureCount / s.basesCovered; + //console.log( '_getRegionStats done', s ); + callback( s ); + }, + function(error) { + callback( null, error ); + } + ); + } + }); + + cache.get( query, + function( stats, error ) { + if( error ) + errorCallback( error ); + else + successCallback( stats ); + }); + + }, + + // utility method that calculates standard deviation from sum and sum of squares + _calcStdFromSums: function( sum, sumSquares, n ) { + if( n == 0 ) + return 0; + + var variance = sumSquares - sum*sum/n; + if (n > 1) { + variance /= n-1; + } + return variance < 0 ? 0 : Math.sqrt(variance); + }, + + /** + * Fetch feature data from this store. + * + * @param {String} query.ref the name of the reference sequence + * @param {Number} query.start start of the region in interbase coordinates + * @param {Number} query.end end of the region in interbase coordinates + * @param {Function} featureCallback(feature) callback that is called once + * for each feature in the region of interest, with a single + * argument; the feature. + * @param {Function} endCallback() callback that is called once + * for each feature in the region of interest, with a single + * argument; the feature. + * @param {Function} errorCallback(error) in the event of an error, this + * callback will be called with one argument, which is anything + * that can stringify to an error message. + */ + getFeatures: function( query, featureCallback, endCallback, errorCallback ) { + endCallback(); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BAM.js b/www/JBrowse/Store/SeqFeature/BAM.js new file mode 100644 index 00000000..845aa7f8 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BAM.js @@ -0,0 +1,117 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/Deferred', + 'dojo/_base/lang', + 'JBrowse/has', + 'JBrowse/Util', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Model/XHRBlob', + 'JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin', + './BAM/File' + ], + function( + declare, + array, + Deferred, + lang, + has, + Util, + SeqFeatureStore, + DeferredStatsMixin, + DeferredFeaturesMixin, + XHRBlob, + GlobalStatsEstimationMixin, + BAMFile + ) { + +var BAMStore = declare( [ SeqFeatureStore, DeferredStatsMixin, DeferredFeaturesMixin, GlobalStatsEstimationMixin ], + +/** + * @lends JBrowse.Store.SeqFeature.BAM + */ +{ + /** + * Data backend for reading feature data directly from a + * web-accessible BAM file. + * + * @constructs + */ + constructor: function( args ) { + + this.createSubfeatures = args.subfeatures; + + var bamBlob = args.bam || + new XHRBlob( this.resolveUrl( + args.urlTemplate || 'data.bam' + ) + ); + + var baiBlob = args.bai || + new XHRBlob( this.resolveUrl( + args.baiUrlTemplate || ( args.urlTemplate ? args.urlTemplate+'.bai' : 'data.bam.bai' ) + ) + ); + + this.bam = new BAMFile({ + store: this, + data: bamBlob, + bai: baiBlob, + chunkSizeLimit: args.chunkSizeLimit + }); + + this.source = ( bamBlob.url ? bamBlob.url.match( /\/([^/\#\?]+)($|[\#\?])/ )[1] : + bamBlob.blob ? bamBlob.blob.name : undefined ) || undefined; + + if( ! has( 'typed-arrays' ) ) { + this._failAllDeferred( 'This web browser lacks support for JavaScript typed arrays.' ); + return; + } + + this.bam.init({ + success: lang.hitch( this, + function() { + this._deferred.features.resolve({success:true}); + + this._estimateGlobalStats() + .then( lang.hitch( + this, + function( stats ) { + this.globalStats = stats; + this._deferred.stats.resolve({success:true}); + } + ), + lang.hitch( this, '_failAllDeferred' ) + ); + }), + failure: lang.hitch( this, '_failAllDeferred' ) + }); + }, + + /** + * Interrogate whether a store has data for a given reference + * sequence. Calls the given callback with either true or false. + * + * Implemented as a binary interrogation because some stores are + * smart enough to regularize reference sequence names, while + * others are not. + */ + hasRefSeq: function( seqName, callback, errorCallback ) { + var thisB = this; + seqName = thisB.browser.regularizeReferenceName( seqName ); + this._deferred.stats.then( function() { + callback( seqName in thisB.bam.chrToIndex ); + }, errorCallback ); + }, + + // called by getFeatures from the DeferredFeaturesMixin + _getFeatures: function( query, featCallback, endCallback, errorCallback ) { + this.bam.fetch( this.refSeq.name, query.start, query.end, featCallback, endCallback, errorCallback ); + } + +}); + +return BAMStore; +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BAM/File.js b/www/JBrowse/Store/SeqFeature/BAM/File.js new file mode 100644 index 00000000..d97f8427 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BAM/File.js @@ -0,0 +1,497 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/has', + 'JBrowse/Util', + 'JBrowse/Errors', + 'JBrowse/Store/LRUCache', + './Util', + './LazyFeature' + ], + function( declare, array, has, Util, Errors, LRUCache, BAMUtil, BAMFeature ) { + +var BAM_MAGIC = 21840194; +var BAI_MAGIC = 21578050; + +var dlog = function(){ console.error.apply(console, arguments); }; + +var Chunk = Util.fastDeclare({ + constructor: function(minv,maxv,bin) { + this.minv = minv; + this.maxv = maxv; + this.bin = bin; + }, + toUniqueString: function() { + return this.minv+'..'+this.maxv+' (bin '+this.bin+')'; + }, + toString: function() { + return this.toUniqueString(); + }, + fetchedSize: function() { + return this.maxv.block + (1<<16) - this.minv.block + 1; + } +}); + +var readInt = BAMUtil.readInt; +var readVirtualOffset = BAMUtil.readVirtualOffset; + +var BamFile = declare( null, + + +/** + * @lends JBrowse.Store.SeqFeature.BAM.File + */ +{ + + /** + * Low-level BAM file reading code. + * + * Adapted by Robert Buels from bam.js in the Dalliance Genome + * Explorer which is copyright Thomas Down 2006-2010 + * @constructs + */ + constructor: function( args ) { + this.store = args.store; + this.data = args.data; + this.bai = args.bai; + + this.chunkSizeLimit = args.chunkSizeLimit || 5000000; + }, + + init: function( args ) { + var bam = this; + var successCallback = args.success || function() {}; + var failCallback = args.failure || function(e) { console.error(e, e.stack); }; + + this._readBAI( dojo.hitch( this, function() { + this._readBAMheader( function() { + successCallback(); + }, failCallback ); + }), failCallback ); + }, + + _readBAI: function( successCallback, failCallback ) { + // Do we really need to fetch the whole thing? :-( + this.bai.fetch( dojo.hitch( this, function(header) { + if (!header) { + dlog("No data read from BAM index (BAI) file"); + failCallback("No data read from BAM index (BAI) file"); + return; + } + + if( ! has('typed-arrays') ) { + dlog('Web browser does not support typed arrays'); + failCallback('Web browser does not support typed arrays'); + return; + } + + var uncba = new Uint8Array(header); + if( readInt(uncba, 0) != BAI_MAGIC) { + dlog('Not a BAI file'); + failCallback('Not a BAI file'); + return; + } + + var nref = readInt(uncba, 4); + + this.indices = []; + + var p = 8; + for (var ref = 0; ref < nref; ++ref) { + var blockStart = p; + var nbin = readInt(uncba, p); p += 4; + for (var b = 0; b < nbin; ++b) { + var bin = readInt(uncba, p); + var nchnk = readInt(uncba, p+4); + p += 8; + for( var chunkNum = 0; chunkNum < nchnk; chunkNum++ ) { + var vo = readVirtualOffset( uncba, p ); + this._findMinAlignment( vo ); + p += 16; + } + } + var nintv = readInt(uncba, p); p += 4; + // as we're going through the linear index, figure out + // the smallest virtual offset in the indexes, which + // tells us where the BAM header ends + this._findMinAlignment( nintv ? readVirtualOffset(uncba,p) : null ); + + p += nintv * 8; + if( nbin > 0 || nintv > 0 ) { + this.indices[ref] = new Uint8Array(header, blockStart, p - blockStart); + } + } + + this.empty = ! this.indices.length; + + successCallback( this.indices, this.minAlignmentVO ); + }), failCallback ); + }, + + _findMinAlignment: function( candidate ) { + if( candidate && ( ! this.minAlignmentVO || this.minAlignmentVO.cmp( candidate ) < 0 ) ) + this.minAlignmentVO = candidate; + }, + + _readBAMheader: function( successCallback, failCallback ) { + var thisB = this; + // We have the virtual offset of the first alignment + // in the file. Cannot completely determine how + // much of the first part of the file to fetch to get just + // up to that, since the file is compressed. Thus, fetch + // up to the start of the BGZF block that the first + // alignment is in, plus 64KB, which should get us that whole + // BGZF block, assuming BGZF blocks are no bigger than 64KB. + thisB.data.read( + 0, + thisB.minAlignmentVO ? thisB.minAlignmentVO.block + 65535 : null, + function(r) { + var unc = BAMUtil.unbgzf(r); + var uncba = new Uint8Array(unc); + + if( readInt(uncba, 0) != BAM_MAGIC) { + dlog('Not a BAM file'); + failCallback( 'Not a BAM file' ); + return; + } + + var headLen = readInt(uncba, 4); + + thisB._readRefSeqs( headLen+8, 65536*4, successCallback, failCallback ); + }, + failCallback + ); + }, + + _readRefSeqs: function( start, refSeqBytes, successCallback, failCallback ) { + var thisB = this; + // have to do another request, because sometimes + // minAlignment VO is just flat wrong. + // if headLen is not too big, this will just be in the + // RemoteBinaryFile cache + thisB.data.read( 0, start+refSeqBytes, + function(r) { + var unc = BAMUtil.unbgzf(r); + var uncba = new Uint8Array(unc); + + var nRef = readInt(uncba, start ); + var p = start + 4; + + thisB.chrToIndex = {}; + thisB.indexToChr = []; + for (var i = 0; i < nRef; ++i) { + var lName = readInt(uncba, p); + var name = ''; + for (var j = 0; j < lName-1; ++j) { + name += String.fromCharCode(uncba[p + 4 + j]); + } + + var lRef = readInt(uncba, p + lName + 4); + //console.log(name + ': ' + lRef); + thisB.chrToIndex[ thisB.store.browser.regularizeReferenceName( name ) ] = i; + thisB.indexToChr.push({ name: name, length: lRef }); + + p = p + 8 + lName; + if( p > uncba.length ) { + // we've gotten to the end of the data without + // finishing reading the ref seqs, need to fetch a + // bigger chunk and try again. :-( + refSeqBytes *= 2; + console.warn( 'BAM header is very big. Re-fetching '+refSeqBytes+' bytes.' ); + thisB._readRefSeqs( start, refSeqBytes, successCallback, failCallback ); + return; + } + } + + successCallback(); + + }, failCallback ); + }, + + /** + * Get an array of Chunk objects for the given ref seq id and range. + */ + blocksForRange: function(refId, min, max) { + var index = this.indices[refId]; + if (!index) { + return []; + } + + // object as { : true, ... } containing the bin numbers + // that overlap this range + var overlappingBins = function() { + var intBins = {}; + var intBinsL = this._reg2bins(min, max); + for (var i = 0; i < intBinsL.length; ++i) { + intBins[intBinsL[i]] = true; + } + return intBins; + }.call(this); + + // parse the chunks for the overlapping bins out of the index + // for this ref seq, keeping a distinction between chunks from + // leaf (lowest-level, smallest) bins, and chunks from other, + // larger bins + var leafChunks = []; + var otherChunks = []; + var nbin = readInt(index, 0); + var p = 4; + for (var b = 0; b < nbin; ++b) { + var bin = readInt(index, p ); + var nchnk = readInt(index, p+4); + p += 8; + if( overlappingBins[bin] ) { + for (var c = 0; c < nchnk; ++c) { + var cs = readVirtualOffset( index, p ); + var ce = readVirtualOffset( index, p + 8 ); + ( bin < 4681 ? otherChunks : leafChunks ).push( new Chunk(cs, ce, bin) ); + p += 16; + } + } else { + p += nchnk * 16; + } + } + + // parse the linear index to find the lowest virtual offset + var lowest = function() { + var lowest = null; + var nintv = readInt(index, p); + var minLin = Math.min(min>>14, nintv - 1); + var maxLin = Math.min(max>>14, nintv - 1); + for (var i = minLin; i <= maxLin; ++i) { + var lb = readVirtualOffset(index, p + 4 + (i * 8)); + if( !lb ) + continue; + + if ( ! lowest || lb.cmp( lowest ) > 0 ) + lowest = lb; + } + return lowest; + }(); + + // discard any chunks that come before the lowest + // virtualOffset that we got from the linear index + if( lowest ) { + otherChunks = function( otherChunks ) { + var relevantOtherChunks = []; + for (var i = 0; i < otherChunks.length; ++i) { + var chnk = otherChunks[i]; + if( chnk.maxv.block >= lowest.block ) { + relevantOtherChunks.push(chnk); + } + } + return relevantOtherChunks; + }(otherChunks); + } + + // add the leaf chunks in, and sort the chunks ascending by virtual offset + var allChunks = otherChunks + .concat( leafChunks ) + .sort( function(c0, c1) { + return c0.minv.block - c1.minv.block || c0.minv.offset - c1.minv.offset; + }); + + // merge chunks from the same block together + var mergedChunks = []; + if( allChunks.length ) { + var cur = allChunks[0]; + for (var i = 1; i < allChunks.length; ++i) { + var nc = allChunks[i]; + if (nc.minv.block == cur.maxv.block /* && nc.minv.offset == cur.maxv.offset */) { // no point splitting mid-block + cur = new Chunk(cur.minv, nc.maxv, 'merged'); + } else { + mergedChunks.push(cur); + cur = nc; + } + } + mergedChunks.push(cur); + } + + return mergedChunks; + }, + + fetch: function(chr, min, max, featCallback, endCallback, errorCallback ) { + + chr = this.store.browser.regularizeReferenceName( chr ); + + var chrId = this.chrToIndex && this.chrToIndex[chr]; + var chunks; + if( !( chrId >= 0 ) ) { + chunks = []; + } else { + chunks = this.blocksForRange(chrId, min, max); + if (!chunks) { + errorCallback( new Errors.Fatal('Error in index fetch') ); + } + } + + // toString function is used by the cache for making cache keys + chunks.toString = function() { + return this.join(', '); + }; + + //console.log( chr, min, max, chunks.toString() ); + + try { + this._fetchChunkFeatures( + chunks, + chrId, + min, + max, + featCallback, + endCallback, + errorCallback + ); + } catch( e ) { + errorCallback( e ); + } + }, + + _fetchChunkFeatures: function( chunks, chrId, min, max, featCallback, endCallback, errorCallback ) { + var thisB = this; + + if( ! chunks.length ) { + endCallback(); + return; + } + + var chunksProcessed = 0; + + var cache = this.featureCache = this.featureCache || new LRUCache({ + name: 'bamFeatureCache', + fillCallback: dojo.hitch( this, '_readChunk' ), + sizeFunction: function( features ) { + return features.length; + }, + maxSize: 100000 // cache up to 100,000 BAM features + }); + + // check the chunks for any that are over the size limit. if + // any are, don't fetch any of them + for( var i = 0; i this.chunkSizeLimit ) { + errorCallback( new Errors.DataOverflow('Too many BAM features. BAM chunk size '+Util.commifyNumber(size)+' bytes exceeds chunkSizeLimit of '+Util.commifyNumber(this.chunkSizeLimit)+'.' ) ); + return; + } + } + + var haveError; + var pastStart; + array.forEach( chunks, function( c ) { + cache.get( c, function( f, e ) { + if( e && !haveError ) + errorCallback(e); + if(( haveError = haveError || e )) { + return; + } + + for( var i = 0; i max ) // past end of range, can stop iterating + break; + else if( feature.get('end') >= min ) // must be in range + featCallback( feature ); + } + } + if( ++chunksProcessed == chunks.length ) { + endCallback(); + } + }); + }); + + }, + + _readChunk: function( chunk, callback ) { + var thisB = this; + var features = []; + // console.log('chunk '+chunk+' size ',Util.humanReadableNumber(size)); + + thisB.data.read( chunk.minv.block, chunk.fetchedSize(), function(r) { + try { + var data = BAMUtil.unbgzf(r, chunk.maxv.block - chunk.minv.block + 1); + thisB.readBamFeatures( new Uint8Array(data), chunk.minv.offset, features, callback ); + } catch( e ) { + callback( null, new Errors.Fatal(e) ); + } + }, function( e ) { + callback( null, new Errors.Fatal(e) ); + }); + }, + + readBamFeatures: function(ba, blockStart, sink, callback ) { + var that = this; + var featureCount = 0; + + var maxFeaturesWithoutYielding = 300; + + while ( true ) { + if( blockStart >= ba.length ) { + // if we're done, call the callback and return + callback( sink ); + return; + } + else if( featureCount <= maxFeaturesWithoutYielding ) { + // if we've read no more than 200 features this cycle, read another one + var blockSize = readInt(ba, blockStart); + var blockEnd = blockStart + 4 + blockSize - 1; + + // only try to read the feature if we have all the bytes for it + if( blockEnd < ba.length ) { + var feature = new BAMFeature({ + store: this.store, + file: this, + bytes: { byteArray: ba, start: blockStart, end: blockEnd } + }); + sink.push(feature); + featureCount++; + } + + blockStart = blockEnd+1; + } + else { + // if we're not done but we've read a good chunk of + // features, put the rest of our work into a timeout to continue + // later, avoiding blocking any UI stuff that's going on + window.setTimeout( function() { + that.readBamFeatures( ba, blockStart, sink, callback ); + }, 1); + return; + } + } + }, + // + // Binning (transliterated from SAM1.3 spec) + // + + /* calculate bin given an alignment covering [beg,end) (zero-based, half-close-half-open) */ + _reg2bin: function( beg, end ) { + --end; + if (beg>>14 == end>>14) return ((1<<15)-1)/7 + (beg>>14); + if (beg>>17 == end>>17) return ((1<<12)-1)/7 + (beg>>17); + if (beg>>20 == end>>20) return ((1<<9)-1)/7 + (beg>>20); + if (beg>>23 == end>>23) return ((1<<6)-1)/7 + (beg>>23); + if (beg>>26 == end>>26) return ((1<<3)-1)/7 + (beg>>26); + return 0; + }, + + /* calculate the list of bins that may overlap with region [beg,end) (zero-based) */ + MAX_BIN: (((1<<18)-1)/7), + _reg2bins: function( beg, end ) { + var k, list = [ 0 ]; + --end; + for (k = 1 + (beg>>26); k <= 1 + (end>>26); ++k) list.push(k); + for (k = 9 + (beg>>23); k <= 9 + (end>>23); ++k) list.push(k); + for (k = 73 + (beg>>20); k <= 73 + (end>>20); ++k) list.push(k); + for (k = 585 + (beg>>17); k <= 585 + (end>>17); ++k) list.push(k); + for (k = 4681 + (beg>>14); k <= 4681 + (end>>14); ++k) list.push(k); + return list; + } + +}); + +return BamFile; + +}); diff --git a/www/JBrowse/Store/SeqFeature/BAM/LazyFeature.js b/www/JBrowse/Store/SeqFeature/BAM/LazyFeature.js new file mode 100644 index 00000000..8a98a0a8 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BAM/LazyFeature.js @@ -0,0 +1,431 @@ +define( ['dojo/_base/array', + 'JBrowse/Util', + './Util', + 'JBrowse/Model/SimpleFeature' + ], + function( array, Util, BAMUtil, SimpleFeature ) { + +var SEQRET_DECODER = ['=', 'A', 'C', 'x', 'G', 'x', 'x', 'x', 'T', 'x', 'x', 'x', 'x', 'x', 'x', 'N']; +var CIGAR_DECODER = ['M', 'I', 'D', 'N', 'S', 'H', 'P', '=', 'X', '?', '?', '?', '?', '?', '?', '?']; + +var readInt = BAMUtil.readInt; +var readShort = BAMUtil.readShort; +var readFloat = BAMUtil.readFloat; + +var Feature = Util.fastDeclare( +{ + constructor: function( args ) { + this.store = args.store; + this.file = args.file; + this.data = { + type: 'match', + source: args.store.source + }; + this.bytes = { + start: args.bytes.start, + end: args.bytes.end, + byteArray: args.bytes.byteArray + }; + + this._coreParse(); + }, + + get: function( field) { + return this._get( field.toLowerCase() ); + }, + + // same as get(), except requires lower-case arguments. used + // internally to save lots of calls to field.toLowerCase() + _get: function( field ) { + return field in this.data ? this.data[field] : // have we already parsed it out? + function(field) { + var v = this.data[field] = + this[field] ? this[field]() : // maybe we have a special parser for it + this._flagMasks[field] ? this._parseFlag( field ) : // or is it a flag? + this._parseTag( field ); // otherwise, look for it in the tags + return v; + }.call(this,field); + }, + + tags: function() { + return this._get('_tags'); + }, + + _tags: function() { + this._parseAllTags(); + + var tags = [ 'seq', 'seq_reverse_complemented', 'unmapped' ]; + if( ! this._get('unmapped') ) + tags.push( 'start', 'end', 'strand', 'score', 'qual', 'MQ', 'CIGAR', 'length_on_ref' ); + if( this._get('multi_segment_template') ) { + tags.push( 'multi_segment_all_aligned', + 'multi_segment_next_segment_unmapped', + 'multi_segment_next_segment_reversed', + 'multi_segment_first', + 'multi_segment_last', + 'secondary_alignment', + 'qc_failed', + 'duplicate', + 'next_segment_position' + ); + } + tags = tags.concat( this._tagList || [] ); + + var d = this.data; + for( var k in d ) { + if( d.hasOwnProperty( k ) && k[0] != '_' ) + tags.push( k ); + } + + var seen = {}; + tags = array.filter( tags, function(t) { + if( t in this.data && this.data[t] === undefined ) + return false; + + var lt = t.toLowerCase(); + var s = seen[lt]; + seen[lt] = true; + return ! s; + },this); + + return tags; + }, + + parent: function() { + return undefined; + }, + + children: function() { + return this._get('subfeatures'); + }, + + id: function() { + return this._get('name')+'/'+this._get('md')+'/'+this._get('cigar')+'/'+this._get('start'); + }, + + // special parsers + /** + * Mapping quality score. + */ + mq: function() { + var mq = (this._get('_bin_mq_nl') & 0xff00) >> 8; + return mq == 255 ? undefined : mq; + }, + score: function() { + return this._get('mq'); + }, + qual: function() { + if( this._get('unmapped') ) + return undefined; + + var qseq = []; + var byteArray = this.bytes.byteArray; + var p = this.bytes.start + 36 + this._get('_l_read_name') + this._get('_n_cigar_op')*4 + this._get('_seq_bytes'); + var lseq = this._get('seq_length'); + for (var j = 0; j < lseq; ++j) { + qseq.push( byteArray[p + j] ); + } + return qseq.join(' '); + }, + strand: function() { + var xs = this._get('xs'); + return xs ? ( xs == '-' ? -1 : 1 ) : + this._get('seq_reverse_complemented') ? -1 : 1; + }, + /** + * Length in characters of the read name. + */ + _l_read_name: function() { + return this._get('_bin_mq_nl') & 0xff; + }, + /** + * number of bytes in the sequence field + */ + _seq_bytes: function() { + return (this._get('seq_length') + 1) >> 1; + }, + seq: function() { + var seq = ''; + var byteArray = this.bytes.byteArray; + var p = this.bytes.start + 36 + this._get('_l_read_name') + this._get('_n_cigar_op')*4; + var seqBytes = this._get('_seq_bytes'); + for (var j = 0; j < seqBytes; ++j) { + var sb = byteArray[p + j]; + seq += SEQRET_DECODER[(sb & 0xf0) >> 4]; + seq += SEQRET_DECODER[(sb & 0x0f)]; + } + return seq; + }, + name: function() { + return this._get('_read_name'); + }, + _read_name: function() { + var byteArray = this.bytes.byteArray; + var readName = ''; + var nl = this._get('_l_read_name'); + var p = this.bytes.start + 36; + for (var j = 0; j < nl-1; ++j) { + readName += String.fromCharCode(byteArray[p+j]); + } + return readName; + }, + _n_cigar_op: function() { + return this._get('_flag_nc') & 0xffff; + }, + cigar: function() { + if( this._get('unmapped') ) + return undefined; + + var byteArray = this.bytes.byteArray; + var numCigarOps = this._get('_n_cigar_op'); + var p = this.bytes.start + 36 + this._get('_l_read_name'); + var cigar = ''; + var lref = 0; + for (var c = 0; c < numCigarOps; ++c) { + var cigop = readInt(byteArray, p); + var lop = cigop >> 4; + var op = CIGAR_DECODER[cigop & 0xf]; + cigar += lop + op; + + // soft clip, hard clip, and insertion don't count toward + // the length on the reference + if( op != 'H' && op != 'S' && op != 'I' ) + lref += lop; + + p += 4; + } + + this.data.length_on_ref = lref; + return cigar; + }, + next_segment_position: function() { + var nextRefID = this._get('_next_refid'); + var nextSegment = this.file.indexToChr[nextRefID]; + if( nextSegment ) + return nextSegment.name+':'+this._get('_next_pos'); + else + return undefined; + }, + subfeatures: function() { + if( ! this.store.createSubfeatures ) + return undefined; + + var cigar = this._get('cigar'); + if( cigar ) + return this._cigarToSubfeats( cigar ); + + return undefined; + }, + length_on_ref: function() { + var c = this._get('cigar'); // the length_on_ref is set as a + // side effect of the CIGAR parsing + return this.data.length_on_ref; + }, + _flags: function() { + return (this.get('_flag_nc') & 0xffff0000) >> 16; + }, + end: function() { + return this._get('start') + ( this._get('length_on_ref') || this._get('seq_length') || undefined ); + }, + + seq_id: function() { + if( this._get('unmapped') ) + return undefined; + + return ( this.file.indexToChr[ this._refID ] || {} ).name; + }, + + _bin_mq_nl: function() { + with( this.bytes ) + return readInt( byteArray, start + 12 ); + }, + _flag_nc: function() { + with( this.bytes ) + return readInt( byteArray, start + 16 ); + }, + seq_length: function() { + with( this.bytes ) + return readInt( byteArray, start + 20 ); + }, + _next_refid: function() { + with( this.bytes ) + return readInt( byteArray, start + 24 ); + }, + _next_pos: function() { + with( this.bytes ) + return readInt( byteArray, start + 28 ); + }, + template_length: function() { + with( this.bytes ) + return readInt( byteArray, start + 32 ); + }, + + /** + * parse the core data: ref ID and start + */ + _coreParse: function() { + with( this.bytes ) { + this._refID = readInt( byteArray, start + 4 ); + this.data.start = readInt( byteArray, start + 8 ); + } + }, + + /** + * Get the value of a tag, parsing the tags as far as necessary. + * Only called if we have not already parsed that field. + */ + _parseTag: function( tagName ) { + // if all of the tags have been parsed and we're still being + // called, we already know that we have no such tag, because + // it would already have been cached. + if( this._allTagsParsed ) + return undefined; + + this._tagList = this._tagList || []; + var byteArray = this.bytes.byteArray; + var p = this._tagOffset || this.bytes.start + 36 + this._get('_l_read_name') + this._get('_n_cigar_op')*4 + this._get('_seq_bytes') + this._get('seq_length'); + + var blockEnd = this.bytes.end; + while( p < blockEnd && lcTag != tagName ) { + var tag = String.fromCharCode( byteArray[p], byteArray[ p+1 ] ); + var lcTag = tag.toLowerCase(); + var type = String.fromCharCode( byteArray[ p+2 ] ); + p += 3; + + var value; + switch( type.toLowerCase() ) { + case 'a': + value = String.fromCharCode( byteArray[p] ); + p += 1; + break; + case 'i': + value = readInt(byteArray, p ); + p += 4; + break; + case 'c': + value = byteArray[p]; + p += 1; + break; + case 's': + value = readShort(byteArray, p); + p += 2; + break; + case 'f': + value = readFloat( byteArray, p ); + p += 4; + break; + case 'z': + case 'h': + value = ''; + while( p <= blockEnd ) { + var cc = byteArray[p++]; + if( cc == 0 ) { + break; + } + else { + value += String.fromCharCode(cc); + } + } + break; + default: + console.warn( "Unknown BAM tag type '"+type + +"', tags may be incomplete" + ); + value = undefined; + p = blockEnd; // stop parsing tags + } + + this._tagOffset = p; + + this._tagList.push( tag ); + if( lcTag == tagName ) + return value; + else { + this.data[ lcTag ] = value; + } + } + this._allTagsParsed = true; + return undefined; + }, + _parseAllTags: function() { + this._parseTag(); // calling _parseTag with no arg just parses + // all the tags and returns the last one + }, + + _flagMasks: { + multi_segment_template: 0x1, + multi_segment_all_aligned: 0x2, + unmapped: 0x4, + multi_segment_next_segment_unmapped: 0x8, + seq_reverse_complemented: 0x10, + multi_segment_next_segment_reversed: 0x20, + multi_segment_first: 0x40, + multi_segment_last: 0x80, + secondary_alignment: 0x100, + qc_failed: 0x200, + duplicate: 0x400 + }, + + _parseFlag: function( flagName ) { + return !!( this._get('_flags') & this._flagMasks[flagName] ); + }, + + _parseCigar: function( cigar ) { + return array.map( cigar.match(/\d+\D/g), function( op ) { + return [ op.match(/\D/)[0].toUpperCase(), parseInt( op ) ]; + }); + }, + + /** + * take a cigar string, and initial position, return an array of subfeatures + */ + _cigarToSubfeats: function(cigar) { + var subfeats = []; + var min = this._get('start'); + var max; + var ops = this._parseCigar( cigar ); + for (var i = 0; i < ops.length; i++) { + var lop = ops[i][1]; + var op = ops[i][0]; // operation type + // converting "=" to "E" to avoid possible problems later with non-alphanumeric type name + if (op === "=") { op = "E"; } + + switch (op) { + case 'M': + case 'D': + case 'N': + case 'E': + case 'X': + max = min + lop; + break; + case 'I': + max = min; + break; + case 'P': // not showing padding deletions (possibly change this later -- could treat same as 'I' ?? ) + case 'H': // not showing hard clipping (since it's unaligned, and offset arg meant to be beginning of aligned part) + case 'S': // not showing soft clipping (since it's unaligned, and offset arg meant to be beginning of aligned part) + break; + // other possible cases + } + if( op !== 'N' ) { + var subfeat = new SimpleFeature({ + data: { + type: op, + start: min, + end: max, + strand: this._get('strand'), + cigar_op: lop+op + }, + parent: this + }); + subfeats.push(subfeat); + } + min = max; + } + return subfeats; + } + +}); + +return Feature; +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BAM/Util.js b/www/JBrowse/Store/SeqFeature/BAM/Util.js new file mode 100644 index 00000000..c184b520 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BAM/Util.js @@ -0,0 +1,129 @@ +define( [ 'jszlib/inflate', + 'jszlib/arrayCopy', + 'JBrowse/Util' + ], + function( inflate, arrayCopy, Util ) { + +var VirtualOffset = Util.fastDeclare({ + constructor: function(b, o) { + this.block = b; + this.offset = o; + }, + toString: function() { + return '' + this.block + ':' + this.offset; + }, + cmp: function(b) { + var a = this; + return b.block - a.block || b.offset - a.offset; + } +}); + +/** + * @lends JBrowse.Store.SeqFeature.BAM.Util + * Package of utility functions used in various places in the BAM code. + */ +var Utils = { + + readInt: function(ba, offset) { + return (ba[offset + 3] << 24) | (ba[offset + 2] << 16) | (ba[offset + 1] << 8) | (ba[offset]); + }, + + readShort: function(ba, offset) { + return (ba[offset + 1] << 8) | (ba[offset]); + }, + + readFloat: function(ba, offset) { + var temp = new Uint8Array( 4 ); + for( var i = 0; i<4; i++ ) { + temp[i] = ba[offset+i]; + } + var fa = new Float32Array( temp.buffer ); + return fa[0]; + }, + + readVirtualOffset: function(ba, offset) { + //console.log( 'readVob', offset ); + var block = (ba[offset+6] & 0xff) * 0x100000000 + + (ba[offset+5] & 0xff) * 0x1000000 + + (ba[offset+4] & 0xff) * 0x10000 + + (ba[offset+3] & 0xff) * 0x100 + + (ba[offset+2] & 0xff); + var bint = (ba[offset+1] << 8) | ba[offset]; + if (block == 0 && bint == 0) { + return null; // Should only happen in the linear index? + } else { + return new VirtualOffset(block, bint); + } + }, + + unbgzf: function(data, lim) { + lim = Math.min( lim || Infinity, data.byteLength - 27); + var oBlockList = []; + var totalSize = 0; + + for( var ptr = [0]; ptr[0] < lim; ptr[0] += 8) { + + var ba = new Uint8Array( data, ptr[0], 18 ); + + // check the bgzf block magic + if( !( ba[0] == 31 && ba[1] == 139 ) ) { + console.error( 'invalid BGZF block header, skipping', ba ); + break; + } + + var xlen = Utils.readShort( ba, 10 ); + var compressedDataOffset = ptr[0] + 12 + xlen; + + // var inPtr = ptr[0]; + // var bSize = Utils.readShort( ba, 16 ); + // var logLength = Math.min(data.byteLength-ptr[0], 40); + // console.log( xlen, bSize, bSize - xlen - 19, new Uint8Array( data, ptr[0], logLength ), logLength ); + + var unc; + try { + unc = inflate( + data, + compressedDataOffset, + data.byteLength - compressedDataOffset, + ptr + ); + } catch( inflateError ) { + // if we have a buffer error and we have already + // inflated some data, there is probably just an + // incomplete BGZF block at the end of the data, so + // ignore it and stop inflating + if( /^Z_BUF_ERROR/.test(inflateError.statusString) && oBlockList.length ) { + break; + } + // otherwise it's some other kind of real error + else { + throw inflateError; + } + } + if( unc.byteLength ) { + totalSize += unc.byteLength; + oBlockList.push( unc ); + } + // else { + // console.error( 'BGZF decompression failed for block ', compressedDataOffset, data.byteLength-compressedDataOffset, [inPtr] ); + // } + } + + if (oBlockList.length == 1) { + return oBlockList[0]; + } else { + var out = new Uint8Array(totalSize); + var cursor = 0; + for (var i = 0; i < oBlockList.length; ++i) { + var b = new Uint8Array(oBlockList[i]); + arrayCopy(b, 0, out, cursor, b.length); + cursor += b.length; + } + return out.buffer; + } + } +}; + +return Utils; + +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BAMCombination.js b/www/JBrowse/Store/SeqFeature/BAMCombination.js new file mode 100644 index 00000000..60a81065 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BAMCombination.js @@ -0,0 +1,60 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Store/SeqFeature/CombinationBase', + 'JBrowse/Store/SeqFeature/BAM/LazyFeature' + ], + + function( + declare, + array, + CombinationBaseStore, + BAMFeature + ) { + + return declare([CombinationBaseStore], { + + // An implementation of the CombinationBaseStore which works with BAM features. Currently, the only supported option is + // a straight concatenation of the features of two stores. + + // This combination store doesn't need to convert between spans and features, so these two functions are essentially irrelevant. + createFeatures: function(spans) { + return spans; + }, + + toSpan: function(features, query) { + return features.map(function(feat) { + return new BAMFeature( feat.feature ? feat.feature : feat ) + }); + }, + + // Only one supported operation - array concatenation + opSpan: function(op, span1, span2, query) { + + if(op == "U") { + this._appendStringToID( span1, "L" ); + this._appendStringToID( span2, "R" ); + return span1.concat( span2 ); + } + console.error( "invalid operation" ); + return undefined; + }, + + _appendStringToID: function ( objArray, string ) { + array.forEach( objArray, function( object ) { + var oldID = object.id; + if( typeof oldID == 'function' ) { + object.id = function() { + return oldID.call( object ) + string; + } + } else { + object.id = oldID + string; + } + }); + return objArray; + } + + }); + + +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BigWig.js b/www/JBrowse/Store/SeqFeature/BigWig.js new file mode 100644 index 00000000..6a1eee44 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BigWig.js @@ -0,0 +1,314 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'dojo/_base/url', + 'JBrowse/has', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Store/DeferredFeaturesMixin', + './BigWig/Window', + 'JBrowse/Util', + 'JBrowse/Model/XHRBlob' + ], + function( declare, lang, array, urlObj, has, SeqFeatureStore, DeferredFeaturesMixin, DeferredStatsMixin, Window, Util, XHRBlob ) { +return declare([ SeqFeatureStore, DeferredFeaturesMixin, DeferredStatsMixin ], + + /** + * @lends JBrowse.Store.BigWig + */ +{ + + BIG_WIG_MAGIC: -2003829722, + BIG_BED_MAGIC: -2021002517, + + BIG_WIG_TYPE_GRAPH: 1, + BIG_WIG_TYPE_VSTEP: 2, + BIG_WIG_TYPE_FSTEP: 3, + + /** + * Data backend for reading wiggle data from BigWig or BigBed files. + * + * Adapted by Robert Buels from bigwig.js in the Dalliance Genome + * Explorer which is copyright Thomas Down 2006-2010 + * @constructs + */ + constructor: function( args ) { + + this.data = args.blob || + new XHRBlob( this.resolveUrl( + args.urlTemplate || 'data.bigwig' + ) + ); + + this.name = args.name || ( this.data.url && new urlObj( this.data.url ).path.replace(/^.+\//,'') ) || 'anonymous'; + + this._load(); + }, + + _getGlobalStats: function( successCallback, errorCallback ) { + var s = this._globalStats || {}; + + // calc mean and standard deviation if necessary + if( !( 'scoreMean' in s )) + s.scoreMean = s.basesCovered ? s.scoreSum / s.basesCovered : 0; + if( !( 'scoreStdDev' in s )) + s.scoreStdDev = this._calcStdFromSums( s.scoreSum, s.scoreSumSquares, s.basesCovered ); + + successCallback( s ); + }, + + _load: function() { + var bwg = this; + var headerSlice = bwg.data.slice(0, 512); + headerSlice.fetch( function( result ) { + if( ! result ) { + this._failAllDeferred( 'BBI header not readable' ); + return; + } + + bwg.fileSize = result.fileSize;; + var header = result; + var sa = new Int16Array(header); + var la = new Int32Array(header); + if (la[0] == bwg.BIG_WIG_MAGIC) { + bwg.type = 'bigwig'; + } else if (la[0] == bwg.BIG_BED_MAGIC) { + bwg.type = 'bigbed'; + } else { + console.error( 'Format '+la[0]+' not supported' ); + bwg._failAllDeferred( 'Format '+la[0]+' not supported' ); + return; + } + // dlog('magic okay'); + + bwg.version = sa[2]; // 4 + bwg.numZoomLevels = sa[3]; // 6 + bwg.chromTreeOffset = (la[2] << 32) | (la[3]); // 8 + bwg.unzoomedDataOffset = (la[4] << 32) | (la[5]); // 16 + bwg.unzoomedIndexOffset = (la[6] << 32) | (la[7]); // 24 + bwg.fieldCount = sa[16]; // 32 + bwg.definedFieldCount = sa[17]; // 34 + bwg.asOffset = (la[9] << 32) | (la[10]); // 36 (unaligned longlong) + bwg.totalSummaryOffset = (la[11] << 32) | (la[12]); // 44 (unaligned longlong) + bwg.uncompressBufSize = la[13]; // 52 + + // dlog('bigType: ' + bwg.type); + // dlog('chromTree at: ' + bwg.chromTreeOffset); + // dlog('uncompress: ' + bwg.uncompressBufSize); + // dlog('data at: ' + bwg.unzoomedDataOffset); + // dlog('index at: ' + bwg.unzoomedIndexOffset); + // dlog('field count: ' + bwg.fieldCount); + // dlog('defined count: ' + bwg.definedFieldCount); + + bwg.zoomLevels = []; + for (var zl = 0; zl < bwg.numZoomLevels; ++zl) { + var zlReduction = la[zl*6 + 16]; + var zlData = (la[zl*6 + 18]<<32)|(la[zl*6 + 19]); + var zlIndex = (la[zl*6 + 20]<<32)|(la[zl*6 + 21]); + // dlog('zoom(' + zl + '): reduction=' + zlReduction + '; data=' + zlData + '; index=' + zlIndex); + bwg.zoomLevels.push({reductionLevel: zlReduction, dataOffset: zlData, indexOffset: zlIndex}); + } + + // parse the totalSummary if present (summary of all data in the file) + if( bwg.totalSummaryOffset ) { + if( Float64Array ) { + (function() { + var ua = new Uint32Array( header, bwg.totalSummaryOffset, 2 ); + var da = new Float64Array( header, bwg.totalSummaryOffset+8, 4 ); + var s = { + basesCovered: ua[0]<<32 | ua[1], + scoreMin: da[0], + scoreMax: da[1], + scoreSum: da[2], + scoreSumSquares: da[3] + }; + bwg._globalStats = s; + // rest of these will be calculated on demand in getGlobalStats + }).call(); + } else { + console.warn("BigWig "+bwg.data.url+ " total summary not available, this web browser is not capable of handling this data type."); + } + } else { + console.warn("BigWig "+bwg.data.url+ " has no total summary data."); + } + + bwg._readChromTree( + function() { + bwg._deferred.features.resolve({success: true}); + bwg._deferred.stats.resolve({success: true}); + }, + dojo.hitch( bwg, '_failAllDeferred' ) + ); + }, + dojo.hitch( this, '_failAllDeferred' ) + ); + }, + + + _readInt: function(ba, offset) { + return (ba[offset + 3] << 24) | (ba[offset + 2] << 16) | (ba[offset + 1] << 8) | (ba[offset]); + }, + + _readShort: function(ba, offset) { + return (ba[offset + 1] << 8) | (ba[offset]); + }, + + /** + * @private + */ + _readChromTree: function( callback, errorCallback ) { + var thisB = this; + this.refsByNumber = {}; + this.refsByName = {}; + + var udo = this.unzoomedDataOffset; + while ((udo % 4) != 0) { + ++udo; + } + + var readInt = this._readInt; + var readShort = this._readShort; + + this.data.slice( this.chromTreeOffset, udo - this.chromTreeOffset ) + .fetch(function(bpt) { + if( ! has('typed-arrays') ) { + thisB._failAllDeferred( 'Web browser does not support typed arrays' ); + return; + } + var ba = new Uint8Array(bpt); + var la = new Int32Array(bpt, 0, 6); + var bptMagic = la[0]; + if( bptMagic !== 2026540177 ) + throw "parse error: not a Kent bPlusTree"; + var blockSize = la[1]; + var keySize = la[2]; + var valSize = la[3]; + var itemCount = (la[4] << 32) | (la[5]); + var rootNodeOffset = 32; + + //dlog('blockSize=' + blockSize + ' keySize=' + keySize + ' valSize=' + valSize + ' itemCount=' + itemCount); + + var bptReadNode = function(offset) { + if( offset >= ba.length ) + throw "reading beyond end of buffer"; + var isLeafNode = ba[offset]; + var cnt = readShort( ba, offset+2 ); + //dlog('ReadNode: ' + offset + ' type=' + isLeafNode + ' count=' + cnt); + offset += 4; + for (var n = 0; n < cnt; ++n) { + if( isLeafNode ) { + // parse leaf node + var key = ''; + for (var ki = 0; ki < keySize; ++ki) { + var charCode = ba[offset++]; + if (charCode != 0) { + key += String.fromCharCode(charCode); + } + } + var refId = readInt( ba, offset ); + var refSize = readInt( ba, offset+4 ); + offset += 8; + + var refRec = { name: key, id: refId, length: refSize }; + + //dlog(key + ':' + refId + ',' + refSize); + thisB.refsByName[ thisB.browser.regularizeReferenceName(key) ] = refRec; + thisB.refsByNumber[refId] = refRec; + } else { + // parse index node + offset += keySize; + var childOffset = (readInt( ba, offset+4 ) << 32) | readInt( ba, offset ); + offset += 8; + childOffset -= thisB.chromTreeOffset; + bptReadNode(childOffset); + } + } + }; + bptReadNode(rootNodeOffset); + + callback.call( thisB, thisB ); + }, errorCallback ); + }, + + /** + * Interrogate whether a store has data for a given reference + * sequence. Calls the given callback with either true or false. + * + * Implemented as a binary interrogation because some stores are + * smart enough to regularize reference sequence names, while + * others are not. + */ + hasRefSeq: function( seqName, callback, errorCallback ) { + var thisB = this; + seqName = thisB.browser.regularizeReferenceName( seqName ); + this._deferred.features.then(function() { + callback( seqName in thisB.refsByName ); + }, errorCallback ); + }, + + _getFeatures: function( query, featureCallback, endCallback, errorCallback ) { + + var chrName = this.browser.regularizeReferenceName( query.ref ); + var min = query.start; + var max = query.end; + + var v = query.basesPerSpan ? this.getView( 1/query.basesPerSpan ) : + query.scale ? this.getView( scale ) : + this.getView( 1 ); + + if( !v ) { + endCallback(); + return; + } + + v.readWigData( chrName, min, max, dojo.hitch( this, function( features ) { + array.forEach( features || [], featureCallback ); + endCallback(); + }), errorCallback ); + }, + + getUnzoomedView: function() { + if (!this.unzoomedView) { + var cirLen = 4000; + var nzl = this.zoomLevels[0]; + if (nzl) { + cirLen = this.zoomLevels[0].dataOffset - this.unzoomedIndexOffset; + } + this.unzoomedView = new Window( this, this.unzoomedIndexOffset, cirLen, false ); + } + return this.unzoomedView; + }, + + getView: function( scale ) { + if( ! this.zoomLevels || ! this.zoomLevels.length ) + return null; + + if( !this._viewCache || this._viewCache.scale != scale ) { + this._viewCache = { + scale: scale, + view: this._getView( scale ) + }; + } + return this._viewCache.view; + }, + + _getView: function( scale ) { + var basesPerSpan = 1/scale; + //console.log('getting view for '+basesPerSpan+' bases per span'); + for( var i = this.zoomLevels.length - 1; i > 0; i-- ) { + var zh = this.zoomLevels[i]; + if( zh && zh.reductionLevel <= basesPerSpan ) { + var indexLength = i < this.zoomLevels.length - 1 + ? this.zoomLevels[i + 1].dataOffset - zh.indexOffset + : this.fileSize - 4 - zh.indexOffset; + //console.log( 'using zoom level '+i); + return new Window( this, zh.indexOffset, indexLength, true ); + } + } + //console.log( 'using unzoomed level'); + return this.getUnzoomedView(); + } +}); + +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BigWig/RequestWorker.js b/www/JBrowse/Store/SeqFeature/BigWig/RequestWorker.js new file mode 100644 index 00000000..449c025b --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BigWig/RequestWorker.js @@ -0,0 +1,425 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'JBrowse/Util/RejectableFastPromise', + 'dojo/promise/all', + 'JBrowse/Model/Range', + 'jszlib/inflate', + 'jszlib/arrayCopy' + ], + function( declare, dlang, array, RejectableFastPromise, all, Range, inflate, arrayCopy ) { + +var dlog = function(){ console.log.apply(console, arguments); }; + +var gettable = declare( null, { + get: function(name) { + return this[name]; + }, + tags: function() { + return ['start','end','seq_id','score','type','source']; + } +}); +var Feature = declare( gettable, {} ); +var Group = declare( gettable, {} ); + +var RequestWorker = declare( null, + /** + * @lends JBrowse.Store.BigWig.Window.RequestWorker.prototype + */ + { + + BIG_WIG_TYPE_GRAPH: 1, + BIG_WIG_TYPE_VSTEP: 2, + BIG_WIG_TYPE_FSTEP: 3, + + /** + * Worker object for reading data from a bigwig or bigbed file. + * Manages the state necessary for traversing the index trees and + * so forth. + * + * Adapted by Robert Buels from bigwig.js in the Dalliance Genome + * Explorer by Thomas Down. + * @constructs + */ + constructor: function( window, chr, min, max, callback, errorCallback ) { + this.window = window; + this.source = window.bwg.name || undefined; + + this.blocksToFetch = []; + this.outstanding = 0; + + this.chr = chr; + this.min = min; + this.max = max; + this.callback = callback; + this.errorCallback = errorCallback || function(e) { console.error( e, e.stack, arguments.caller ); }; + }, + + cirFobRecur: function(offset, level) { + this.outstanding += offset.length; + + var maxCirBlockSpan = 4 + (this.window.cirBlockSize * 32); // Upper bound on size, based on a completely full leaf node. + var spans; + for (var i = 0; i < offset.length; ++i) { + var blockSpan = new Range(offset[i], Math.min(offset[i] + maxCirBlockSpan, this.window.cirTreeOffset + this.window.cirTreeLength)); + spans = spans ? spans.union( blockSpan ) : blockSpan; + } + + var fetchRanges = spans.ranges(); + //dlog('fetchRanges: ' + fetchRanges); + for (var r = 0; r < fetchRanges.length; ++r) { + var fr = fetchRanges[r]; + this.cirFobStartFetch(offset, fr, level); + } + }, + + cirFobStartFetch: function(offset, fr, level, attempts) { + var length = fr.max() - fr.min(); + //dlog('fetching ' + fr.min() + '-' + fr.max() + ' (' + (fr.max() - fr.min()) + ')'); + //console.log('cirfobstartfetch'); + this.window.bwg.data + .slice(fr.min(), fr.max() - fr.min()) + .fetch( dlang.hitch( this,function(resultBuffer) { + for (var i = 0; i < offset.length; ++i) { + if (fr.contains(offset[i])) { + this.cirFobRecur2(resultBuffer, offset[i] - fr.min(), level); + --this.outstanding; + if (this.outstanding == 0) { + this.cirCompleted(); + } + } + } + }), this.errorCallback ); + }, + + cirFobRecur2: function(cirBlockData, offset, level) { + var ba = new Int8Array(cirBlockData); + var sa = new Int16Array(cirBlockData); + var la = new Int32Array(cirBlockData); + + var isLeaf = ba[offset]; + var cnt = sa[offset/2 + 1]; + // dlog('cir level=' + level + '; cnt=' + cnt); + offset += 4; + + if (isLeaf != 0) { + for (var i = 0; i < cnt; ++i) { + var lo = offset/4; + var startChrom = la[lo]; + var startBase = la[lo + 1]; + var endChrom = la[lo + 2]; + var endBase = la[lo + 3]; + var blockOffset = (la[lo + 4]<<32) | (la[lo + 5]); + var blockSize = (la[lo + 6]<<32) | (la[lo + 7]); + if ((startChrom < this.chr || (startChrom == this.chr && startBase <= this.max)) && + (endChrom > this.chr || (endChrom == this.chr && endBase >= this.min))) + { + // dlog('Got an interesting block: startBase=' + startBase + '; endBase=' + endBase + '; offset=' + blockOffset + '; size=' + blockSize); + this.blocksToFetch.push({offset: blockOffset, size: blockSize}); + } + offset += 32; + } + } else { + var recurOffsets = []; + for (var i = 0; i < cnt; ++i) { + var lo = offset/4; + var startChrom = la[lo]; + var startBase = la[lo + 1]; + var endChrom = la[lo + 2]; + var endBase = la[lo + 3]; + var blockOffset = (la[lo + 4]<<32) | (la[lo + 5]); + if ((startChrom < this.chr || (startChrom == this.chr && startBase <= this.max)) && + (endChrom > this.chr || (endChrom == this.chr && endBase >= this.min))) + { + recurOffsets.push(blockOffset); + } + offset += 24; + } + if (recurOffsets.length > 0) { + this.cirFobRecur(recurOffsets, level + 1); + } + } + }, + + cirCompleted: function() { + // merge contiguous blocks + this.blockGroupsToFetch = this.groupBlocks( this.blocksToFetch ); + + if (this.blockGroupsToFetch.length == 0) { + this.callback([]); + } else { + this.features = []; + this.readFeatures(); + } + }, + + + groupBlocks: function( blocks ) { + + // sort the blocks by file offset + blocks.sort(function(b0, b1) { + return (b0.offset|0) - (b1.offset|0); + }); + + // group blocks that are within 2KB of eachother + var blockGroups = []; + var lastBlock; + var lastBlockEnd; + for( var i = 0; i= this.min) { + this.createFeature( fmin, fmax, opts ); + } + }, + + parseSummaryBlock: function( block, startOffset ) { + var sa = new Int16Array(block.data, startOffset ); + var la = new Int32Array(block.data, startOffset ); + var fa = new Float32Array(block.data, startOffset ); + + var itemCount = block.data.byteLength/32; + for (var i = 0; i < itemCount; ++i) { + var chromId = la[(i*8)]; + var start = la[(i*8)+1]; + var end = la[(i*8)+2]; + var validCnt = la[(i*8)+3]; + var minVal = fa[(i*8)+4]; + var maxVal = fa[(i*8)+5]; + var sumData = fa[(i*8)+6]; + var sumSqData = fa[(i*8)+7]; + + if (chromId == this.chr) { + var summaryOpts = {score: sumData/validCnt}; + if (this.window.bwg.type == 'bigbed') { + summaryOpts.type = 'density'; + } + this.maybeCreateFeature( start, end, summaryOpts); + } + } + }, + + parseBigWigBlock: function( block, startOffset ) { + var ba = new Uint8Array(block.data, startOffset ); + var sa = new Int16Array(block.data, startOffset ); + var la = new Int32Array(block.data, startOffset ); + var fa = new Float32Array(block.data, startOffset ); + + var chromId = la[0]; + var blockStart = la[1]; + var blockEnd = la[2]; + var itemStep = la[3]; + var itemSpan = la[4]; + var blockType = ba[20]; + var itemCount = sa[11]; + + // dlog('processing bigwig block, type=' + blockType + '; count=' + itemCount); + + if (blockType == this.BIG_WIG_TYPE_FSTEP) { + for (var i = 0; i < itemCount; ++i) { + var score = fa[i + 6]; + this.maybeCreateFeature( blockStart + (i*itemStep), blockStart + (i*itemStep) + itemSpan, {score: score}); + } + } else if (blockType == this.BIG_WIG_TYPE_VSTEP) { + for (var i = 0; i < itemCount; ++i) { + var start = la[(i*2) + 6]; + var score = fa[(i*2) + 7]; + this.maybeCreateFeature( start, start + itemSpan, {score: score}); + } + } else if (blockType == this.BIG_WIG_TYPE_GRAPH) { + for (var i = 0; i < itemCount; ++i) { + var start = la[(i*3) + 6]; + var end = la[(i*3) + 7]; + var score = fa[(i*3) + 8]; + if (start > end) { + start = end; + } + this.maybeCreateFeature( start, end, {score: score}); + } + } else { + dlog('Currently not handling bwgType=' + blockType); + } + }, + + parseBigBedBlock: function( block, startOffset ) { + var ba = new Uint8Array( block.data, startOffset ); + var offset = 0; + while (offset < ba.length) { + var chromId = (ba[offset+3]<<24) | (ba[offset+2]<<16) | (ba[offset+1]<<8) | (ba[offset+0]); + var start = (ba[offset+7]<<24) | (ba[offset+6]<<16) | (ba[offset+5]<<8) | (ba[offset+4]); + var end = (ba[offset+11]<<24) | (ba[offset+10]<<16) | (ba[offset+9]<<8) | (ba[offset+8]); + offset += 12; + var rest = ''; + while (true) { + var ch = ba[offset++]; + if (ch != 0) { + rest += String.fromCharCode(ch); + } else { + break; + } + } + + var featureOpts = {}; + + var bedColumns = rest.split('\t'); + if (bedColumns.length > 0) { + featureOpts.label = bedColumns[0]; + } + if (bedColumns.length > 1) { + featureOpts.score = stringToInt(bedColumns[1]); + } + if (bedColumns.length > 2) { + featureOpts.orientation = bedColumns[2]; + } + if (bedColumns.length > 5) { + var color = bedColumns[5]; + if (this.window.BED_COLOR_REGEXP.test(color)) { + featureOpts.override_color = 'rgb(' + color + ')'; + } + } + + if (bedColumns.length < 9) { + if (chromId == this.chr) { + this.maybeCreateFeature( start, end, featureOpts); + } + } else if (chromId == this.chr && start <= this.max && end >= this.min) { + // Complex-BED? + // FIXME this is currently a bit of a hack to do Clever Things with ensGene.bb + + var thickStart = bedColumns[3]|0; + var thickEnd = bedColumns[4]|0; + var blockCount = bedColumns[6]|0; + var blockSizes = bedColumns[7].split(','); + var blockStarts = bedColumns[8].split(','); + + featureOpts.type = 'bb-transcript'; + var grp = new Group(); + grp.id = bedColumns[0]; + grp.type = 'bb-transcript'; + grp.notes = []; + featureOpts.groups = [grp]; + + if (bedColumns.length > 10) { + var geneId = bedColumns[9]; + var geneName = bedColumns[10]; + var gg = new Group(); + gg.id = geneId; + gg.label = geneName; + gg.type = 'gene'; + featureOpts.groups.push(gg); + } + + var spans = null; + for (var b = 0; b < blockCount; ++b) { + var bmin = (blockStarts[b]|0) + start; + var bmax = bmin + (blockSizes[b]|0); + var span = new Range(bmin, bmax); + if (spans) { + spans = spans.union( span ); + } else { + spans = span; + } + } + + var tsList = spans.ranges(); + for (var s = 0; s < tsList.length; ++s) { + var ts = tsList[s]; + this.createFeature( ts.min(), ts.max(), featureOpts); + } + + if (thickEnd > thickStart) { + var tl = spans.intersection( new Range(thickStart, thickEnd) ); + if (tl) { + featureOpts.type = 'bb-translation'; + var tlList = tl.ranges(); + for (var s = 0; s < tlList.length; ++s) { + var ts = tlList[s]; + this.createFeature( ts.min(), ts.max(), featureOpts); + } + } + } + } + } + }, + + readFeatures: function() { + var thisB = this; + var blockFetches = array.map( thisB.blockGroupsToFetch, function( blockGroup ) { + //console.log( 'fetching blockgroup with '+blockGroup.blocks.length+' blocks: '+blockGroup ); + var d = new RejectableFastPromise(); + thisB.window.bwg.data + .slice( blockGroup.offset, blockGroup.size ) + .fetch( function(result) { + array.forEach( blockGroup.blocks, function( block ) { + var offset = block.offset-blockGroup.offset; + if( thisB.window.bwg.uncompressBufSize > 0 ) { + // var beforeInf = new Date(); + block.data = inflate( result, offset+2, block.size - 2); + //console.log( 'inflate', 2, block.size - 2); + // var afterInf = new Date(); + // dlog('inflate: ' + (afterInf - beforeInf) + 'ms'); + } else { + var tmp = new Uint8Array(block.size); // FIXME is this really the best we can do? + arrayCopy(new Uint8Array(result, offset, block.size), 0, tmp, 0, block.size); + block.data = tmp.buffer; + } + }); + d.resolve( blockGroup ); + }, dlang.hitch( d, 'reject' ) ); + return d; + }, thisB ); + + all( blockFetches ).then( function( blockGroups ) { + array.forEach( blockGroups, function( blockGroup ) { + array.forEach( blockGroup.blocks, function( block ) { + if( thisB.window.isSummary ) { + thisB.parseSummaryBlock( block, block.fetchOffset||0 ); + } else if (thisB.window.bwg.type == 'bigwig') { + thisB.parseBigWigBlock( block, block.fetchOffset||0 ); + } else if (thisB.window.bwg.type == 'bigbed') { + thisB.parseBigBedBlock( block, block.fetchOffset||0 ); + } else { + dlog("Don't know what to do with " + thisB.window.bwg.type); + } + }); + }); + + thisB.callback( thisB.features ); + }, thisB.errorCallback ); + } +}); + +return RequestWorker; + +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/BigWig/Window.js b/www/JBrowse/Store/SeqFeature/BigWig/Window.js new file mode 100644 index 00000000..6e2781cd --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/BigWig/Window.js @@ -0,0 +1,75 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + './RequestWorker' + ], + function( declare, lang, RequestWorker ) { + +var dlog = function(){ console.log.apply(console, arguments); }; + +return declare( null, + /** + * @lends JBrowse.Store.BigWig.Window.prototype + */ +{ + + /** + * View into a subset of the data in a BigWig file. + * + * Adapted by Robert Buels from bigwig.js in the Dalliance Genome + * Explorer by Thomas Down. + * @constructs + */ + constructor: function(bwg, cirTreeOffset, cirTreeLength, isSummary) { + this.bwg = bwg; + this.cirTreeOffset = cirTreeOffset; + this.cirTreeLength = cirTreeLength; + this.isSummary = isSummary; + }, + + BED_COLOR_REGEXP: /^[0-9]+,[0-9]+,[0-9]+/, + + readWigData: function(chrName, min, max, callback, errorCallback ) { + // console.log( 'reading wig data from '+chrName+':'+min+'..'+max); + var chr = this.bwg.refsByName[chrName]; + if ( ! chr ) { + // Not an error because some .bwgs won't have data for all chromosomes. + + // dlog("Couldn't find chr " + chrName); + // dlog('Chroms=' + miniJSONify(this.bwg.refsByName)); + callback([]); + } else { + this.readWigDataById( chr.id, min, max, callback, errorCallback ); + } + }, + + readWigDataById: function(chr, min, max, callback, errorCallback ) { + if( !this.cirHeader ) { + var readCallback = lang.hitch( this, 'readWigDataById', chr, min, max, callback ); + if( this.cirHeaderLoading ) { + this.cirHeaderLoading.push( readCallback ); + } + else { + this.cirHeaderLoading = [ readCallback ]; + // dlog('No CIR yet, fetching'); + this.bwg.data + .slice(this.cirTreeOffset, 48) + .fetch( lang.hitch( this, function(result) { + this.cirHeader = result; + var la = new Int32Array( this.cirHeader, 0, 2 ); + this.cirBlockSize = la[1]; + dojo.forEach( this.cirHeaderLoading, function(c) { c(); }); + delete this.cirHeaderLoading; + }), errorCallback ); + } + return; + } + + //dlog('_readWigDataById', chr, min, max, callback); + + var worker = new RequestWorker( this, chr, min, max, callback, errorCallback ); + worker.cirFobRecur([this.cirTreeOffset + 48], 1); + } +}); + +}); diff --git a/www/JBrowse/Store/SeqFeature/Combination.js b/www/JBrowse/Store/SeqFeature/Combination.js new file mode 100644 index 00000000..d93adde1 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/Combination.js @@ -0,0 +1,245 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/Deferred', + 'JBrowse/Model/SimpleFeature', + 'JBrowse/Store/SeqFeature/CombinationBase' + ], + function( + declare, + array, + Deferred, + SimpleFeature, + CombinationBaseStore + ) { + +return declare([CombinationBaseStore], { + +// An implementation of CombinationBase that deals with set-type features (without score, as in HTMLFeatures tracks). +// Usual operations are things like intersection, union, set subtraction and XOR. + +// Creates features from spans. Essentially copies the basic span information and adds a feature id. +createFeatures: function(spans) { + var features = []; + //Validate this next time... + for(var span in spans) { + var id = "comfeat_" + spans[span].start + "." + spans[span].end + "." + spans[span].strand; + features.push(new SimpleFeature({data: {start: spans[span].start, end: spans[span].end, strand: spans[span].strand}, id: id})); + } + return features; +}, + + +// Defines the various set-theoretic operations that may occur and assigns each to a span-making function. +// Passes the two sets of spans to the appropriate operator function. +opSpan: function(op, span1, span2, query) { + switch (op) { + case "&" : + return this.andSpan(span1, span2); + break; + case "U" : + return this.orSpan(span1, span2); + break; + case "X" : + return this.andSpan(this.orSpan(span1, span2), this.notSpan(this.andSpan(span1, span2), query)); + break; + case "S" : + return this.andSpan( span1, this.notSpan(span2, query) ); + break; + default : + console.error("Invalid boolean operation: "+op); + break; + } + return undefined; +}, + +// given a set of features, takes the "union" of them and outputs a single set of nonoverlapping spans +toSpan: function(features, query) { + // strip away extra stuff and keep only the relevant feature data + var rawSpans = this._rawToSpan(features,query); + + // Splits the spans based on which strand they're on, and remove overlap from each strand's spans, recombining at the end. + return this._removeOverlap(this._strandFilter(rawSpans, +1)).concat(this._removeOverlap(this._strandFilter(rawSpans, -1))); + +}, + +_rawToSpan: function( features, query ) { + // given a set of features, makes a set of spans with the + // same start and end points (a.k.a. pseudo-features) + var spans = []; + for (var feature in features) { + if (features.hasOwnProperty(feature)) { + spans.push( { start: features[feature].get('start'), //Math.max( features[feature].get('start'), query.start ), + end: features[feature].get('end'), //Math.min( features[feature].get('end'), query.end ), + strand: features[feature].get('strand') } ); + } } + return spans; +}, + + +// Filters an array of spans based on which strand of the reference sequence they are attached to +_strandFilter: function( spans, strand ) { + return array.filter( spans, function(item) { + return item.strand == strand || !item.strand; + }).map( function(item) { + if(!item.strand) + return { start: item.start, end: item.end, strand: strand } // Adds strand to strandless spans + else + return item; + }); +}, + +// converts overlapping spans into their union. Assumes the spans are all on the same strand. +_removeOverlap: function( spans ) { + if(!spans.length) { + return []; + } + spans.sort(function(a,b) { return a.start - b.start; }); + return this._removeOverlapSorted(spans); + +}, + +// Given an array of spans sorted by their start bp, converts them into a single non-overlapping set (ie takes their union). +_removeOverlapSorted: function( spans ) { + var retSpans = []; + var i = 0; + var strand = spans[0].strand; + while(i < spans.length) { + var start = spans[i].start; + var end = spans[i].end; + while(i < spans.length && spans[i].start <= end) { + end = Math.max(end, spans[i].end); + i++; + } + retSpans.push( { start: start, end: end, strand: strand}); + } + return retSpans; +}, + + // given two sets of spans without internal overlap, outputs a set corresponding to their union. +orSpan: function( span1, span2 ){ + return this._computeUnion(this._strandFilter(span1, 1), this._strandFilter(span2, 1)) + .concat(this._computeUnion(this._strandFilter(span1,-1), this._strandFilter(span2,-1))); +}, + + // given two sets of spans without internal overlap, outputs a set corresponding to their intersection +andSpan: function( span1, span2){ + + return this._computeIntersection(this._strandFilter(span1, 1), this._strandFilter(span2,1)) + .concat(this._computeIntersection(this._strandFilter(span1,-1), this._strandFilter(span2,-1))); + +}, + + // This method should merge two sorted span arrays in O(n) time, which is better + // then using span1.concat(span2) and then array.sort(), which takes O(n*log(n)) time. +_sortedArrayMerge: function( span1, span2) { + var newArray = []; + var i = 0; + var j = 0; + while(i < span1.length && j < span2.length) { + if( span1[i].start <= span2[j].start ) { + newArray.push(span1[i]); + i++; + } else { + newArray.push(span2[j]); + j++; + } + } + if(i < span1.length) { + newArray = newArray.concat(span1.slice(i, span1.length)); + } else if(j < span2.length) { + newArray = newArray.concat(span2.slice(j, span2.length)); + } + return newArray; +}, + + // A helper method for computing the union of two arrays of spans. +_computeUnion: function( span1, span2) { + if(!span1.length && !span2.length) { + return []; + } + return this._removeOverlapSorted(this._sortedArrayMerge(span1,span2)); +}, + + // A helper method for computing the intersection of two arrays of spans. +_computeIntersection: function( span1, span2) { + if(!span1.length || !span2.length) { + return []; + } + + var allSpans = this._sortedArrayMerge(span1, span2); + var retSpans = []; + + var maxEnd = allSpans[0].end; + var strand = span1[0].strand; // Assumes both span sets contain only features for one specific strand + var i = 1; + while(i < allSpans.length) { + var start = allSpans[i].start; + var end = Math.min(allSpans[i].end, maxEnd); + if(start < end) { + retSpans.push({start: start, end: end, strand: strand}); + } + maxEnd = Math.max(allSpans[i].end, maxEnd); + i++; + } + + return retSpans; +}, + +// Filters span set by strand, inverts the sets represented on each strand, and recombines. +notSpan: function( spans, query) { + return this._rawNotSpan(this._strandFilter(spans, +1), query, +1).concat(this._rawNotSpan(this._strandFilter(spans, -1), query, -1)); +}, + +// Converts a set of spans into its complement in the reference sequence. +_rawNotSpan: function( spans, query, strand ) { + var invSpan = []; + invSpan[0] = { start: query.start }; + var i = 0; + for (span in spans) { + if (spans.hasOwnProperty(span)) { + span = spans[span]; + invSpan[i].strand = strand; + invSpan[i].end = span.start; + i++; + invSpan[i] = { start: span.end }; + } + } + invSpan[i].strand = strand; + invSpan[i].end = query.end; + if (invSpan[i].end <= invSpan[i].start) { + invSpan.splice(i,1); + } + if (invSpan[0].end <= invSpan[0].start) { + invSpan.splice(0,1); + } + return invSpan; +}, + +loadRegion: function ( region ) { + var d = new Deferred(); + + if(this.stores.length == 1) { + d.resolve(this, true); + return d.promise; + } + var thisB = this; + var regionLoaded = region; + regionLoaded.spans = []; + + delete this.regionLoaded; + + this._getFeatures(region, function(){}, function(results){ + if(results && results.spans) { + regionLoaded.spans = results.spans; + thisB.regionLoaded = regionLoaded; + } + d.resolve(thisB, true); + }, function(){ + d.reject("cannot load region"); + }); + return d.promise; +} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/CombinationBase.js b/www/JBrowse/Store/SeqFeature/CombinationBase.js new file mode 100644 index 00000000..d0575397 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/CombinationBase.js @@ -0,0 +1,207 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/Deferred', + 'dojo/when', + 'dojo/promise/all', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin', + 'JBrowse/Util', + 'JBrowse/Model/BinaryTreeNode' + ], + function( + declare, + array, + Deferred, + when, + all, + SeqFeatureStore, + DeferredStatsMixin, + DeferredFeaturesMixin, + GlobalStatsEstimationMixin, + Util, + TreeNode + ) { +// Helper object that wraps a feature and which store it comes from +var featureWrapper = Util.fastDeclare( + { + get: function( arg ) { + return this.feature.get(arg); + }, + + id: function() { + return this.feature.id()+this.storeName; + }, + + parent: function() { + return this.feature.parent(); + }, + + children: function() { + return this.feature.children(); + }, + + tags: function() { + return this.feature.tags(); + }, + + constructor: function( feat, storeName ) { + this.feature = feat; + this.storeName = storeName; + this.source = feat ? feat.source : undefined; + } + } +); + +return declare([SeqFeatureStore, DeferredFeaturesMixin, DeferredStatsMixin, GlobalStatsEstimationMixin], { + +// The base class for combination stores. A combination store is one that pulls feature data from other stores +// and combines it according to a binary tree of operations in order to produce new features. + +constructor: function( args ) { + + // Objects can access this to know if a given store is a combination store of some kind + this.isCombinationStore = true; + + this.defaultOp = args.op; + + // If constructed with an opTree already included, might as well try to get all the store info from that opTree. + if(args.opTree) { + this.reload(args.opTree); + } + +}, + +// Loads an operation tree (opTree). + +reload: function( optree ) { + this._deferred.features = new Deferred(); + this._deferred.stats = new Deferred(); + var refSeq; + + // Load in opTree + if( !optree) { + optree = new TreeNode({ Value: this.defaultOp}); + } + this.opTree = optree; + this.stores = optree.getLeaves() || []; + + // If any of the stores doesn't have a name, then something weird is happening... + for(var store in this.stores) { + if(!this.stores[store].name) { + this.stores = []; + } + } + var thisB = this; + + this._deferred.features.resolve(true); + delete this._regionStatsCache; + this._estimateGlobalStats().then( dojo.hitch( + this, + function( stats ) { + this.globalStats = stats; + this._deferred.stats.resolve({success:true}); + } + ), + dojo.hitch( this, '_failAllDeferred' ) + ); +}, + +// Filters the featureArrays to return the list of features for the query, and then calls finish() to pass to the callback +_getFeatures: function( query, featCallback, doneCallback, errorCallback ) { + var thisB = this; + if(this.stores.length == 1) { + this.stores[0].getFeatures( query, featCallback, doneCallback, errorCallback); + return; + } + + if(this.regionLoaded) { + var spans = array.filter(this.regionLoaded.spans, function(span) { + return span.start <= query.end && span.end >= query.start; + }); + var features = this.createFeatures(spans); + this.finish(features, spans, featCallback, doneCallback); + return; + } + + // featureArrays will be a map from the names of the stores to an array of each store's features + var featureArrays = {}; + + // Generate map + var fetchAllFeatures = thisB.stores.map( + function (store) { + var d = new Deferred(); + if ( !featureArrays[store.name] ) { + featureArrays[store.name] = []; + store.getFeatures( + query, + dojo.hitch( this, function( feature ) { + var feat = new featureWrapper( feature, store.name ); + featureArrays[store.name].push( feat ); + }), + function(){d.resolve( featureArrays[store.name] ); }, + function(){d.reject("Error fetching features for store " + store.name);} + ); + } else { + d.resolve(featureArrays[store.name], true); + } + d.then(function(){}, errorCallback); // Makes sure that none of the rejected deferred promises keep propagating + return d.promise; + } + ); + + // Once we have all features, combine them according to the operation tree and create new features based on them. + when( all( fetchAllFeatures ), function() { + // Create a set of spans based on the evaluation of the operation tree + var spans = thisB.evalTree(featureArrays, thisB.opTree, query); + var features = thisB.createFeatures(spans); + thisB.finish(features, spans, featCallback, doneCallback); + }, errorCallback); +}, + +// Evaluate (recursively) an operation tree to create a list of spans (essentially pseudo-features) +evalTree: function(featureArrays, tree, query) { + if(!tree) { + return false; + } else if(tree.isLeaf()) { + return this.toSpan(featureArrays[tree.get().name], query); + } else if(!tree.hasLeft()) { + return this.toSpan(featureArrays[tree.right().get().name], query); + } else if(!tree.hasRight()) { + return this.toSpan(featureArrays[tree.left().get().name], query); + } + return this.opSpan( + tree.get(), + this.evalTree(featureArrays, tree.left(), query), + this.evalTree(featureArrays, tree.right(), query), + query + ); +}, + +// Passes the list of combined features to the getFeatures() callbacks +finish: function( features, spans, featCallback, doneCallback ) { + /* Pass features to the track's original featCallback, and pass spans to the doneCallback. + */ + for ( var key in features ) { + if ( features.hasOwnProperty(key) ) { + featCallback( features[key] ); + } + } + doneCallback( { spans: spans} ); +}, + +// These last four functions are stubbed out because each derived class should have its own implementation of them. + +// Converts a list of spans into a list of features. +createFeatures: function(spans) {}, + +// Transforms a set of features into a set of spans +toSpan: function(features, query) {}, + +// Defines the various operations that may occur and assigns each to a span-making function. +opSpan: function(op, span1, span2, query) {} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/Coverage.js b/www/JBrowse/Store/SeqFeature/Coverage.js new file mode 100644 index 00000000..89fa361a --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/Coverage.js @@ -0,0 +1,82 @@ +/** + * Store class that encapsulates another store, and synthesizes + * quantitative features that give the depth of coverage for the + * features in it. + */ + +define([ + 'dojo/_base/declare', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Util', + 'JBrowse/Model/CoverageFeature' + ], + function( declare, SeqFeatureStore, Util, CoverageFeature ) { + +return declare( SeqFeatureStore, { + + constructor: function( args ) { + this.store = args.store; + }, + + getGlobalStats: function( callback, errorCallback ) { + callback( {} ); + }, + + getFeatures: function( query, featureCallback, finishCallback, errorCallback ) { + var leftBase = query.start; + var rightBase = query.end; + var scale = query.scale || ( ('basesPerSpan' in query) ? 1/query.basesPerSpan : 10 ); // px/bp + var widthBp = rightBase-leftBase; + var widthPx = widthBp * scale; + + var binWidth = Math.ceil( 1/scale ); // in bp + + var coverageBins = new Array( Math.ceil( widthBp/binWidth ) ); + var binOverlap = function( bp, isRightEnd ) { + var binCoord = (bp-leftBase-1) / binWidth; + var binNumber = Math.floor( binCoord ); + var overlap = isRightEnd ? 1-(binCoord-binNumber) : binCoord - binNumber; + return { + bin: binNumber, + overlap: overlap // between 0 and 1: proportion of this bin that the feature overlaps + }; + }; + + this.store.getFeatures( + query, + function( feature ) { + var startBO = binOverlap( feature.get('start'), false ); + var endBO = binOverlap( feature.get('end'), true ); + + // increment start and end partial-overlap bins by proportion of overlap + if( startBO.bin == endBO.bin ) { + coverageBins[startBO.bin] = (coverageBins[startBO.bin] || 0) + endBO.overlap + startBO.overlap - 1; + } + else { + coverageBins[startBO.bin] = (coverageBins[startBO.bin] || 0) + startBO.overlap; + coverageBins[endBO.bin] = (coverageBins[endBO.bin] || 0) + endBO.overlap; + } + + // increment completely overlapped interior bins by 1 + for( var i = startBO.bin+1; i <= endBO.bin-1; i++ ) { + coverageBins[i] = (coverageBins[i] || 0) + 1; + } + }, + function () { + // make fake features from the coverage + for( var i = 0; i < coverageBins.length; i++ ) { + var score = (coverageBins[i] || 0); + var bpOffset = leftBase+binWidth*i; + featureCallback( new CoverageFeature({ + start: bpOffset, + end: bpOffset+binWidth, + score: score + })); + } + finishCallback(); + }, + errorCallback + ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/FromConfig.js b/www/JBrowse/Store/SeqFeature/FromConfig.js new file mode 100644 index 00000000..ac1213c3 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/FromConfig.js @@ -0,0 +1,75 @@ +/** + * Store that shows features defined in its `features` configuration + * key, like: + * "features": [ { "seq_id": "ctgA", "start":1, "end":20 }, + * ... + * ] + */ + +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Model/SimpleFeature' + ], + function( + declare, + array, + SeqFeatureStore, + SimpleFeature + ) { + +return declare( SeqFeatureStore, +{ + constructor: function( args ) { + this.features = this._makeFeatures( this.config.features || [] ); + }, + + _makeFeatures: function( fdata ) { + var features = {}; + for( var i=0; i end ) ) { + featCallback( f ); + } + } + endCallback(); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/GFF3.js b/www/JBrowse/Store/SeqFeature/GFF3.js new file mode 100644 index 00000000..1db63dad --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/GFF3.js @@ -0,0 +1,202 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'dojo/Deferred', + 'JBrowse/Model/SimpleFeature', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin', + './GFF3/Parser' + ], + function( + declare, + lang, + array, + Deferred, + SimpleFeature, + SeqFeatureStore, + DeferredFeatures, + DeferredStats, + GlobalStatsEstimationMixin, + Parser + ) { + +return declare([ SeqFeatureStore, DeferredFeatures, DeferredStats, GlobalStatsEstimationMixin ], + + /** + * @lends JBrowse.Store.SeqFeature.GFF3 + */ +{ + constructor: function( args ) { + this.data = args.blob; + this.features = []; + this._loadFeatures(); + }, + + _loadFeatures: function() { + var thisB = this; + var features = this.bareFeatures = []; + + var featuresSorted = true; + var seenRefs = this.refSeqs = {}; + var parser = new Parser( + { + featureCallback: function(fs) { + array.forEach( fs, function( feature ) { + var prevFeature = features[ features.length-1 ]; + var regRefName = thisB.browser.regularizeReferenceName( feature.seq_id ); + if( regRefName in seenRefs && prevFeature && prevFeature.seq_id != feature.seq_id ) + featuresSorted = false; + if( prevFeature && prevFeature.seq_id == feature.seq_id && feature.start < prevFeature.start ) + featuresSorted = false; + + if( !( regRefName in seenRefs )) + seenRefs[ regRefName ] = features.length; + + feature.strand = {'+':1,'-':-1}[feature.strand] || 0; + features.push( feature ); + }); + }, + endCallback: function() { + if( ! featuresSorted ) { + features.sort( thisB._compareFeatureData ); + // need to rebuild the refseq index if changing the sort order + thisB._rebuildRefSeqs( features ); + } + + thisB._estimateGlobalStats() + .then( function( stats ) { + thisB.globalStats = stats; + thisB._deferred.stats.resolve(); + }); + + thisB._deferred.features.resolve( features ); + } + }); + + // parse the whole file and store it + this.data.fetchLines( + function( line ) { + parser.addLine(line); + }, + lang.hitch( parser, 'finish' ), + lang.hitch( this, '_failAllDeferred' ) + ); + }, + + _rebuildRefSeqs: function( features ) { + var refs = {}; + for( var i = 0; i= 0 )) { + finishCallback(); + return; + } + + var checkEnd = 'start' in query + ? function(f) { return f.get('end') >= query.start; } + : function() { return true; }; + + for( ; i query.end ) + break; + + if( checkEnd( f ) ) { + featureCallback( f ); + } + } + + finishCallback(); + }, + + _formatFeature: function( data ) { + var f = new SimpleFeature({ + data: this._featureData( data ), + id: (data.attributes.ID||[])[0] + }); + f._reg_seq_id = this.browser.regularizeReferenceName( data.seq_id ); + return f; + }, + + // flatten array like [ [1,2], [3,4] ] to [ 1,2,3,4 ] + _flattenOneLevel: function( ar ) { + var r = []; + for( var i = 0; i maximum_recursion_level ){ + // return false; + // } + + // recurse if there there are children + if ( featureArrayToSearch[j]["children"].length > 0 ){ + if ( placeChildrenWithParent(thisLine, featureArrayToSearch[j]["children"] )){ + foundParents++; + } + } + } + if ( foundParents > 0 ){ + return true; + } + else { + return false; + } + } + + // put feature in ["data"] for discontinuous features we've already "filed" + var df_recursion_level = 0; + var df_maximum_df_recursion_level = 200; + var placeDiscontiguousFeature = function(thisLine, featureArrayToSearch) { + df_recursion_level++; + var thisId = thisLine["data"][0]["attributes"]["ID"][0]; + // first, search each item in featureArrayToSearch + for ( var j = 0; j < featureArrayToSearch.length; j++ ){ + if ( thisId == featureArrayToSearch[j]["ID"] ){ + featureArrayToSearch[j]["data"] = featureArrayToSearch[j]["data"].concat( thisLine["data"] ); + featureArrayToSearch[j]["children"] = featureArrayToSearch[j]["children"].concat( thisLine["children"] ); + return true; + } + // paranoid about infinite recursion + if ( df_recursion_level > df_maximum_df_recursion_level ){ + return false; + } + // recurse if there there are children + if ( featureArrayToSearch[j]["children"].length > 0 ){ + if ( placeDiscontiguousFeature(thisLine, featureArrayToSearch[j]["children"] )){ + return true; + } + } + } + return false; + } + + var bigDataStruct = { + "parsedData" : [], + "parseErrors": [], + "parseWarnings": [] + }; // parsed GFF3 in JSON format, to be returned + + var lines = gff3String.match(/^.*((\r\n|\n|\r)|$)/gm); // this is wasteful, maybe try to avoid storing split lines separately later + var hasParent = {}; // child (or grandchild, or whatever) features + var noParent = {}; // toplevel features without parents + + var seenIDs = {}; + var noParentIDs = []; + var hasParentIDs = []; + + for (var i = 0; i < lines.length; i++) { + + // check for ##FASTA pragma + if( lines[i].match(/^##FASTA/) || lines[i].match(/^>/) ){ + break; + } + // skip comment lines + if( lines[i].match(/^#/) ){ + continue; + } + // skip comment lines + if( lines[i].match(/^\s*$/) ){ + continue; + } + // make sure lines[i] has stuff in it + if(typeof(lines[i]) == 'undefined' || lines[i] == null) { + continue; + } + lines[i] = lines[i].replace(/(\n|\r)+$/, ''); // chomp + var fields = lines[i].split("\t"); + // check that we have enough fields + if(fields.length < 9 ){ + console.log("Number of fields < 9! Skipping this line:\n\t" + lines[i] + "\n"); + bigDataStruct["parseWarnings"].push( "Number of fields < 9! Skipping this line:\n\t" + lines[i] + "\n" ); + continue; + } + else { + if (fields.length > 9 ){ + console.log("Number of fields > 9!\n\t" + lines[i] + "\nI'll try to parse this line anyway."); + bigDataStruct["parseWarnings"].push( "Number of fields > 9!\n\t" + lines[i] + "\nI'll try to parse this line anyway." ); + } + } + + // parse ninth field into key/value pairs + var attributesKeyVal = new Object; + if(typeof(fields[8]) != undefined && fields[8] != null) { + var ninthFieldSplit = fields[8].split(/;/); + for ( var j = 0; j < ninthFieldSplit.length; j++){ + /* + Multiple attributes of the same type are indicated by separating the + values with the comma "," character, as in: + Parent=AF2312,AB2812,abc-3 + */ + var theseKeyVals = ninthFieldSplit[j].split(/\=/); + if ( theseKeyVals.length >= 2 ){ + var key = unescape(theseKeyVals[0]); + var valArray = new Array; + + // see if we have multiple values + if ( theseKeyVals[1].match(/\,/) ){ // multiple values + if ( !! theseKeyVals[1] && theseKeyVals.length != undefined ){ + // value can be >1 thing separated by comma, for example for multiple parents + valArray = theseKeyVals[1].split(/\,/); + if ( !! valArray && valArray.length != undefined ){ + for ( k = 0; k < valArray.length; k++){ + valArray[k] = unescape(valArray[k]); + } + + } + valArray[0] = unescape(valArray[0]); + valArray[1] = unescape(valArray[1]); + } + } + else { // just one value + valArray[0] = unescape(theseKeyVals[1]); + } + attributesKeyVal[key] = valArray; + } + } + } + + if ( ! attributesKeyVal["ID"] ){ + attributesKeyVal["ID"] = []; + attributesKeyVal["ID"][0] = "parser_autogen_id_" + i; + } + + var thisLine = {"ID": attributesKeyVal["ID"][0], + "data": [ + {"rawdata" : fields, + "attributes" : attributesKeyVal + } + ], + "children": [] + }; + + if ( attributesKeyVal["Parent"] != undefined ){ + hasParent[attributesKeyVal["ID"][0]] = thisLine; + hasParentIDs.push( attributesKeyVal["ID"][0] ); + } + else { + noParent[attributesKeyVal["ID"][0]] = thisLine; + noParentIDs.push( attributesKeyVal["ID"][0] ); + } + + // keep track of what IDs we've seen + if ( isNaN(seenIDs[attributesKeyVal["ID"][0]]) ){ + seenIDs[attributesKeyVal["ID"][0]] = 1; + } + else { + seenIDs[attributesKeyVal["ID"][0]]++; + } + } + + var dealtWithIDs = {}; // list of IDs that have been processed + + // put things with no parent in parsedData straight away + for (var j = 0; j < noParentIDs.length; j++) { + var thisID = noParentIDs[j]; + var thisLine = noParent[thisID]; + + // is this a discontiguous feature that's already been "filed"? + if ( seenIDs[thisID] > 1 && dealtWithIDs[thisID] > 0 ){ // yes + placeDiscontiguousFeature(thisLine, bigDataStruct["parsedData"] ); + } + else { + bigDataStruct["parsedData"].push( thisLine ); + } + + // keep track of what IDs we've dealt with + if ( isNaN( dealtWithIDs[thisID] ) ){ + dealtWithIDs[thisID] = 1 + } + else { + dealtWithIDs[thisID]++; + } + + } + + // now put children (and grandchildren, and so on) in data struct + for (var k = 0; k < hasParentIDs.length; k++) { + var thisID = hasParentIDs[k]; + var thisLine = hasParent[thisID]; + + // is this a discontiguous feature that's already been "filed"? + if ( seenIDs[thisID] > 1 && dealtWithIDs[thisID] > 0 ){ // yes + placeDiscontiguousFeature(thisLine, bigDataStruct["parsedData"] ); + } + else { + // put this child in the right children array, recursively, or put it on top level and mark it as an orphan + if ( ! placeChildrenWithParent(thisLine, bigDataStruct["parsedData"] ) ){ + bigDataStruct["parsedData"].push( thisLine ); + bigDataStruct["parseWarnings"].push( thisID + " seems to be an orphan" ); + } + } + + // keep track of what IDs we've dealt with + if ( isNaN( dealtWithIDs[thisID] ) ){ + dealtWithIDs[thisID] = 1 + } + else { + dealtWithIDs[thisID]++; + } + + } + return bigDataStruct; +}; + +return GFF3Parser; + +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/GFF3/Parser.js b/www/JBrowse/Store/SeqFeature/GFF3/Parser.js new file mode 100644 index 00000000..fb49dafc --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/GFF3/Parser.js @@ -0,0 +1,214 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'dojo/json', + 'JBrowse/Util/GFF3' + ], + function( + declare, + array, + lang, + JSON, + GFF3 + ) { + +return declare( null, { + + constructor: function( args ) { + lang.mixin( this, { + featureCallback: args.featureCallback || function() {}, + endCallback: args.endCallback || function() {}, + commentCallback: args.commentCallback || function() {}, + errorCallback: args.errorCallback || function(e) { console.error(e); }, + directiveCallback: args.directiveCallback || function() {}, + + // features that we have to keep on hand for now because they + // might be referenced by something else + under_construction_top_level : [], + // index of the above by ID + under_construction_by_id : {}, + + completed_references: {}, + + // features that reference something we have not seen yet + // structured as: + // { 'some_id' : { + // 'Parent' : [ orphans that have a Parent attr referencing it ], + // 'Derives_from' : [ orphans that have a Derives_from attr referencing it ], + // } + under_construction_orphans : {}, + + // if this is true, the parser ignores the + // rest of the lines in the file. currently + // set when the file switches over to FASTA + eof: false + }); + }, + + addLine: function( line ) { + var match; + if( this.eof ) { + // do nothing + } else if( /^\s*[^#\s>]/.test(line) ) { //< feature line, most common case + var f = GFF3.parse_feature( line ); + this._buffer_feature( f ); + } + // directive or comment + else if(( match = /^\s*(\#+)(.*)/.exec( line ) )) { + var hashsigns = match[1], contents = match[2]; + if( hashsigns.length == 3 ) { //< sync directive, all forward-references are resolved. + this._return_all_under_construction_features(); + } + else if( hashsigns.length == 2 ) { + var directive = GFF3.parse_directive( line ); + if( directive.directive == 'FASTA' ) { + this._return_all_under_construction_features(); + this.eof = true; + } else { + this._return_item( directive ); + } + } + else { + contents = contents.replace(/\s*/,''); + this._return_item({ comment: contents }); + } + } + else if( /^\s*$/.test( line ) ) { + // blank line, do nothing + } + else if( /^\s*>/.test(line) ) { + // implicit beginning of a FASTA section. just stop + // parsing, since we don't currently handle sequences + this._return_all_under_construction_features(); + this.eof = true; + } + else { // it's a parse error + line = line.replace( /\r?\n?$/g, '' ); + throw "GFF3 parse error. Cannot parse '"+line+"'."; + } + }, + + _return_item: function(i) { + if( i[0] ) + this.featureCallback( i ); + else if( i.directive ) + this.directiveCallback( i ); + else if( i.comment ) + this.commentCallback( i ); + }, + + finish: function() { + this._return_all_under_construction_features(); + this.endCallback(); + }, + + /** + * return all under-construction features, called when we know + * there will be no additional data to attach to them + */ + _return_all_under_construction_features: function() { + // since the under_construction_top_level buffer is likely to be + // much larger than the item_buffer, we swap them and unshift the + // existing buffer onto it to avoid a big copy. + array.forEach( this.under_construction_top_level, + this._return_item, + this ); + + this.under_construction_top_level = []; + this.under_construction_by_id = {}; + this.completed_references = {}; + + // if we have any orphans hanging around still, this is a + // problem. die with a parse error + for( var o in this.under_construction_orphans ) { + for( var orphan in o ) { + throw "parse error: orphans "+JSON.stringify( this.under_construction_orphans ); + } + } + }, + + container_attributes: { Parent : 'child_features', Derives_from : 'derived_features' }, + + // do the right thing with a newly-parsed feature line + _buffer_feature: function( feature_line ) { + feature_line.child_features = []; + feature_line.derived_features = []; + + // NOTE: a feature is an arrayref of one or more feature lines. + var ids = feature_line.attributes.ID || []; + var parents = feature_line.attributes.Parent || []; + var derives = feature_line.attributes.Derives_from || []; + + if( !ids.length && !parents.length && !derives.length ) { + // if it has no IDs and does not refer to anything, we can just + // output it + this._return_item([ feature_line ]); + return; + } + + var feature; + array.forEach( ids, function( id ) { + var existing; + if(( existing = this.under_construction_by_id[id] )) { + // another location of the same feature + existing.push( feature_line ); + feature = existing; + } + else { + // haven't seen it yet + feature = [ feature_line ]; + if( ! parents.length && ! derives.length ) { + this.under_construction_top_level.push( feature ); + } + this.under_construction_by_id[id] = feature; + + // see if we have anything buffered that refers to it + this._resolve_references_to( feature, id ); + } + },this); + + // try to resolve all its references + this._resolve_references_from( feature || [ feature_line ], { Parent : parents, Derives_from : derives }, ids ); + }, + + _resolve_references_to: function( feature, id ) { + var references = this.under_construction_orphans[id]; + if( ! references ) + return; + + for( var attrname in references ) { + var pname = container_attributes[attrname] || attrname.toLowerCase(); + array.forEach( feature, function( loc ) { + loc[pname].push( references[attrname] ); + delete references[attrname]; + }); + } + }, + _resolve_references_from: function( feature, references, ids ) { + // go through our references + // if we have the feature under construction, put this feature in the right place + // otherwise, put this feature in the right slot in the orphans + + var pname; + for( var attrname in references ) { + array.forEach( references[attrname], function( to_id ) { + var other_feature; + if(( other_feature = this.under_construction_by_id[ to_id ] )) { + if( ! pname ) + pname = this.container_attributes[attrname] || attrname.toLowerCase(); + if( ! array.some( ids, function(i) { return this.completed_references[i+','+attrname+','+to_id]++; },this) ) { + array.forEach( other_feature, function( loc ) { + loc[pname].push( feature ); + }); + } + } + else { + ( this.under_construction_orphans[to_id][attrname] = this.under_construction_orphans[to_id][attrname] || [] ) + .push( feature ); + } + },this); + } + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin.js b/www/JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin.js new file mode 100644 index 00000000..137e3ad6 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin.js @@ -0,0 +1,68 @@ +/** + * Mixin that adds _estimateGlobalStats method to a store, which + * samples a section of the features in the store and uses those to + * esimate the statistics of the whole data set. + */ + +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/Deferred' + ], + function( declare, array, Deferred ) { + +return declare( null, { + + /** + * Fetch a region of the current reference sequence and use it to + * estimate the feature density of the store. + * @private + */ + _estimateGlobalStats: function( refseq ) { + var deferred = new Deferred(); + + refseq = refseq || this.refSeq; + + var statsFromInterval = function( length, callback ) { + var thisB = this; + var sampleCenter = refseq.start*0.75 + refseq.end*0.25; + var start = Math.max( 0, Math.round( sampleCenter - length/2 ) ); + var end = Math.min( Math.round( sampleCenter + length/2 ), refseq.end ); + var features = []; + this._getFeatures({ ref: refseq.name, start: start, end: end}, + function( f ) { features.push(f); }, + function( error ) { + features = array.filter( features, function(f) { return f.get('start') >= start && f.get('end') <= end; } ); + callback.call( thisB, length, + { + featureDensity: features.length / length, + _statsSampleFeatures: features.length, + _statsSampleInterval: { ref: refseq.name, start: start, end: end, length: length } + }); + }, + function( error ) { + console.error( error ); + callback.call( thisB, length, null, error ); + }); + }; + + var maybeRecordStats = function( interval, stats, error ) { + if( error ) { + deferred.reject( error ); + } else { + var refLen = refseq.end - refseq.start; + if( stats._statsSampleFeatures >= 300 || interval * 2 > refLen || error ) { + console.log( 'Store statistics: '+(this.source||this.name), stats ); + deferred.resolve( stats ); + } else { + statsFromInterval.call( this, interval * 2, maybeRecordStats ); + } + } + }; + + statsFromInterval.call( this, 100, maybeRecordStats ); + return deferred; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/Mask.js b/www/JBrowse/Store/SeqFeature/Mask.js new file mode 100644 index 00000000..e017682b --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/Mask.js @@ -0,0 +1,226 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/Deferred', + 'dojo/when', + 'dojo/promise/all', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Model/SimpleFeature', + 'JBrowse/Model/BinaryTreeNode', + 'JBrowse/Util' + ], + function( + declare, + array, + Deferred, + when, + all, + SeqFeatureStore, + SimpleFeature, + TreeNode, + Util + ) { + +return declare([SeqFeatureStore], { + +// A store that takes in two feature stores (one of them set-based e.g. NCList) and uses the data from one store as a mask +// for the other. Although the design resembles those of combinationStores, differences are substantial enough that +// this class does not derive from CombinationBase. + +constructor: function( args ) { + this.isCombinationStore = true; + this.inverse = args.inverse || false; + this.stores = {}; + + if(args.mask && args.display) { + this.reload(args.mask, args.display); + } +}, + +// Loads an opTree (optionally), and a mask and display store. Ensure all stores exist, +// and build an operation tree for the benefit of combination tracks. +reload: function(opTree, mask, display) { + var inverse; + + this.gotAllStores = new Deferred(); + if(opTree) { + this.opTree = opTree; + this.inverse = (inverse === undefined) ? (opTree.get() == "N") : inverse; + this.stores.mask = opTree.leftChild && !mask ? opTree.leftChild.get() : mask; + this.stores.display = opTree.rightChild && !display ? opTree.rightChild.get() : display; + this.gotAllStores.resolve(true); + } + else { + if(inverse !== undefined) { + this.inverse = inverse; + } + this.opTree = new TreeNode({Value: this.inverse ? "N" : "M"}); + this.stores.mask = mask; + this.stores.display = display; + var thisB = this; + + var grabStore = function(store) { + var haveStore = new Deferred(); + if(typeof store == "string") { + thisB.browser.getStore(store, function(result) { + if(result) { + haveStore.resolve(result, true); + } else { + haveStore.reject("store " + store + " not found"); + } + }); + } else { + haveStore.resolve(store, true); + } + return haveStore.promise; + } + + var haveMaskStore = grabStore(this.stores.mask).then( function(store) { + thisB.stores.mask = store; + }); + var haveDisplayStore = grabStore(this.stores.display).then( function(store) { + thisB.stores.display = store; + }); + this.gotAllStores = all([haveMaskStore, haveDisplayStore]); + this.gotAllStores.then( function() { + thisB.opTree.leftChild = thisB.stores.mask.isCombinationStore ? thisB.stores.mask.opTree : new TreeNode({Value: thisB.stores.mask}); + thisB.opTree.rightChild = thisB.stores.display.isCombinationStore ? thisB.stores.display.opTree : new TreeNode({Value: thisB.stores.display}); + }); + } +}, + +// The global stats of this store should be the same as those for the display data. +getGlobalStats: function (callback, errorCallback) { + this.stores.display.getGlobalStats( callback, errorCallback ); +}, + +// The regional stats of this store should be the same as those for the display data. +getRegionStats: function (query, callback, errorCallback) { + this.stores.display.getRegionStats( query, callback, errorCallback ); +}, + +// Gets the features from the mask and display stores, and then returns the display store features with the mask store features +// added as masks +getFeatures: function( query, featCallback, doneCallback, errorCallback ) { + var thisB = this; + + this.gotAllStores.then( + function() { + var featureArray = {}; + + // Get features from one particular store + var grabFeats = function( key ) { + var d = new Deferred( ); + featureArray[key] = []; + + thisB.stores[key].getFeatures( query, + function(feature) { + featureArray[key].push( feature ); + }, + function() { d.resolve( true ); }, + function() { d.reject( "failed to load features for " + key + " store" ); } + ); + return d.promise; + } + + when(all([grabFeats( "mask" ), grabFeats( "display" )]), + function() { + // Convert mask features into simplified spans + var spans = thisB.toSpans( featureArray.mask, query ); + // invert masking spans if necessary + spans = thisB.inverse ? thisB.notSpan( spans, query ) : spans; + var features = featureArray.display; + + thisB.maskFeatures( features, spans, featCallback, doneCallback ); + }, errorCallback + ); + }, errorCallback); +}, + + + // given a feature or pseudo-feature, returns true if the feature + // overlaps the span. False otherwise. +inSpan: function( feature, span ) { + if ( !feature || !span ) { + console.error("invalid arguments to inSpan function"); + } + return feature.get ? !( feature.get('start') >= span.end || feature.get('end') <= span.start ) : + !( feature.start >= span.end || feature.end <= span.start ); + +}, + +maskFeatures: function( features, spans, featCallback, doneCallback ) { + /* Pass features to the track's original featCallback, and pass spans to the doneCallback. + * If the track has boolean support, the DoneCallback will use the spans to mask the features. + * For glyph based tracks, the masks passed to each feature will be used to do masking. + */ + for ( var key in features ) { + if ( features.hasOwnProperty( key ) ) { + var feat = features[key]; + delete feat.masks; + for (var span in spans ) { + if ( spans.hasOwnProperty( span ) && this.inSpan( feat, spans[span] ) ) { + // add masks to the feature. Used by Glyphs to do masking. + feat.masks = feat.masks ? feat.masks.concat( [spans[span]] ) : [spans[span]]; + } + } + featCallback( features[key] ) + } + } + doneCallback( { maskingSpans: spans} ); +}, + +notSpan: function( spans, query ) { + // creates the complement spans of the input spans + var invSpan = []; + invSpan[0] = { start: query.start }; + var i = 0; + for (span in spans) { + if ( spans.hasOwnProperty( span ) ) { + span = spans[span]; + invSpan[i].end = span.start; + i++; + invSpan[i] = { start: span.end }; + } + } + invSpan[i].end = query.end; + if (invSpan[i].end <= invSpan[i].start) { + invSpan.splice(i,1); + } + if (invSpan[0].end <= invSpan[0].start) { + invSpan.splice(0,1); + } + return invSpan; +}, + +toSpans: function(features, query) { + // given a set of features, takes the "union" of them and outputs a single set of nonoverlapping spans + var spans = []; + for (var feature in features) { + if (features.hasOwnProperty(feature)) { + spans.push( { start: features[feature].get('start'), //Math.max( features[feature].get('start'), query.start ), + end: features[feature].get('end') //Math.min( features[feature].get('end'), query.end ) + } ); + } + } + + if(!spans.length) return []; + spans.sort(function(a,b) { return a.start - b.start; }); + + var retSpans = []; + var i = 0; + while(i < spans.length) { + var start = spans[i].start; + var end = spans[i].end; + while(i < spans.length && spans[i].start <= end) { + end = Math.max(end, spans[i].end); + i++; + } + retSpans.push( { start: start, end: end}); + } + return retSpans; + +} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/NCList.js b/www/JBrowse/Store/SeqFeature/NCList.js new file mode 100644 index 00000000..a700d73c --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/NCList.js @@ -0,0 +1,261 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/Deferred', + 'dojo/request/xhr', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Util', + 'JBrowse/Model/ArrayRepr', + 'JBrowse/Store/NCList', + 'JBrowse/Store/LazyArray' + ], + function( + declare, + lang, + Deferred, + xhr, + SeqFeatureStore, + DeferredFeaturesMixin, + DeferredStatsMixin, + Util, + ArrayRepr, + GenericNCList, + LazyArray + ) { + +/** + * Implementation of SeqFeatureStore using nested containment + * lists held in static files that are lazily fetched from the web + * server. + * + * @class JBrowse.Store.SeqFeature.NCList + * @extends SeqFeatureStore + */ + +var idfunc = function() { return this._uniqueID; }; +var parentfunc = function() { return this._parent; }; +var childrenfunc = function() { return this.get('subfeatures'); }; + +return declare( SeqFeatureStore, +{ + constructor: function(args) { + this.args = args; + + this.baseUrl = args.baseUrl; + this.urlTemplates = { root: args.urlTemplate }; + + this._deferred = {}; + }, + + makeNCList: function() { + return new GenericNCList(); + }, + + loadNCList: function( refData, trackInfo, url ) { + refData.nclist.importExisting( trackInfo.intervals.nclist, + refData.attrs, + url, + trackInfo.intervals.urlTemplate, + trackInfo.intervals.lazyClass + ); + }, + + getDataRoot: function( refName ) { + if( ! this._deferred.root || this.curRefName != refName ) { + var d = this._deferred.root = new Deferred(); + this.curRefName = refName; + + var refData = { + nclist: this.makeNCList() + }; + + var url = this.resolveUrl( + this.urlTemplates.root, + { refseq: refName } + ); + + // fetch the trackdata + var thisB = this; + xhr.get( url, { handleAs: 'json', failOk: true }) + .then( function( trackInfo, request ) { + //trackInfo = JSON.parse( trackInfo ); + thisB._handleTrackInfo( refData, trackInfo, url ); + }, + function(error) { + if( error.response.status == 404 ) { + thisB._handleTrackInfo( refData, {}, url ); + } else if( error.response.status != 200) { + thisB._failAllDeferred( "Server returned an HTTP " + error.response.status + " error" ); + } + else + thisB._failAllDeferred( error ); + } + ); + } + return this._deferred.root; + }, + + _handleTrackInfo: function( refData, trackInfo, url ) { + refData.stats = { + featureCount: trackInfo.featureCount || 0, + featureDensity: ( trackInfo.featureCount || 0 ) / this.refSeq.length + }; + + this.empty = !trackInfo.featureCount; + + if( trackInfo.intervals ) { + refData.attrs = new ArrayRepr( trackInfo.intervals.classes ); + this.loadNCList( refData, trackInfo, url ); + } + + var histograms = trackInfo.histograms; + if( histograms && histograms.meta ) { + for (var i = 0; i < histograms.meta.length; i++) { + histograms.meta[i].lazyArray = + new LazyArray( histograms.meta[i].arrayParams, url ); + } + refData._histograms = histograms; + } + + this._deferred.root.resolve( refData ); + }, + + getGlobalStats: function( successCallback, errorCallback ) { + return ( this._deferred.root || this.getDataRoot( this.browser.refSeq.name ) ) + .then( function( data ) { successCallback( data.stats ); }, + errorCallback + ); + }, + + getRegionStats: function( query, successCallback, errorCallback ) { + this.getDataRoot( query.ref ) + .then( function( data ) { successCallback( data.stats ); }, + errorCallback + ); + }, + + getRegionFeatureDensities: function( query, successCallback, errorCallback ) { + this.getDataRoot( query.ref ) + .then( function( data ) { + var numBins = query.numBins || 25; + if( ! query.bpPerBin ) + throw 'bpPerBin arg required for getRegionFeatureDensities'; + + // The histogramMeta array describes multiple levels of histogram detail, + // going from the finest (smallest number of bases per bin) to the + // coarsest (largest number of bases per bin). + // We want to use coarsest histogramMeta that's at least as fine as the + // one we're currently rendering. + // TODO: take into account that the histogramMeta chosen here might not + // fit neatly into the current histogram (e.g., if the current histogram + // is at 50,000 bases/bin, and we have server histograms at 20,000 + // and 2,000 bases/bin, then we should choose the 2,000 histogramMeta + // rather than the 20,000) + var histogramMeta = data._histograms.meta[0]; + for (var i = 0; i < data._histograms.meta.length; i++) { + if( query.bpPerBin >= data._histograms.meta[i].basesPerBin ) + histogramMeta = data._histograms.meta[i]; + } + + // number of bins in the server-supplied histogram for each current bin + var binCount = query.bpPerBin / histogramMeta.basesPerBin; + + // if the server-supplied histogram fits neatly into our requested + if ( binCount > .9 + && + Math.abs(binCount - Math.round(binCount)) < .0001 + ) { + //console.log('server-supplied',query); + // we can use the server-supplied counts + var firstServerBin = Math.floor( query.start / histogramMeta.basesPerBin); + binCount = Math.round(binCount); + var histogram = []; + for (var bin = 0; bin < numBins; bin++) + histogram[bin] = 0; + + histogramMeta.lazyArray.range( + firstServerBin, + firstServerBin + binCount*numBins, + function(i, val) { + // this will count features that span the boundaries of + // the original histogram multiple times, so it's not + // perfectly quantitative. Hopefully it's still useful, though. + histogram[ Math.floor( (i - firstServerBin) / binCount ) ] += val; + }, + function() { + successCallback({ bins: histogram, stats: data._histograms.stats }); + } + ); + } else { + //console.log('make own',query); + // make our own counts + data.nclist.histogram.call( + data.nclist, + query.start, + query.end, + numBins, + function( hist ) { + successCallback({ bins: hist, stats: data._histograms.stats }); + }); + } + }, errorCallback ); + }, + + getFeatures: function( query, origFeatCallback, finishCallback, errorCallback ) { + if( this.empty ) { + finishCallback(); + return; + } + + var thisB = this; + this.getDataRoot( query.ref ) + .then( function( data ) { + thisB._getFeatures( data, query, origFeatCallback, finishCallback, errorCallback ); + }, errorCallback); + }, + + _getFeatures: function( data, query, origFeatCallback, finishCallback, errorCallback ) { + var thisB = this; + var startBase = query.start; + var endBase = query.end; + var accessors = data.attrs.accessors(), + /** @inner */ + featCallBack = function( feature, path ) { + // the unique ID is a stringification of the path in the + // NCList where the feature lives; it's unique across the + // top-level NCList (the top-level NCList covers a + // track/chromosome combination) + + // only need to decorate a feature once + if (! feature.decorated) { + var uniqueID = path.join(","); + thisB._decorate_feature( accessors, feature, uniqueID ); + } + return origFeatCallback( feature ); + }; + + data.nclist.iterate.call( data.nclist, startBase, endBase, featCallBack, finishCallback, errorCallback ); + }, + + // helper method to recursively add .get and .tags methods to a feature and its + // subfeatures + _decorate_feature: function( accessors, feature, id, parent ) { + feature.get = accessors.get; + // possibly include set method in decorations? not currently + // feature.set = accessors.set; + feature.tags = accessors.tags; + feature._uniqueID = id; + feature.id = idfunc; + feature._parent = parent; + feature.parent = parentfunc; + feature.children = childrenfunc; + dojo.forEach( feature.get('subfeatures'), function(f,i) { + this._decorate_feature( accessors, f, id+'-'+i, feature ); + },this); + feature.decorated = true; + } +}); +}); + diff --git a/www/JBrowse/Store/SeqFeature/NCList_v0.js b/www/JBrowse/Store/SeqFeature/NCList_v0.js new file mode 100644 index 00000000..12aea058 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/NCList_v0.js @@ -0,0 +1,166 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/Deferred', + 'JBrowse/Store/SeqFeature/NCList', + 'JBrowse/Store/NCList_v0', + 'JBrowse/Store/LazyArray' + ], function( declare, Deferred, SFNCList, GenericNCList, LazyArray ) { +return declare( SFNCList, + +/** + * @lends JBrowse.Store.SeqFeature.NCList_v0 + */ +{ + + /** + * Feature storage backend for backward-compatibility with JBrowse 1.2.1 stores. + * @extends SeqFeatureStore.NCList + * @constructs + */ + constructor: function(args) { + this.fields = {}; + this.track = new Deferred(); + if( args.track ) + this.track.resolve( args.track ); + }, + + setTrack: function(t) { + this.track.resolve( t ); + }, + + /** + * Delete an object member and return the deleted value. + * @private + */ + _del: function( obj, old ) { + var x = obj[old]; + delete obj[old]; + return x; + }, + + _handleTrackInfo: function( refData, trackInfo, url ) { + + if( trackInfo ) { + + // munge the trackInfo to make the histogram stuff work with v1 code + dojo.forEach( trackInfo.histogramMeta, function(m) { + m.arrayParams.urlTemplate = m.arrayParams.urlTemplate.replace(/\{chunk\}/,'{Chunk}'); + }); + trackInfo.histograms = { + meta: this._del( trackInfo, 'histogramMeta' ), + stats: this._del( trackInfo, 'histStats' ) + }; + // rename stats.bases to stats.basesPerBin + dojo.forEach( trackInfo.histograms.stats, function(s) { + s.basesPerBin = this._del( s, 'bases' ); + },this); + + // since the old format had style information inside the + // trackdata file, yuckily push it up to the track's config.style + var renameVar = { + urlTemplate: "linkTemplate" + }; + + this.track.then( function( track ) { + dojo.forEach( + ['className','arrowheadClass','subfeatureClasses','urlTemplate','clientConfig'], + function(varname) { + if( !track.config.style ) track.config.style = {}; + var dest_varname = renameVar[varname] || varname; + if( varname in trackInfo ) + track.config.style[dest_varname] = trackInfo[varname]; + },this); + + // also need to merge Ye Olde clientConfig values into the style object + if( track.config.style.clientConfig ) { + track.config.style = dojo.mixin( track.config.style, track.config.style.clientConfig ); + delete track.config.style.clientConfig; + } + }); + + // remember the field offsets from the old-style trackinfo headers + refData.fields = {}; + refData.fieldOrder = []; + var i; + for (i = 0; i < trackInfo.headers.length; i++) { + refData.fieldOrder.push( trackInfo.headers[i] ); + refData.fields[trackInfo.headers[i]] = i; + } + refData.subFields = {}; + refData.subFieldOrder = []; + if (trackInfo.subfeatureHeaders) { + for (i = 0; i < trackInfo.subfeatureHeaders.length; i++) { + refData.subFieldOrder.push( trackInfo.subfeatureHeaders[i] ); + refData.subFields[trackInfo.subfeatureHeaders[i]] = i; + } + } + + refData.stats = { + featureCount: trackInfo.featureCount, + featureDensity: trackInfo.featureCount / this.refSeq.length + }; + + this.loadNCList( refData, trackInfo, url ); + + var histograms = trackInfo.histograms; + if( histograms && histograms.meta ) { + for (var i = 0; i < histograms.meta.length; i++) { + histograms.meta[i].lazyArray = + new LazyArray( histograms.meta[i].arrayParams, url ); + } + refData._histograms = histograms; + } + + this._deferred.root.resolve( refData ); + } + }, + + makeNCList: function() { + return new GenericNCList(); + }, + + loadNCList: function( refData, trackInfo, url ) { + refData.nclist.importExisting(trackInfo.featureNCList, + trackInfo.sublistIndex, + trackInfo.lazyIndex, + url, + trackInfo.lazyfeatureUrlTemplate); + }, + + _getFeatures: function( data, query, origFeatCallback, finishCallback, errorCallback ) { + var that = this, + startBase = query.start, + endBase = query.end, + fields = data.fields, + fieldOrder = data.fieldOrder, + subFields = data.subFields, + subfieldOrder = data.subfieldOrder, + get = function(fieldname) { + var f = fields[fieldname]; + if( f >= 0 ) + return this[f]; + else + return undefined; + }, + subget = function(fieldname) { + var f = subFields[fieldname]; + if( f >= 0 ) + return this[f]; + else + return undefined; + }, + tags = function() { + return fieldOrder; + }, + subTags = function() { + return subfieldOrder; + }, + featCallBack = function( feature, path ) { + that._decorate_feature( { get: get, tags: tags }, feature, path.join(',') ); + return origFeatCallback( feature, path ); + }; + + return data.nclist.iterate.call( data.nclist, startBase, endBase, featCallBack, finishCallback ); + } +}); +}); diff --git a/www/JBrowse/Store/SeqFeature/PseudoNCList.js b/www/JBrowse/Store/SeqFeature/PseudoNCList.js new file mode 100644 index 00000000..5af85286 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/PseudoNCList.js @@ -0,0 +1,22 @@ +/** +* notes on PseudoNCList needed for porting Trellis data loading to jbrowse_1.7 branch +* mostly need to override ID assignment based on position in NCList, +* and replace with unique IDs already present in feature arrays +* +* inherit from jbrowse Store/NCList, but override: + +_decorate_feature: function(accessors, feature, id, parent) { + feature.get = accessors.get; + if (config.uniqueIdField) { + otherid = feature.get(uniqueIdField) + } + var uid; + if (otherid) { uid = otherid; } + else { uid = id; } + this.inherited( accessors, feature, uid, parent ); +} + + +*/ + + diff --git a/www/JBrowse/Store/SeqFeature/QuantitativeCombination.js b/www/JBrowse/Store/SeqFeature/QuantitativeCombination.js new file mode 100644 index 00000000..bfd6d6b2 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/QuantitativeCombination.js @@ -0,0 +1,188 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Store/SeqFeature/CombinationBase' + ], + function( + declare, + array, + CombinationBaseStore + ) { + +// Plagiarized from Store/SeqFeature/Bigwig/RequestWorker to create BigWig features +var gettable = declare( null, { + get: function(name) { + return this[ { start: 'start', end: 'end', seq_id: 'segment' }[name] || name ]; + }, + tags: function() { + return ['start','end','seq_id','score','type','source']; + } +}); +var Feature = declare( gettable, {} ); + +return declare([CombinationBaseStore], { + +// An implementation of CombinationBase that deals with quantitative features (with score, as with BigWig features). +// Usual operations are things like addition, subtraction, multiplication, and division. + +// Applies a given operation on two scores. +applyOp: function(scoreA, scoreB, op) { + var retValue; + switch(op) { + case "+": + retValue = scoreA + scoreB; + break; + case "-": + retValue = scoreA - scoreB; + break; + case "*": + retValue = scoreA * scoreB; + break; + case "/": + retValue = (scoreB == 0) ? undefined : scoreA/scoreB; + break; + default: + console.error("invalid operation " + op); + return undefined; + } + return retValue; +}, + +// Converts a list of spans to a list of features. +createFeatures: function(spans) { + var features = []; + for(var span in spans) { + var f = new Feature(); + f.start = spans[span].start; + f.end = spans[span].end; + f.score = spans[span].score; + if(spans[span].segment) f.segment = spans[span].segment; + if(spans[span].type) f.type = spans[span].type; + f.source = this.name; + + features.push(f); + } + return features; +}, + + +// Loops through two sets of pseudo-features (spans). At any region for which both sets have features defined, +// applies the given operation on those features. Otherwise, uses whichever one is defined. +opSpan: function(op, pseudosA, pseudosB, query) { + var retPseudos = []; + var i = 0; + var j = 0; + + if(!pseudosA.length && !pseudosB.length) + return retPseudos; + + // Critical values are the starts and ends of features for either set of spans. + // nextCritical will iterate through all critical values. + var nextCritical = pseudosA[i] ? (pseudosB[j] ? Math.min(pseudosA[i].start, pseudosB[j].start) : pseudosA[i].start) : pseudosB[j].start; + + var inA; + var inB; + + + while(i < pseudosA.length && j < pseudosB.length) { + // Decide whether to add a span to the list at all - we don't add spans if the gap from this critical point to the + // next critical point is not inside any feature. + if(nextCritical == pseudosA[i].start) + inA = true; + if(nextCritical == pseudosB[j].start) + inB = true; + var addPseudo = inA || inB; + // If we're inside at least one pseudo-feature, adds data for the current feature. + if(addPseudo) { + var newPseudo = + { + start: nextCritical, + score: this.applyOp(inA ? pseudosA[i].score : 0, inB ? pseudosB[j].score : 0, op) + }; + if(inA != inB || pseudosA[i].segment == pseudosB[j].segment) { + newPseudo.segment = inA ? pseudosA[i].segment : pseudosB[j].segment; + } + if(inA != inB || pseudosA[i].type == pseudosB[j].type) { + newPseudo.type = inA ? pseudosA[i].type : pseudosB[j].type; + } + } + // Dividing by zero or other invalid operation being performed, don't add the feature + if(newPseudo.score === undefined) + addPseudo = false; + + // Fetches the next critical point (the next base pair greater than the current nextCritical value + // that is either the beginning or the end of a pseudo) + var _possibleCriticals = [pseudosA[i].start, pseudosA[i].end, pseudosB[j].start, pseudosB[j].end]; + + _possibleCriticals = array.filter(_possibleCriticals, function(item) { + return (item > nextCritical); + }).sort(function(a,b){ + return a-b; + }); + + nextCritical = _possibleCriticals[0]; + if(!nextCritical) + break; + + // Determines whether the next pseudo to be created will use data from pseudosA or pseudosB or both + if(nextCritical == pseudosA[i].end) { + inA = false; + i++; + } + if(nextCritical == pseudosB[j].end) { + inB = false; + j++; + } + + + // If there is currently a pseudo-feature being built, adds it + if(addPseudo) { + newPseudo.end = nextCritical; + + retPseudos.push(newPseudo); + } + } + + // If some pseudofeatures remain in either pseudo set, they are pushed as is into the return pseudo set. + for(; i < pseudosA.length; i++) { + retPseudos.push({ + start: Math.max(nextCritical, pseudosA[i].start), + end: pseudosA[i].end, + score: this.applyOp(pseudosA[i].score, 0, op), + segment: pseudosA[i].segment, + type: pseudosA[i].type + }); + } + for(; j < pseudosB.length; j++) { + retPseudos.push({ + start: Math.max(nextCritical, pseudosB[j].start), + end: pseudosB[j].end, + score: this.applyOp(0, pseudosB[j].score, op), + segment: pseudosB[j].segment, + type: pseudosB[j].type + }); + } + return retPseudos; +}, + + +toSpan: function(features, query) { + // given a set of features, creates a set of pseudo-features with similar properties. + var pseudos = []; + for(var feature in features) { + var pseudo = + { + start: features[feature].get('start'), + end: features[feature].get('end'), + score: features[feature].get('score'), + segment: features[feature].get('segment'), + type: features[feature].get('type') + }; + pseudos.push(pseudo); + } + return pseudos; +} + + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/REST.js b/www/JBrowse/Store/SeqFeature/REST.js new file mode 100644 index 00000000..0a633f6a --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/REST.js @@ -0,0 +1,186 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'dojo/io-query', + 'dojo/request', + 'JBrowse/Store/LRUCache', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Util', + 'JBrowse/Model/SimpleFeature' + ], + function( + declare, + lang, + array, + ioquery, + request, + LRUCache, + SeqFeatureStore, + DeferredFeaturesMixin, + DeferredStatsMixin, + Util, + SimpleFeature + ) { + + +return declare( SeqFeatureStore, +{ + + constructor: function( args ) { + + // make sure the baseUrl has a trailing slash + this.baseUrl = args.baseUrl || this.config.baseUrl; + if( this.baseUrl.charAt( this.baseUrl.length-1 ) != '/' ) + this.baseUrl = this.baseUrl + '/'; + + }, + + _defaultConfig: function() { + return { + noCache: false + }; + }, + + getGlobalStats: function( callback, errorCallback ) { + var url = this._makeURL( 'stats/global' ); + this._get( url, callback, errorCallback ); + }, + + getRegionStats: function( query, successCallback, errorCallback ) { + + if( ! this.config.region_stats ) { + this._getRegionStats.apply( this, arguments ); + return; + } + + var url = this._makeURL( 'stats/region', query ); + this._get( url, callback, errorCallback ); + }, + + getFeatures: function( query, featureCallback, endCallback, errorCallback ) { + var thisB = this; + var url = this._makeURL( 'features', query ); + this._get( url, + dojo.hitch( this, '_makeFeatures', + featureCallback, endCallback, errorCallback + ), + errorCallback + ); + }, + + clearCache: function() { + delete this._cache; + }, + + // HELPER METHODS + _get: function( url, callback, errorCallback ) { + + if( this.config.noCache ) + request( url, { + method: 'GET', + handleAs: 'json' + }).then( + callback, + this._errorHandler( errorCallback ) + ); + else + this._cachedGet( url, callback, errorCallback ); + + }, + + // get JSON from a URL, and cache the results + _cachedGet: function( url, callback, errorCallback ) { + var thisB = this; + if( ! this._cache ) { + this._cache = new LRUCache( + { + name: 'REST data cache '+this.name, + maxSize: 25000, // cache up to about 5MB of data (assuming about 200B per feature) + sizeFunction: function( data ) { return data.length || 1; }, + fillCallback: function( url, callback ) { + var get = request( url, { method: 'GET', handleAs: 'json' }, + true // work around dojo/request bug + ); + get.then( + function(data) { + var nocacheResponse = /no-cache/.test(get.response.getHeader('Cache-Control')) + || /no-cache/.test(get.response.getHeader('Pragma')); + callback(data,null,{nocache: nocacheResponse}); + }, + thisB._errorHandler( lang.partial( callback, null ) ) + ); + } + }); + } + + this._cache.get( url, function( data, error ) { + if( error ) + thisB._errorHandler(errorCallback)(error); + else + callback( data ); + }); + }, + + _errorHandler: function( handler ) { + handler = handler || function(e) { + console.error( e, e.stack ); + throw e; + }; + return dojo.hitch( this, function( error ) { + var httpStatus = ((error||{}).response||{}).status; + if( httpStatus >= 400 ) { + handler( "HTTP " + httpStatus + " fetching "+error.response.url+" : "+error.response.text ); + } + else { + handler( error ); + } + }); + }, + + _makeURL: function( subpath, query ) { + var url = this.baseUrl + subpath; + if( query ) { + query = dojo.mixin( {}, query ); + if( this.config.query ) + query = dojo.mixin( dojo.mixin( {}, this.config.query ), + query + ); + var ref = query.ref || (this.refSeq||{}).name; + delete query.ref; + url += (ref ? '/' + ref : '' ) + '?' + ioquery.objectToQuery( query ); + } + return url; + }, + + _makeFeatures: function( featureCallback, endCallback, errorCallback, featureData ) { + var features; + if( featureData && ( features = featureData.features ) ) { + for( var i = 0; i < features.length; i++ ) { + featureCallback( this._makeFeature( features[i] ) ); + } + } + + endCallback(); + }, + + _parseInt: function( data ) { + array.forEach(['start','end','strand'], function( field ) { + if( field in data ) + data[field] = parseInt( data[field] ); + }); + if( 'score' in data ) + data.score = parseFloat( data.score ); + if( 'subfeatures' in data ) + for( var i=0; i= 0 && binNumber <= maxBin ) { + var overlap = + isRightEnd ? 1 - ( binCoord - binNumber ) + : binCoord - binNumber; + return { + bin: binNumber, + overlap: overlap // between 0 and 1: proportion of this bin that the feature overlaps + }; + } + // otherwise null, this feature goes outside the block + else { + return isRightEnd ? { bin: maxBin, overlap: 1 } + : { bin: 0, overlap: 1 }; + } + }; + + + thisB.store.getFeatures( + query, + function( feature ) { + + // calculate total coverage + var startBO = binOverlap( feature.get('start'), false ); + var endBO = binOverlap( feature.get('end')-1 , true ); + + // increment start and end partial-overlap bins by proportion of overlap + if( startBO.bin == endBO.bin ) { + coverageBins[startBO.bin].increment( 'reference', endBO.overlap + startBO.overlap - 1 ); + } + else { + coverageBins[startBO.bin].increment( 'reference', startBO.overlap ); + coverageBins[endBO.bin].increment( 'reference', endBO.overlap ); + } + + // increment completely overlapped interior bins by 1 + for( var i = startBO.bin+1; i <= endBO.bin-1; i++ ) { + coverageBins[i].increment( 'reference', 1 ); + } + + + // Calculate SNP coverage + if( binWidth == 1 ) { + + // mark each bin as having its snps counted + for( var i = startBO.bin; i <= endBO.bin; i++ ) { + coverageBins[i].snpsCounted = 1; + } + + // parse the MD + var mdTag = feature.get('md'); + if( mdTag ) { + var SNPs = thisB._mdToMismatches(feature, mdTag); + // loops through mismatches and updates coverage variables accordingly. + for (var i = 0; i maxEnd ) + maxEnd = e; + + featureCount++; + } + + this.globalStats = { + featureDensity: featureCount/(this.refSeq.end - this.refSeq.start +1), + featureCount: featureCount, + minStart: minStart, /* 5'-most feature start */ + maxEnd: maxEnd, /* 3'-most feature end */ + span: (maxEnd-minStart+1) /* min span containing all features */ + }; + }, + + getFeatures: function( query, featCallback, endCallback, errorCallback ) { + var start = query.start; + var end = query.end; + for( var id in this.features ) { + var f = this.features[id]; + if(! ( f.get('end') < start || f.get('start') > end ) ) { + featCallback( f ); + } + } + if (endCallback) { endCallback() } + } + }); +}); diff --git a/www/JBrowse/Store/SeqFeature/SimpleTrack.js b/www/JBrowse/Store/SeqFeature/SimpleTrack.js new file mode 100644 index 00000000..cd99abc5 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/SimpleTrack.js @@ -0,0 +1,18 @@ +define(['jquery', 'dojo/_base/declare', 'JBrowse/Model/SimpleFeature', 'JBrowse/Store/SeqFeature'] +, function ($, declare, SimpleFeature, SeqFeatureStore) { + + return declare(SeqFeatureStore, + { + getFeatures: function(query, featureCallback, endCallback, errorCallback) { + var url = 'features/' + query.ref + ':' + query.start + '..' + query.end; + $.getJSON(url, function (features) { + for(var i = 0; i < features.length; i++) { + var feature = features[i]; + featureCallback(new SimpleFeature({data: feature})); + } + }); + + endCallback(); + } + }); +}); diff --git a/www/JBrowse/Store/SeqFeature/VCFTabix.js b/www/JBrowse/Store/SeqFeature/VCFTabix.js new file mode 100644 index 00000000..dffd6fdd --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/VCFTabix.js @@ -0,0 +1,146 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/Deferred', + 'JBrowse/Store/SeqFeature', + 'JBrowse/Store/DeferredStatsMixin', + 'JBrowse/Store/DeferredFeaturesMixin', + 'JBrowse/Store/TabixIndexedFile', + 'JBrowse/Store/SeqFeature/GlobalStatsEstimationMixin', + 'JBrowse/Model/XHRBlob', + './VCFTabix/Parser' + ], + function( + declare, + lang, + Deferred, + SeqFeatureStore, + DeferredStatsMixin, + DeferredFeaturesMixin, + TabixIndexedFile, + GlobalStatsEstimationMixin, + XHRBlob, + VCFParser + ) { + + +// subclass the TabixIndexedFile to modify the parsed items a little +// bit so that the range filtering in TabixIndexedFile will work. VCF +// files don't actually have an end coordinate, so we have to make it +// here. also convert coordinates to interbase. +var VCFIndexedFile = declare( TabixIndexedFile, { + parseItem: function() { + var i = this.inherited( arguments ); + if( i ) { + i.start--; + i.end = i.start + i.fields[3].length; + } + return i; + } +}); + +return declare( [ SeqFeatureStore, DeferredStatsMixin, DeferredFeaturesMixin, GlobalStatsEstimationMixin, VCFParser ], +{ + + constructor: function( args ) { + var thisB = this; + + var tbiBlob = args.tbi || + new XHRBlob( + this.resolveUrl( + this.getConf('tbiUrlTemplate',[]) || this.getConf('urlTemplate',[])+'.tbi' + ) + ); + + var fileBlob = args.file || + new XHRBlob( + this.resolveUrl( this.getConf('urlTemplate',[]) ) + ); + + this.indexedData = new VCFIndexedFile( + { + tbi: tbiBlob, + file: fileBlob, + browser: this.browser, + chunkSizeLimit: args.chunkSizeLimit + }); + + this._loadHeader() + .then( function() { + thisB._deferred.features.resolve({success:true}); + thisB._estimateGlobalStats() + .then( + function( stats ) { + thisB.globalStats = stats; + thisB._deferred.stats.resolve( stats ); + }, + lang.hitch( thisB, '_failAllDeferred' ) + ); + }, + lang.hitch( thisB, '_failAllDeferred' ) + ); + }, + + /** fetch and parse the VCF header lines */ + _loadHeader: function() { + var thisB = this; + return this._parsedHeader = this._parsedHeader || function() { + var d = new Deferred(); + + thisB.indexedData.indexLoaded.then( function() { + var maxFetch = thisB.indexedData.index.firstDataLine + ? thisB.indexedData.index.firstDataLine.block + thisB.indexedData.data.blockSize - 1 + : null; + + thisB.indexedData.data.read( + 0, + maxFetch, + function( bytes ) { + + thisB.parseHeader( new Uint8Array( bytes ) ); + + d.resolve({ success:true}); + }, + lang.hitch( d, 'reject' ) + ); + }, + lang.hitch( d, 'reject' ) + ); + + return d; + }.call(); + }, + + _getFeatures: function( query, featureCallback, finishedCallback, errorCallback ) { + var thisB = this; + thisB._loadHeader().then( function() { + thisB.indexedData.getLines( + query.ref || thisB.refSeq.name, + query.start, + query.end, + function( line ) { + var f = thisB.lineToFeature( line ); + //console.log(f); + featureCallback( f ); + //return f; + }, + finishedCallback, + errorCallback + ); + }, errorCallback ); + }, + + /** + * Interrogate whether a store has data for a given reference + * sequence. Calls the given callback with either true or false. + * + * Implemented as a binary interrogation because some stores are + * smart enough to regularize reference sequence names, while + * others are not. + */ + hasRefSeq: function( seqName, callback, errorCallback ) { + return this.indexedData.index.hasRefSeq( seqName, callback, errorCallback ); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/Store/SeqFeature/VCFTabix/LazyFeature.js b/www/JBrowse/Store/SeqFeature/VCFTabix/LazyFeature.js new file mode 100644 index 00000000..7b51963d --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/VCFTabix/LazyFeature.js @@ -0,0 +1,117 @@ +/** + * Lazy-parsing feature implementation for VCF stores. + */ + +define( ['dojo/_base/array', + 'dojo/json', + 'JBrowse/Util' + ], + function( array, dojoJSON, Util ) { + +var Feature = Util.fastDeclare( +{ + constructor: function( args ) { + this.parser = args.parser; + this.data = args.data; + this._id = args.id; + this.fields = args.fields; + }, + + get: function( field) { + return this._get( field ) || this._get( field.toLowerCase() ); + }, + + // same as get(), except requires lower-case arguments. used + // internally to save lots of calls to field.toLowerCase() + _get: function( field ) { + return field in this.data ? this.data[field] : // have we already parsed it out? + function(field) { + var v = this.data[field] = + this['_parse_'+field] ? this['_parse_'+field]() : // maybe we have a special parser for it + undefined; + return v; + }.call(this,field); + }, + + parent: function() { + return null; + }, + + children: function() { + return null; + }, + + tags: function() { + var t = []; + var d = this.data; + for( var k in d ) { + if( d.hasOwnProperty( k ) ) + t.push( k ); + } + if( ! d.genotypes ) + t.push('genotypes'); + return t; + }, + + id: function() { + return this._id; + }, + + _parse_genotypes: function() { + var fields = this.fields; + var parser = this.parser; + delete this.fields; // TODO: remove these deletes if we add other laziness + delete this.parser; + + if( fields.length < 10 ) + return null; + + // parse the genotype data fields + var genotypes = []; + var format = array.map( fields[8].split(':'), function( fieldID ) { + return { id: fieldID, meta: parser._getFormatMeta( fieldID ) }; + }, this ); + for( var i = 9; i < fields.length; ++i ) { + var g = (fields[i]||'').split(':'); + var gdata = {}; + for( var j = 0; j$/) ) { + metaData = this._parseGenericHeaderLine( metaData ); + } + + if( ! headData[metaField] ) + headData[metaField] = []; + + headData[metaField].push( metaData ); + } + else if( /^#CHROM\t/.test( line ) ) { + var f = line.split("\t"); + if( f[8] == 'FORMAT' && f.length > 9 ) + headData.samples = f.slice(9); + } + } + //console.log(headData); + + // index some of the headers by ID + for( var headerType in headData ) { + if( dojo.isArray( headData[headerType] ) && typeof headData[headerType][0] == 'object' && 'id' in headData[headerType][0] ) + headData[headerType] = this._indexUniqObjects( headData[headerType], 'id' ); + } + + this.header = headData; + return headData; + }, + + /** + * Given a line from a TabixIndexedFile, convert it into a feature + * and return it. Assumes that the header has already been parsed + * and stored (i.e. _parseHeader has already been called.) + */ + lineToFeature: function( line ) { + var fields = line.fields; + var ids = []; + for( var i=0; i 1 ) + featureData.aliases = ids.slice(1).join(','); + } + + if( fields[5] !== null ) + featureData.score = parseFloat( fields[5] ); + if( fields[6] !== null ) + featureData.filter = fields[6]; + + if( alt && alt[0] != '<' ) + featureData.alternative_alleles = { + meta: { + description: 'VCF ALT field, list of alternate non-reference alleles called on at least one of the samples' + }, + values: alt + }; + + // parse the info field and store its contents as attributes in featureData + this._parseInfoField( featureData, fields ); + + var f = new LazyFeature({ + id: ids[0] || fields.slice( 0, 9 ).join('/'), + data: featureData, + fields: fields, + parser: this + }); + + return f; + }, + + _newlineCode: "\n".charCodeAt(0), + + /** + * helper method that parses the next line from a Uint8Array or similar. + * @param parseState.data the byte array + * @param parseState.offset the offset to start parsing. $/g,''); + return this._parseKeyValue( metaData, ',', ';', 'lowercase' ); + }, + + _vcfReservedInfoFields: { + // from the VCF4.1 spec, http://www.1000genomes.org/wiki/Analysis/Variant%20Call%20Format/vcf-variant-call-format-version-41 + AA: { description: "ancestral allele" }, + AC: { description: "allele count in genotypes, for each ALT allele, in the same order as listed" }, + AF: { description: "allele frequency for each ALT allele in the same order as listed: use this when estimated from primary data, not called genotypes" }, + AN: { description: "total number of alleles in called genotypes" }, + BQ: { description: "RMS base quality at this position" }, + CIGAR: { description: "cigar string describing how to align an alternate allele to the reference allele" }, + DB: { description: "dbSNP membership" }, + DP: { description: "combined depth across samples, e.g. DP=154" }, + END: { description: "end position of the variant described in this record (esp. for CNVs)" }, + H2: { description: "membership in hapmap2" }, + MQ: { description: "RMS mapping quality, e.g. MQ=52" }, + MQ0: { description: "Number of MAPQ == 0 reads covering this record" }, + NS: { description: "Number of samples with data" }, + SB: { description: "strand bias at this position" }, + SOMATIC: { description: "indicates that the record is a somatic mutation, for cancer genomics" }, + VALIDATED: { description: "validated by follow-up experiment" }, + + //specifically for structural variants + "IMPRECISE": { number: 0, type: 'Flag', description: "Imprecise structural variation" }, + "NOVEL": { number: 0, type: 'Flag',description: "Indicates a novel structural variation" }, + "END": { number: 1, type: 'Integer', description: "End position of the variant described in this record" }, + + // For precise variants, END is POS + length of REF allele - + // 1, and the for imprecise variants the corresponding best + // estimate. + + "SVTYPE": { number: 1, type: 'String',description: "Type of structural variant" }, + + // Value should be one of DEL, INS, DUP, INV, CNV, BND. This + // key can be derived from the REF/ALT fields but is useful + // for filtering. + + "SVLEN": { number:'.',type: 'Integer', description: 'Difference in length between REF and ALT alleles' }, + + // One value for each ALT allele. Longer ALT alleles + // (e.g. insertions) have positive values, shorter ALT alleles + // (e.g. deletions) have negative values. + + "CIPOS": { number: 2, "type": 'Integer', "description": 'Confidence interval around POS for imprecise variants' }, + "CIEND": { number: 2, "type": 'Integer', "description": "Confidence interval around END for imprecise variants" }, + "HOMLEN": { "type": "Integer", "description": "Length of base pair identical micro-homology at event breakpoints" }, + "HOMSEQ": { "type": "String", "description": "Sequence of base pair identical micro-homology at event breakpoints" }, + "BKPTID": { "type": "String", "description": "ID of the assembled alternate allele in the assembly file" }, + + // For precise variants, the consensus sequence the alternate + // allele assembly is derivable from the REF and ALT + // fields. However, the alternate allele assembly file may + // contain additional information about the characteristics of + // the alt allele contigs. + + "MEINFO": { number:4, "type": "String", "description": "Mobile element info of the form NAME,START,END,POLARITY" }, + "METRANS": { number:4, "type": "String", "description": "Mobile element transduction info of the form CHR,START,END,POLARITY" }, + "DGVID": { number:1, "type": "String", "description": "ID of this element in Database of Genomic Variation"}, + "DBVARID": { number:1, "type": "String", "description": "ID of this element in DBVAR"}, + "DBRIPID": { number:1, "type": "String", "description": "ID of this element in DBRIP"}, + "MATEID": { "type": "String", "description": "ID of mate breakends"}, + "PARID": { number:1, "type": "String", "description": "ID of partner breakend"}, + "EVENT": { number:1, "type": "String", "description": "ID of event associated to breakend"}, + "CILEN": { number:2, "type": "Integer","description": "Confidence interval around the length of the inserted material between breakends"}, + "DP": { number:1, "type": "Integer","description": "Read Depth of segment containing breakend"}, + "DPADJ": { "type": "Integer","description": "Read Depth of adjacency"}, + "CN": { number:1, "type": "Integer","description": "Copy number of segment containing breakend"}, + "CNADJ": { "type": "Integer","description": "Copy number of adjacency"}, + "CICN": { number:2, "type": "Integer","description": "Confidence interval around copy number for the segment"}, + "CICNADJ": { "type": "Integer","description": "Confidence interval around copy number for the adjacency"} + }, + + _vcfStandardGenotypeFields: { + // from the VCF4.1 spec, http://www.1000genomes.org/wiki/Analysis/Variant%20Call%20Format/vcf-variant-call-format-version-41 + GT : { description: "genotype, encoded as allele values separated by either of '/' or '|'. The allele values are 0 for the reference allele (what is in the REF field), 1 for the first allele listed in ALT, 2 for the second allele list in ALT and so on. For diploid calls examples could be 0/1, 1|0, or 1/2, etc. For haploid calls, e.g. on Y, male non-pseudoautosomal X, or mitochondrion, only one allele value should be given; a triploid call might look like 0/0/1. If a call cannot be made for a sample at a given locus, '.' should be specified for each missing allele in the GT field (for example './.' for a diploid genotype and '.' for haploid genotype). The meanings of the separators are as follows (see the PS field below for more details on incorporating phasing information into the genotypes): '/' meaning genotype unphased, '|' meaning genotype phased" }, + DP : { description: "read depth at this position for this sample (Integer)" }, + FT : { description: "sample genotype filter indicating if this genotype was \"called\" (similar in concept to the FILTER field). Again, use PASS to indicate that all filters have been passed, a semi-colon separated list of codes for filters that fail, or \".\" to indicate that filters have not been applied. These values should be described in the meta-information in the same way as FILTERs (String, no white-space or semi-colons permitted)" }, + GL : { description: "genotype likelihoods comprised of comma separated floating point log10-scaled likelihoods for all possible genotypes given the set of alleles defined in the REF and ALT fields. In presence of the GT field the same ploidy is expected and the canonical order is used; without GT field, diploidy is assumed. If A is the allele in REF and B,C,... are the alleles as ordered in ALT, the ordering of genotypes for the likelihoods is given by: F(j/k) = (k*(k+1)/2)+j. In other words, for biallelic sites the ordering is: AA,AB,BB; for triallelic sites the ordering is: AA,AB,BB,AC,BC,CC, etc. For example: GT:GL 0/1:-323.03,-99.29,-802.53 (Floats)" }, + GLE : { description: "genotype likelihoods of heterogeneous ploidy, used in presence of uncertain copy number. For example: GLE=0:-75.22,1:-223.42,0/0:-323.03,1/0:-99.29,1/1:-802.53 (String)" }, + PL : { description: "the phred-scaled genotype likelihoods rounded to the closest integer (and otherwise defined precisely as the GL field) (Integers)" }, + GP : { description: "the phred-scaled genotype posterior probabilities (and otherwise defined precisely as the GL field); intended to store imputed genotype probabilities (Floats)" }, + GQ : { description: "conditional genotype quality, encoded as a phred quality -10log_10p(genotype call is wrong, conditioned on the site's being variant) (Integer)" }, + HQ : { description: "haplotype qualities, two comma separated phred qualities (Integers)" }, + PS : { description: "phase set. A phase set is defined as a set of phased genotypes to which this genotype belongs. Phased genotypes for an individual that are on the same chromosome and have the same PS value are in the same phased set. A phase set specifies multi-marker haplotypes for the phased genotypes in the set. All phased genotypes that do not contain a PS subfield are assumed to belong to the same phased set. If the genotype in the GT field is unphased, the corresponding PS field is ignored. The recommended convention is to use the position of the first variant in the set as the PS identifier (although this is not required). (Non-negative 32-bit Integer)" }, + PQ : { description: "phasing quality, the phred-scaled probability that alleles are ordered incorrectly in a heterozygote (against all other members in the phase set). We note that we have not yet included the specific measure for precisely defining \"phasing quality\"; our intention for now is simply to reserve the PQ tag for future use as a measure of phasing quality. (Integer)" }, + EC : { description: "comma separated list of expected alternate allele counts for each alternate allele in the same order as listed in the ALT field (typically used in association analyses) (Integers)" }, + MQ : { description: "RMS mapping quality, similar to the version in the INFO field. (Integer)" } + }, + + _vcfReservedAltTypes: { + "DEL": { description: "Deletion relative to the reference", so_term: 'deletion' }, + "INS": { description: "Insertion of novel sequence relative to the reference", so_term: 'insertion' }, + "DUP": { description: "Region of elevated copy number relative to the reference", so_term: 'copy_number_gain' }, + "INV": { description: "Inversion of reference sequence", so_term: 'inversion' }, + "CNV": { description: "Copy number variable region (may be both deletion and duplication)", so_term: 'copy_number_variation' }, + "DUP:TANDEM": { description: "Tandem duplication", so_term: 'copy_number_gain' }, + "DEL:ME": { description: "Deletion of mobile element relative to the reference" }, + "INS:ME": { description: "Insertion of a mobile element relative to the reference" } + }, + + /** + * parse a VCF line's INFO field, storing the contents as + * attributes in featureData + */ + _parseInfoField: function( featureData, fields ) { + if( !fields[7] || fields[7] == '.' ) + return; + var info = this._parseKeyValue( fields[7] ); + + // decorate the info records with references to their descriptions + for( var field in info ) { + if( info.hasOwnProperty( field ) ) { + var i = info[field] = { + values: info[field], + toString: function() { return (this.values || []).join(','); } + }; + var meta = this._getInfoMeta( field ); + if( meta ) + i.meta = meta; + } + } + + dojo.mixin( featureData, info ); + }, + + _getAltMeta: function( alt ) { + return (this.header.alt||{})[alt] || this._vcfReservedAltTypes[alt]; + }, + _getInfoMeta: function( id ) { + return (this.header.info||{})[id] || this._vcfReservedInfoFields[id]; + }, + _getFormatMeta: function( fieldname ) { + return (this.header.format||{})[fieldname] || this._vcfStandardGenotypeFields[fieldname]; + }, + + /** + * Take an array of objects and make another object that indexes + * them into another object for easy lookup by the given field. + * WARNING: Values of the field must be unique. + */ + _indexUniqObjects: function( entries, indexField, lowerCase ) { + // index the info fields by field ID + var items = {}; + array.forEach( entries, function( rec ) { + var k = rec[indexField]; + if( dojo.isArray(k) ) + k = k[0]; + if( lowerCase ) + k = k.toLowerCase(); + items[ rec[indexField] ]= rec; + }); + return items; + }, + + /** + * Parse a VCF key-value string like DP=154;Foo="Bar; baz";MQ=52;H2 into an object like + * { DP: [154], Foo:['Bar',' baz'], ... } + * + * Done in a low-level style to properly support quoted values. >:-{ + */ + _parseKeyValue: function( str, pairSeparator, valueSeparator, lowercaseKeys ) { + pairSeparator = pairSeparator || ';'; + valueSeparator = valueSeparator || ','; + + var data = {}; + var currKey = ''; + var currValue = ''; + var state = 1; // states: 1: read key to =, 2: read value to comma or sep, 3: read value to quote + for( var i = 0; i < str.length; i++ ) { + if( state == 1 ) { // read key + if( str[i] == '=' ) { + if( lowercaseKeys ) + currKey = currKey.toLowerCase(); + data[currKey] = []; + state = 2; + } + else if( str[i] == pairSeparator ) { + if( lowercaseKeys ) + currKey = currKey.toLowerCase(); + data[currKey] = [true]; + currKey = ''; + state = 1; + } + else { + currKey += str[i]; + } + } + else if( state == 2 ) { // read value to value sep or pair sep + if( str[i] == valueSeparator ) { + data[currKey].push( currValue ); + currValue = ''; + } + else if( str[i] == pairSeparator ) { + data[currKey].push( currValue ); + currKey = ''; + state = 1; + currValue = ''; + } else if( str[i] == '"' ) { + state = 3; + currValue = ''; + } + else + currValue += str[i]; + } + else if( state == 3 ) { // read value to quote + if( str[i] != '"' ) + currValue += str[i]; + else + state = 2; + } + } + + if( state == 2 || state == 3) { + data[currKey].push( currValue ); + } + + return data; + }, + + _find_SO_term: function( ref, alt ) { + // it's just a remark if there are no alternate alleles + if( ! alt || alt == '.' ) + return 'remark'; + + var types = array.filter( array.map( alt.split(','), function( alt ) { + return this._find_SO_term_from_alt_definitions( alt ); + }, this ), + function( t ) { return t; } ); + + if( types[0] ) + return types.join(','); + + + return this._find_SO_term_by_examination( ref, alt ); + }, + + /** + * Given an ALT string, return a string suitable for appending to + * the feature description, if available. + */ + _makeDescriptionString: function( SO_term, ref, alt ) { + if( ! alt ) + return 'no alternative alleles'; + + alt = alt.replace(/^<|>$/g,''); + + var def = this._getAltMeta( alt ); + return def && def.description ? alt+' - '+def.description : SO_term+" "+ref+" -> "+ alt; + }, + + _find_SO_term_from_alt_definitions: function( alt ) { + // not a symbolic ALT if doesn't begin with '<', so we'll have no definition + if( alt[0] != '<' ) + return null; + + alt = alt.replace(/^<|>$/g,''); // trim off < and > + + // look for a definition with an SO type for this + var def = (this.header.alt||{})[alt] || this._vcfReservedAltTypes[alt]; + if( def && def.so_term ) + return def.so_term; + + // try to look for a definition for a parent term if we can + alt = alt.split(':'); + if( alt.length > 1 ) + return this._find_SO_term_from_alt_definitions( '<'+alt.slice( 0, alt.length-1 ).join(':')+'>' ); + else // no parent + return null; + }, + + _find_SO_term_by_examination: function( ref, alt ) { + alt = alt.split(','); + + var minAltLen = Infinity; + var maxAltLen = -Infinity; + var altLen = array.map( alt, function(a) { + var l = a.length; + if( l < minAltLen ) + minAltLen = l; + if( l > maxAltLen ) + maxAltLen = l; + return a.length; + }); + + if( ref.length == 1 && minAltLen == 1 && maxAltLen == 1 ) + return 'SNV'; // use SNV because SO definition of SNP says + // abundance must be at least 1% in + // population, and can't be sure we meet + // that + + if( ref.length == minAltLen && ref.length == maxAltLen ) + if( alt.length == 1 && ref.split('').reverse().join('') == alt[0] ) + return 'inversion'; + else + return 'substitution'; + + if( ref.length <= minAltLen && ref.length < maxAltLen ) + return 'insertion'; + + if( ref.length > minAltLen && ref.length >= maxAltLen ) + return 'deletion'; + + return 'indel'; + } + +}); +}); diff --git a/www/JBrowse/Store/SeqFeature/_MismatchesMixin.js b/www/JBrowse/Store/SeqFeature/_MismatchesMixin.js new file mode 100644 index 00000000..fd66b0a7 --- /dev/null +++ b/www/JBrowse/Store/SeqFeature/_MismatchesMixin.js @@ -0,0 +1,144 @@ +/** + * Functions for parsing MD and CIGAR strings. + */ +define([ + 'dojo/_base/declare', + 'dojo/_base/array' + ], + function( + declare, + array + ) { + +return declare( null, { + + constructor: function() { + this.cigarAttributeName = ( this.config.cigarAttribute || 'cigar' ).toLowerCase(); + this.mdAttributeName = ( this.config.mdAttribute || 'md' ).toLowerCase(); + }, + + _getMismatches: function( feature ) { + var mismatches = []; + + // parse the CIGAR tag if it has one + var cigarString = feature.get( this.cigarAttributeName ), + cigarOps; + if( cigarString ) { + cigarOps = this._parseCigar( cigarString ); + mismatches.push.apply( mismatches, this._cigarToMismatches( feature, cigarOps ) ); + } + + // parse the MD tag if it has one + var mdString = feature.get( this.mdAttributeName ); + if( mdString ) { + mismatches.push.apply( mismatches, this._mdToMismatches( feature, mdString, cigarOps, mismatches ) ); + } + + return mismatches; + }, + + _parseCigar: function( cigar ) { + return array.map( cigar.match(/\d+\D/g), function( op ) { + return [ op.match(/\D/)[0].toUpperCase(), parseInt( op ) ]; + }); + }, + + _cigarToMismatches: function( feature, ops ) { + var currOffset = 0; + var mismatches = []; + array.forEach( ops, function( oprec ) { + var op = oprec[0].toUpperCase(); + if( !op ) + return; + var len = oprec[1]; + // if( op == 'M' || op == '=' || op == 'E' ) { + // // nothing + // } + if( op == 'I' ) + mismatches.push( { start: currOffset, type: 'insertion', base: ''+len, length: 1 }); + else if( op == 'D' ) + mismatches.push( { start: currOffset, type: 'deletion', base: '*', length: len }); + else if( op == 'N' ) + mismatches.push( { start: currOffset, type: 'skip', base: 'N', length: len }); + else if( op == 'X' ) + mismatches.push( { start: currOffset, type: 'mismatch', base: 'X', length: len }); + + if( op != 'I' && op != 'S' && op != 'H' ) + currOffset += len; + }); + return mismatches; + }, + + /** + * parse a SAM MD tag to find mismatching bases of the template versus the reference + * @returns {Array[Object]} array of mismatches and their positions + * @private + */ + _mdToMismatches: function( feature, mdstring, cigarOps, cigarMismatches ) { + var mismatchRecords = []; + var curr = { start: 0, base: '', length: 0, type: 'mismatch' }; + + // number of bases soft-clipped off the beginning of the template seq + + function getTemplateCoord( refCoord, cigarOps ) { + var templateOffset = 0; + var refOffset = 0; + for( var i = 0; i < cigarOps.length && refOffset <= refCoord ; i++ ) { + var op = cigarOps[i][0]; + var len = cigarOps[i][1]; + if( op == 'H' || op == 'S' || op == 'I' ) { + templateOffset += len; + } + else if( op == 'D' || op == 'N' || op == 'P' ) { + refOffset += len; + } + else { + templateOffset += len; + refOffset += len; + } + } + return templateOffset - ( refOffset - refCoord ); + } + + + function nextRecord() { + // correct the start of the current mismatch if it comes after a cigar skip + var skipOffset = 0; + array.forEach( cigarMismatches || [], function( mismatch ) { + if( mismatch.type == 'skip' && curr.start >= mismatch.start ) { + curr.start += mismatch.len; + } + }); + + // record it + mismatchRecords.push( curr ); + + // get a new mismatch record ready + curr = { start: curr.start + curr.length, length: 0, base: '', type: 'mismatch'}; + }; + + var seq = feature.get('seq'); + + // now actually parse the MD string + array.forEach( mdstring.match(/(\d+|\^[a-z]+|[a-z])/ig), function( token ) { + if( token.match(/^\d/) ) { // matching bases + curr.start += parseInt( token ); + } + else if( token.match(/^\^/) ) { // insertion in the template + curr.length = token.length-1; + curr.base = '*'; + curr.type = 'deletion'; + nextRecord(); + } + else if( token.match(/^[a-z]/i) ) { // mismatch + for( var i = 0; i this.chunkSizeLimit ) { + errorCallback( new Errors.DataOverflow('Too much data. Chunk size '+Util.commifyNumber(size)+' bytes exceeds chunkSizeLimit of '+Util.commifyNumber(this.chunkSizeLimit)+'.' ) ); + return; + } + } + + var fetchError; + try { + this._fetchChunkData( + chunks, + ref, + min, + max, + itemCallback, + finishCallback, + errorCallback + ); + } catch( e ) { + errorCallback( e ); + } + }, + + _fetchChunkData: function( chunks, ref, min, max, itemCallback, endCallback, errorCallback ) { + var thisB = this; + + if( ! chunks.length ) { + endCallback(); + return; + } + + var allItems = []; + var chunksProcessed = 0; + + var cache = this.chunkCache = this.chunkCache || new LRUCache({ + name: 'TabixIndexedFileChunkedCache', + fillCallback: dojo.hitch( this, '_readChunkItems' ), + sizeFunction: function( chunkItems ) { + return chunkItems.length; + }, + maxSize: 100000 // cache up to 100,000 items + }); + + var regRef = this.browser.regularizeReferenceName( ref ); + + var haveError; + array.forEach( chunks, function( c ) { + cache.get( c, function( chunkItems, e ) { + if( e && !haveError ) + errorCallback( e ); + if(( haveError = haveError || e )) { + return; + } + + for( var i = 0; i< chunkItems.length; i++ ) { + var item = chunkItems[i]; + if( item._regularizedRef == regRef ) { + // on the right ref seq + if( item.start > max ) // past end of range, can stop iterating + break; + else if( item.end >= min ) // must be in range + itemCallback( item ); + } + } + if( ++chunksProcessed == chunks.length ) { + endCallback(); + } + }); + }); + }, + + _readChunkItems: function( chunk, callback ) { + var thisB = this; + var items = []; + + thisB.data.read(chunk.minv.block, chunk.maxv.block - chunk.minv.block + 1, function( data ) { + data = new Uint8Array(data); + + // throw away the first (probably incomplete) line + var parseStart = chunk.minv.block ? array.indexOf( data, thisB._newlineCode, 0 ) + 1 : 0; + + try { + thisB.parseItems( + data, + parseStart, + function(i) { items.push(i); }, + function() { callback(items); } + ); + } catch( e ) { + callback( null, e ); + } + }, + function(e) { + callback( null, e ); + }); + }, + + parseItems: function( data, blockStart, itemCallback, finishCallback ) { + var that = this; + var itemCount = 0; + + var maxItemsWithoutYielding = 300; + var parseState = { data: data, offset: blockStart }; + + while ( true ) { + // if we've read no more than a certain number of items this cycle, read another one + if( itemCount <= maxItemsWithoutYielding ) { + var item = this.parseItem( parseState ); //< increments parseState.offset + if( item ) { + itemCallback( item ); + itemCount++; + } + else { + finishCallback(); + return; + } + } + // if we're not done but we've read a good chunk of + // items, schedule the rest of our work in a timeout to continue + // later, avoiding blocking any UI stuff that needs to be done + else { + window.setTimeout( function() { + that.parseItems( data, parseState.offset, itemCallback, finishCallback ); + }, 1); + return; + } + } + }, + + // stub method, override in subclasses or instances + parseItem: function( parseState ) { + var metaChar = this.index.metaChar; + + var line; + do { + line = this._getline( parseState ); + } while( line && line[0] == metaChar ); + + if( !line ) + return null; + + // function extractColumn( colNum ) { + // var skips = ''; + // while( colNum-- > 1 ) + // skips += '^[^\t]*\t'; + // var match = (new Regexp( skips+'([^\t]*)' )).exec( line ); + // if( ! match ) + // return null; + // return match[1]; + // } + var fields = line.split( "\t" ); + var item = { // note: index column numbers are 1-based + ref: fields[this.index.columnNumbers.ref-1], + _regularizedRef: this.browser.regularizeReferenceName( fields[this.index.columnNumbers.ref-1] ), + start: parseInt(fields[this.index.columnNumbers.start-1]), + end: parseInt(fields[this.index.columnNumbers.end-1]), + fields: fields + }; + return item; + }, + + _newlineCode: "\n".charCodeAt(0), + + _getline: function( parseState ) { + var data = parseState.data; + var newlineIndex = array.indexOf( data, this._newlineCode, parseState.offset ); + + if( newlineIndex == -1 ) // no more lines + return null; + + var line = ''; + for( var i = parseState.offset; i < newlineIndex; i++ ) + line += String.fromCharCode( data[i] ); + parseState.offset = newlineIndex+1; + return line; + } +}); +}); diff --git a/www/JBrowse/Store/TrackMetaData.js b/www/JBrowse/Store/TrackMetaData.js new file mode 100644 index 00000000..7aa435bd --- /dev/null +++ b/www/JBrowse/Store/TrackMetaData.js @@ -0,0 +1,766 @@ +define( + [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/data/util/simpleFetch', + 'JBrowse/Util', + 'JBrowse/Digest/Crc32' + ], + function( declare, array, simpleFetch, Util, Crc32 ) { +var dojof = Util.dojof; +var Meta = declare( null, + +/** + * @lends JBrowse.Store.TrackMetaData.prototype + */ +{ + + _noDataValue: '(no data)', + + /** + * Data store for track metadata, supporting faceted + * (parameterized) searching. Keeps all of the track metadata, + * and the indexes thereof, in memory. + * @constructs + * @param args.trackConfigs {Array} array of track configuration + * @param args.indexFacets {Function|Array|String} + * @param args.onReady {Function} + * @param args.metadataStores {Array[dojox.data]} + */ + constructor: function( args ) { + // set up our facet name discrimination: what facets we will + // actually provide search on + var non_facet_attrs = ['conf']; + this._filterFacet = (function() { + var filter = args.indexFacets || function() {return true;}; + // if we have a non-function filter, coerce to an array, + // then convert that array to a function + if( typeof filter == 'string' ) + filter = [filter]; + if( dojo.isArray( filter ) ) { + var oldfilter = filter; + filter = function( facetName) { + return dojo.some( oldfilter, function(fn) { + return facetName == fn.toLowerCase(); + }); + }; + } + var ident_facets = this.getIdentityAttributes(); + return function(facetName) { + return ( + // always index ident facets + dojo.some( ident_facets, function(n) { return n == facetName; } ) + // otherwise, must pass the user filter AND not be one of our explicitly-blocked attrs + || filter(facetName) + && ! dojo.some( non_facet_attrs, function(a) { return a == facetName;}) + ); + }; + }).call(this); + + // set up our onReady callbacks to fire once the data is + // loaded + if( ! dojo.isArray( args.onReady ) ){ + this.onReadyFuncs = args.onReady ? [ args.onReady ] : []; + } else { + this.onReadyFuncs = dojo.clone(args.onReady); + } + + // interpret the track configurations themselves as a metadata store + this._indexItems( + { + store: this, + items: dojo.map( args.trackConfigs, + dojo.hitch( this, '_trackConfigToItem' ) ) + } + ); + + // fetch and index all the items from each of the stores + var stores_fetched_count = 0; + // filter out empty metadata store entries + args.metadataStores = dojo.filter( args.metadataStores, function(s) { return s; } ); + if( ! args.metadataStores || ! args.metadataStores.length ) { + // if we don't actually have any stores besides the track + // confs, we're ready now. + this._finishLoad(); + } else { + // index the track metadata from each of the stores + + var storeFetchFinished = dojo.hitch( this, function() { + if( ++stores_fetched_count == args.metadataStores.length ) + this._finishLoad(); + }); + dojo.forEach( args.metadataStores, function(store) { + store.fetch({ + scope: this, + onComplete: dojo.hitch( this, function(items) { + // build our indexes + this._indexItems({ store: store, items: items, supplementalOnly: true }); + + // if this is the last store to be fetched, call + // our onReady callbacks + storeFetchFinished(); + }), + onError: function(e) { + console.error(e, e.stack); + storeFetchFinished(); + } + }); + },this); + } + + // listen for track-editing commands and update our track metadata accordingly + args.browser.subscribe( '/jbrowse/v1/c/tracks/new', + dojo.hitch( this, 'addTracks' )); + args.browser.subscribe( '/jbrowse/v1/c/tracks/replace', dojo.hitch( this, function( trackConfigs ) { + this.deleteTracks( trackConfigs, 'no events' ); + this.addTracks( trackConfigs, 'no events' ); + })); + args.browser.subscribe( '/jbrowse/v1/c/tracks/delete', + dojo.hitch( this, 'deleteTracks' )); + }, + + /** + * Convert a track config object into a data store item. + */ + _trackConfigToItem: function( conf ) { + var metarecord = dojo.clone( conf.metadata || {} ); + metarecord.label = conf.label; + metarecord.key = conf.key; + metarecord.conf = conf; + metarecord['track type'] = conf.type; + if( conf.category ) + metarecord.category = conf.category; + return metarecord; + }, + + addTracks: function( trackConfigs, suppressEvents ) { + if( trackConfigs.length ) { + // clear the query cache + delete this.previousQueryFingerprint; + delete this.previousResults; + } + + array.forEach( trackConfigs, function( conf ) { + // insert in the indexes + this._indexItems({ + store: this, + items: [ this._trackConfigToItem( conf ) ] + }); + + var name = conf.label; + var item = this.fetchItemByIdentity( name ); + if( ! item ) + console.error( 'failed to add '+name+' track to track metadata store', conf ); + else if( ! suppressEvents ) + this.onNew( item ); + },this ); + }, + + deleteTracks: function( trackConfigs, suppressEvents ) { + if( trackConfigs.length ) { + // clear the query cache + delete this.previousQueryFingerprint; + delete this.previousResults; + } + + // we don't actually delete things, we just mark them as + // deleted and filter out deleted ones when returning results. + array.forEach( trackConfigs, function( conf ) { + var name = conf.label; + var item = this.fetchItemByIdentity( name ); + if( item ) { + item.DELETED = true; + if( ! suppressEvents ) + this.onDelete( item ); + } + },this); + }, + + /** + * Set the store's state to be ready (i.e. loaded), and calls all + * our onReady callbacks. + * @private + */ + _finishLoad: function() { + + // sort the facet names + this.facets.sort(); + + // calculate the average bucket size for each facet index + dojo.forEach( dojof.values( this.facetIndexes.byName ), function(bucket) { + bucket.avgBucketSize = bucket.itemCount / bucket.bucketCount; + }); + // calculate the rank of the facets: make an array of + // facet names sorted by bucket size, descending + this.facetIndexes.facetRank = dojo.clone(this.facets).sort(dojo.hitch(this,function(a,b){ + return this.facetIndexes.byName[a].avgBucketSize - this.facetIndexes.byName[b].avgBucketSize; + })); + + // sort the facet indexes by ident, so that we can do our + // kind-of-efficient N-way merging when querying + var itemSortFunction = dojo.hitch( this, '_itemSortFunc' ); + dojo.forEach( dojof.values( this.facetIndexes.byName ), function( facetIndex ) { + dojo.forEach( dojof.keys( facetIndex.byValue ), function( value ) { + facetIndex.byValue[value].items = facetIndex.byValue[value].items.sort( itemSortFunction ); + }); + },this); + + this.ready = true; + this._onReady(); + }, + + _itemSortFunc: function(a,b) { + var ai = this.getIdentity(a), + bi = this.getIdentity(b); + return ai == bi ? 0 : + ai > bi ? 1 : + ai < bi ? -1 : 0; + }, + + _indexItems: function( args ) { + // get our (filtered) list of facets we will index for + var store = args.store, + items = args.items; + + var storeAttributes = {}; + + // convert the items to a uniform format + items = dojo.map( items, function( item ) { + var itemattrs = store.getAttributes(item); + + //convert the item into a uniform data format of plain objects + var newitem = {}; + dojo.forEach( itemattrs, function(attr) { + // stores sometimes emit undef attributes >:-{ + if( ! attr ) + return; + + var lcattr = attr.toLowerCase(); + storeAttributes[lcattr] = true; + newitem[lcattr] = store.getValue(item,attr); + }); + return newitem; + }, + this + ); + + // merge them with any existing records, filtering out ones + // that should be ignored if we were passed + // 'supplementalOnly', and update the identity index + this.identIndex = this.identIndex || {}; + items = (function() { + var seenInThisStore = {}; + return dojo.map( items, function(item) { + // merge the new item attributes with any existing + // record for this item + var ident = this.getIdentity(item); + var existingItem = this.identIndex[ ident ]; + if( existingItem && existingItem.DELETED ) + delete existingItem.DELETED; + + // skip this item if we have already + // seen it from this store, or if we + // are supplementalOnly and it + // does not already exist + if( seenInThisStore[ident] || args.supplementalOnly && !existingItem) { + return null; + } + seenInThisStore[ident] = true; + + return this.identIndex[ ident ] = dojo.mixin( existingItem || {}, item ); + }, + this + ); + }).call(this); + + // filter out nulls + items = dojo.filter( items, function(i) { return i;}); + + // update our facet list to include any new attrs these + // items have + var store_facets = dojof.keys( storeAttributes ); + var new_facets = this._addFacets( dojof.keys( storeAttributes ) ); + var use_facets = array.filter( this.facets, function(f) { return f in storeAttributes; } ); + + // initialize indexes for any new facets + this.facetIndexes = this.facetIndexes || { itemCount: 0, bucketCount: 0, byName: {} }; + dojo.forEach( new_facets, function(facet) { + if( ! this.facetIndexes.byName[facet] ) { + this.facetIndexes.bucketCount++; + this.facetIndexes.byName[facet] = { itemCount: 0, bucketCount: 0, byValue: {} }; + } + }, this); + + // now update the indexes with the new data + if( use_facets.length ) { + var gotDataForItem = {}; + dojo.forEach( use_facets, function(f){ gotDataForItem[f] = {};}); + + dojo.forEach( items, function( item ) { + this.facetIndexes.itemCount++; + dojo.forEach( use_facets, function( facet ) { + var value = this.getValue( item, facet, undefined ); + if( typeof value == 'undefined' ) + return; + gotDataForItem[facet][this.getIdentity(item)] = 1; + this._indexItem( facet, value, item ); + },this); + }, this); + + // index the items that do not have data for this facet + dojo.forEach( use_facets, function(facet) { + dojo.forEach( dojof.values( this.identIndex ), function(item) { + if( ! gotDataForItem[facet][this.getIdentity(item)] ) { + this._indexItem( facet, this._noDataValue, item ); + } + },this); + },this); + } + }, + + /** + * Add an item to the indexes for the given facet name and value. + * @private + */ + _indexItem: function( facet, value, item ) { + var facetValues = this.facetIndexes.byName[facet]; + var bucket = facetValues.byValue[value]; + if( !bucket ) { + bucket = facetValues.byValue[value] = { itemCount: 0, items: [] }; + facetValues.bucketCount++; + } + bucket.itemCount++; + facetValues.itemCount++; + bucket.items.push(item); + }, + + /** + * Given an array of string facet names, add records for them, + * initializing the necessary data structures. + * @private + * @returns {Array[String]} facet names that did not already exist + */ + _addFacets: function( facetNames ) { + var old_facets = this.facets || []; + var seen = {}; + this.facets = dojo.filter( + old_facets.concat( facetNames ), + function(facetName) { + var take = this._filterFacet(facetName) && !seen[facetName]; + seen[facetName] = true; + return take; + }, + this + ); + return this.facets.slice( old_facets.length ); + }, + + /** + * Get the number of items that matched the most recent query. + * @returns {Number} the item count, or undefined if there has not + * been any query so far. + */ + getCount: function() { + return this._fetchCount; + }, + + + /** + * @param facetName {String} facet name + * @returns {Object} + */ + getFacetCounts: function( facetName ) { + var context = this._fetchFacetCounts[ facetName ] || this._fetchFacetCounts[ '__other__' ]; + return context ? context[facetName] : undefined; + }, + + /** + * Get an array of the text names of the facets that are defined + * in this track metadata. + * @param callback {Function} called as callback( [facet,facet,...] ) + */ + getFacetNames: function( callback ) { + return this.facets; + }, + + /** + * Get an Array of the distinct values for a given facet name. + * @param facetName {String} the facet name + * @returns {Array} distinct values for that facet + */ + getFacetValues: function( facetName ) { + var index = this.facetIndexes.byName[facetName]; + if( !index ) + return []; + + return dojof.keys( index.byValue ); + }, + + /** + * Get statistics about the facet with the given name. + * @returns {Object} as: { itemCount: ##, bucketCount: ##, avgBucketSize: ## } + */ + getFacetStats: function( facetName ) { + var index = this.facetIndexes.byName[facetName]; + if( !index ) return {}; + + var stats = {}; + dojo.forEach( ['itemCount','bucketCount','avgBucketSize'], + function(attr) { stats[attr] = index[attr]; } + ); + return stats; + }, + + // dojo.data.api.Read support + + getValue: function( i, attr, defaultValue ) { + var v = i[attr]; + return typeof v == 'undefined' ? defaultValue : v; + }, + getValues: function( i, attr ) { + var a = [ i[attr] ]; + return typeof a[0] == 'undefined' ? [] : a; + }, + + getAttributes: function(item) { + return dojof.keys( item ); + }, + + hasAttribute: function(item,attr) { + return item.hasOwnProperty(attr); + }, + + containsValue: function(item, attribute, value) { + return item[attribute] == value; + }, + + isItem: function(item) { + return typeof item == 'object' && typeof item.label == 'string'; + }, + + isItemLoaded: function() { + return this.ready; + }, + + loadItem: function( args ) { + }, + + getItem: function( label ) { + if( this.ready ) + return this.identIndex[label]; + else + return null; + }, + + // used by the dojo.data.util.simpleFetch mixin to implement fetch() + _fetchItems: function( keywordArgs, findCallback, errorCallback ) { + if( ! this.ready ) { + this.onReady( dojo.hitch( this, '_fetchItems', keywordArgs, findCallback, errorCallback ) ); + return; + } + + var query = dojo.clone( keywordArgs.query || {} ); + // coerce query arguments to arrays if they are not already arrays + dojo.forEach( dojof.keys( query ), function(qattr) { + if( ! dojo.isArray( query[qattr] ) ) { + query[qattr] = [ query[qattr] ]; + } + },this); + + var results; + var queryFingerprint = Crc32.objectFingerprint( query ); + if( queryFingerprint == this.previousQueryFingerprint ) { + results = this.previousResults; + } else { + this.previousQueryFingerprint = queryFingerprint; + this.previousResults = results = this._doQuery( query ); + } + + // and finally, hand them to the finding callback + findCallback(results,keywordArgs); + this.onFetchSuccess(); + }, + + /** + * @private + */ + _doQuery: function( /**Object*/ query ) { + + var textFilter = this._compileTextFilter( query.text ); + delete query.text; + + // algorithm pseudocode: + // + // * for each individual facet, get a set of tracks that + // matches its selected values. sort each set by the + // track's unique identifier. + // * while still need to go through all the items in the filtered sets: + // - if all the facets have the same track first in their sorted set: + // add it to the core result set. + // count it in the global counts + // - if all the facets *but one* have the same track first: + // this track will need to be counted in the + // 'leave-out' counts for the odd facet out. count it. + // - shift the lowest-labeled track off of whatever facets have it at the front + + var results = []; // array of items that completely match the query + + // construct the filtered sets (arrays of items) for each of + // our search criteria + var filteredSets = []; + if( textFilter ) { + filteredSets.push( + this._filterDeleted( + array.filter( dojof.values( this.identIndex ), textFilter ) + ).sort( dojo.hitch(this,'_itemSortFunc') ) + ); + filteredSets[0].facetName = 'Contains text'; + } + filteredSets.push.apply( filteredSets, + dojo.map( dojof.keys( query ), function( facetName ) { + var values = query[facetName]; + var items = []; + if( ! this.facetIndexes.byName[facetName] ) { + console.error( "No facet defined with name '"+facetName+"'." ); + throw "No facet defined with name '"+facetName+"', faceted search failed."; + } + dojo.forEach( values, function(value) { + var idx = this.facetIndexes.byName[facetName].byValue[value] || {}; + items.push.apply( items, this._filterDeleted( idx.items || [] ) ); + },this); + items.facetName = facetName; + items.sort( dojo.hitch( this, '_itemSortFunc' )); + return items; + },this) + ); + dojo.forEach( filteredSets, function(s) { + s.myOffset = 0; + s.topItem = function() { return this[this.myOffset]; }; + s.shift = function() { this.myOffset++; }; + }); + + // init counts + var facetMatchCounts = {}; + + if( ! filteredSets.length ) { + results = this._filterDeleted( dojof.values( this.identIndex ) ); + } else { + // calculate how many item records total we need to go through + var leftToProcess = 0; + dojo.forEach( filteredSets, + function(s) { leftToProcess += s.length;} ); + + // do a sort of N-way merge of the filtered sets + while( leftToProcess ) { + + // look at the top of each of our sets, seeing what items + // we have there. group the sets by the identity of their + // topmost item. + var setsByTopIdent = {}, uniqueIdents = [], ident, item; + dojo.forEach(filteredSets, function(set,i) { + item = set.topItem(); + ident = item ? this.getIdentity( item ) : '(at end of set)'; + if( setsByTopIdent[ ident ] ) { + setsByTopIdent[ ident ].push( set ); + } else { + setsByTopIdent[ ident ] = [set]; + uniqueIdents.push( ident ); + } + },this); + if( uniqueIdents.length == 1 ) { + // each of our matched sets has the same item at the + // top. this means it is part of the core result set. + results.push( item ); + } else { + + // ident we are operating on is always the + // lexically-first one that is not the end-of-set + // marker + uniqueIdents.sort(); + var leftOutIndex; + if( uniqueIdents[0] == '(at end of set)' ) { + ident = uniqueIdents[1]; + leftOutIndex = 0; + } else { + ident = uniqueIdents[0]; + leftOutIndex = 1; + } + ident = uniqueIdents[0] == '(at end of set)' ? uniqueIdents[1] : uniqueIdents[0]; + + if( uniqueIdents.length == 2 + && setsByTopIdent[ ident ].length == filteredSets.length - 1 ) { + // all of the matched sets except one has the same + // item on top, and it is the lowest-labeled item + + var leftOutSet = setsByTopIdent[ uniqueIdents[ leftOutIndex ] ][0]; + this._countItem( facetMatchCounts, setsByTopIdent[ident][0].topItem(), leftOutSet.facetName ); + } + } + + dojo.forEach( setsByTopIdent[ ident ], function(s) { s.shift(); leftToProcess--; }); + } + } + + // each of the leave-one-out count sets needs to also have the + // core result set counted in it, and also make a counting set + // for the core result set (used by __other__ facets not + // involved in the query) + dojo.forEach( dojof.keys(facetMatchCounts).concat( ['__other__'] ), function(category) { + dojo.forEach( results, function(item) { + this._countItem( facetMatchCounts, item, category); + },this); + },this); + + // in the case of just one filtered set, the 'leave-one-out' + // count for it is actually the count of all results, so we + // need to make a special little count of that attribute for + // the global result set. + if( filteredSets.length == 1 ) { + dojo.forEach( dojof.values( this.identIndex ), function(item) { + this._countItem( facetMatchCounts, item, filteredSets[0].facetName ); + },this); + } + + this._fetchFacetCounts = facetMatchCounts; + this._fetchCount = results.length; + return results; + }, + + _countItem: function( facetMatchCounts, item, facetName ) { + var facetEntry = facetMatchCounts[facetName]; + if( !facetEntry ) facetEntry = facetMatchCounts[facetName] = {}; + var facets = facetName == '__other__' ? this.facets : [facetName]; + dojo.forEach( facets, function(attrName) { + var value = this.getValue( item, attrName, this._noDataValue ); + var attrEntry = facetEntry[attrName]; + if( !attrEntry ) { + attrEntry = facetEntry[attrName] = {}; + attrEntry[value] = 0; + } + attrEntry[value] = ( attrEntry[value] || 0 ) + 1; + },this); + }, + + onReady: function( scope, func ) { + scope = scope || dojo.global; + func = dojo.hitch( scope, func ); + if( ! this.ready ) { + this.onReadyFuncs.push( func ); + return; + } else { + func(); + } + }, + + /** + * Event hook called once when the store is initialized and has + * an initial set of data loaded. + */ + _onReady: function() { + dojo.forEach( this.onReadyFuncs || [], function(func) { + func.call(); + }); + }, + + /** + * Event hook called after a fetch has been successfully completed + * on this store. + */ + onFetchSuccess: function() { + }, + + /** + * Event hook called when there are new items in the store. + */ + onNew: function( item ) { + }, + /** + * Event hook called when something is deleted from the store. + */ + onDelete: function( item ) { + }, + /** + * Event hook called when one or more items in the store have changed their values. + */ + onSet: function( item, attribute, oldvalue, newvalue ) { + }, + + _filterDeleted: function( items ) { + return array.filter( items, function(i) { + return ! i.DELETED; + }); + }, + + /** + * Compile a text search string into a function that tests whether + * a given piece of text matches that search string. + * @private + */ + _compileTextFilter: function( textString ) { + if( textString === undefined ) + return null; + + // parse out words and quoted words, and convert each into a regexp + var rQuotedWord = /\s*["']([^"']+)["']\s*/g; + var rWord = /(\S+)/g; + var parseWord = function() { + var word = rQuotedWord.exec( textString ) || rWord.exec( textString ); + if( word ) { + word = word[1]; + var lastIndex = Math.max( rQuotedWord.lastIndex, rWord.lastIndex ); + rWord.lastIndex = rQuotedWord.lastIndex = lastIndex; + } + return word; + }; + var wordREs = []; + var currentWord; + while( (currentWord = parseWord()) ) { + // escape regex control chars, and convert glob-like chars to + // their regex equivalents + currentWord = dojo.regexp.escapeString( currentWord, '*?' ) + .replace(/\*/g,'.+') + .replace(/ /g,'\\s+') + .replace(/\?/g,'.'); + wordREs.push( new RegExp(currentWord,'i') ); + } + + // return a function that takes on item and returns true if it + // matches the text filter + return dojo.hitch(this, function(item) { + return dojo.some( this.facets, function(facetName) { + var text = this.getValue( item, facetName ); + return array.every( wordREs, function(re) { return re.test(text); } ); + },this); + }); + }, + + getFeatures: function() { + return { + 'dojo.data.api.Read': true, + 'dojo.data.api.Identity': true, + 'dojo.data.api.Notification': true + }; + }, + close: function() {}, + + getLabel: function(i) { + return this.getValue(i,'key',undefined); + }, + getLabelAttributes: function(i) { + return ['key']; + }, + + // dojo.data.api.Identity support + getIdentityAttributes: function() { + return ['label']; + }, + getIdentity: function(i) { + return this.getValue(i, 'label', undefined); + }, + fetchItemByIdentity: function(id) { + return this.identIndex[id]; + } +}); +dojo.extend( Meta, simpleFetch ); +return Meta; +}); diff --git a/www/JBrowse/TouchScreenSupport.js b/www/JBrowse/TouchScreenSupport.js new file mode 100644 index 00000000..f9f5dae4 --- /dev/null +++ b/www/JBrowse/TouchScreenSupport.js @@ -0,0 +1,271 @@ +define([], function() { + +var startX; +var initialPane; + +/** + * Utility functions for touch-screen device (smartphone and tablet) support. + * + * @lends JBrowse.TouchScreenSupport + */ +var Touch; +Touch = { + + CompareObjPos: function(nodes, touch) { + var samePos = 0, + j= 0, + top = touch.pageY; + + for (var i=0; i < nodes.length; i++) { + samePos = j++; + var position = findPos(nodes[i]); + if(position.top > top) { + break; + } + } + return samePos; + }, + + checkAvatarPosition: function(first) { + var leftPane = document.getElementById("tracksAvail"), + rightPane = document.getElementById("container"); + + if (first.pageX < (leftPane.offsetLeft + leftPane.offsetWidth)) { + return leftPane; + } + else { + return rightPane; + } + }, + + removeTouchEvents: function() { + + startX = null; + + }, + + + touchSimulated: function(event) { + if(event.touches.length <= 1) { + + var touches = event.changedTouches, + first = touches[0], + type1 = "", + type2 = "mouseover", + objAvatar = document.getElementsByClassName("dojoDndAvatar"), + obj = {}, + pane = Touch.checkAvatarPosition(first), + nodes = pane.getElementsByClassName("dojoDndItem"), + element = {}, + simulatedEvent_1 = document.createEvent("MouseEvent"), + simulatedEvent_2 = document.createEvent("MouseEvent"); + + + switch (event.type) { + + case "touchstart": + startX = first.pageX; + type1 = "mousedown"; + break; + case "touchmove": + event.preventDefault(); + type1 = "mousemove"; + break; + default: + return; + } + + simulatedEvent_1.initMouseEvent(type1, true, true, window, 1, first.pageX, first.pageY, first.clientX, first.clientY, + false, false, false, false, 0, null); + + + simulatedEvent_2.initMouseEvent(type2, true, true, window, 1, first.pageX, first.pageY, first.clientX, first.clientY, + false, false, false, false, 0, null); + + switch (event.type) { + case "touchstart": + first.target.dispatchEvent(simulatedEvent_1); + first.target.dispatchEvent(simulatedEvent_2); + initialPane = pane; + break; + case "touchmove": + if(objAvatar.length > 0) { + if (nodes.length > 0) { + element = Touch.CompareObjPos(nodes,first); + obj = nodes[element]; + } + try { + if (initialPane != pane) { + var simulatedEvent_3 = document.createEvent("MouseEvent"); + var type3 = "mouseout"; + simulatedEvent_3.initMouseEvent(type3, true, true, window, 1, + first.pageX, first.pageY, first.clientX, first.clientY, + false, false, false, false, 0, null); + initialPane.dispatchEvent(simulatedEvent_3); + } + obj.dispatchEvent(simulatedEvent_2); + obj.dispatchEvent(simulatedEvent_1); + } + catch(err) + { + //No Elements in the pane + pane.dispatchEvent(simulatedEvent_2); + pane.dispatchEvent(simulatedEvent_1); + } + } + break; + default: + return; + } + } + else { + Touch.removeTouchEvents(); + } + }, + + touchEnd: function(event) { + var touches = event.changedTouches, + first = touches[0], + type1 = "mouseup", + type2 = "mouseover", + objAvatar = document.getElementsByClassName("dojoDndAvatar"), + obj = {}, + pane = Touch.checkAvatarPosition(first), + nodes = pane.getElementsByClassName("dojoDndItem"), + element = {}, + simulatedEvent_1 = document.createEvent("MouseEvent"), + simulatedEvent_2 = document.createEvent("MouseEvent"); + + if (startX !== first.pageX) { + //slide ocurrs + event.preventDefault(); + } + + var test = Touch.findPos(first.target); + + simulatedEvent_1.initMouseEvent(type1, true, true, window, 1, first.pageX, first.pageY, first.clientX, first.clientY, + false, false, false, false, 0, null); + + simulatedEvent_2.initMouseEvent(type2, true, true, window, 1, first.pageX, first.pageY, first.clientX, first.clientY, + false, false, false, false, 0, null); + + if(objAvatar.length > 0) { + if (nodes.length > 0) { + element = CompareObjPos(nodes,first); + obj = nodes[element]; + } + try { + obj.dispatchEvent(simulatedEvent_2); + obj.dispatchEvent(simulatedEvent_1); + } + catch(error) + { + first.target.dispatchEvent(simulatedEvent_2); + pane.dispatchEvent(simulatedEvent_2); + } + } + else { + first.target.dispatchEvent(simulatedEvent_1); + first.target.dispatchEvent(simulatedEvent_2); + } + + Touch.removeTouchEvents(); + }, + + touchHandle: function(event) { + dojo.query(".dojoDndItemAnchor").connect("touchstart", Touch.touchSimulated); + dojo.query(".dojoDndItemAnchor").connect("touchmove", Touch.touchSimulated); + dojo.query(".dojoDndItemAnchor").connect("touchend", Touch.touchEnd); + dojo.query(".dojoDndItemAnchor").connect("click" , function(){void(0);}); + + if(event.touches.length <= 1) { + + + var touches = event.changedTouches, + first = touches[0], + type = ""; + + + + switch(event.type) + { + case "touchstart": + startX = first.pageX; + type = "mousedown"; + break; + + case "touchmove": + event.preventDefault(); + type = "mousemove"; + break; + + case "touchend": + if (startX !== first.pageX) { + //slide ocurrs + event.preventDefault(); + } + type = "mouseup"; + break; + + + default: + return; + } + + + var simulatedEvent = document.createEvent("MouseEvent"); + + simulatedEvent.initMouseEvent(type, true, true, window, 1, first.screenX, first.screenY, first.clientX, first.clientY, + false, false, false, false, 0/*left*/, null); + + first.target.dispatchEvent(simulatedEvent); + + } + else { + Touch.removeTouchEvents(); + } + }, + + touchinit: function() { + dojo.query(".dojoDndItem").connect("touchstart", Touch.touchSimulated); + dojo.query(".dojoDndItem").connect("touchmove", Touch.touchSimulated); + dojo.query(".dojoDndItem").connect("touchend", Touch.touchEnd); + + dojo.query(".locationThumb").connect("touchstart", Touch.touchHandle); + dojo.query(".locationThumb").connect("touchmove", Touch.touchHandle); + dojo.query(".locationThumb").connect("touchend", Touch.touchHandle); + + dojo.query(".dojoDndItem").connect("click" , function(){void(0);}); + + dojo.query(".dojoDndTarget").connect("touchstart", Touch.touchHandle); + dojo.query(".dojoDndTarget").connect("touchmove", Touch.touchHandle); + dojo.query(".dojoDndTarget").connect("touchend", Touch.touchHandle); + + dojo.query(".dijitSplitter").connect("touchstart", Touch.touchHandle); + dojo.query(".dijitSplitter").connect("touchmove", Touch.touchHandle); + dojo.query(".dijitSplitter").connect("touchend", Touch.touchHandle); + }, + + loadTouch: function() { + Touch.touchinit(); + document.documentElement.style.webkitTouchCallout = "none"; + }, + + findPos: function(obj) { + var curtop = 0, + objP = {}; + + if (obj.offsetParent) { + do { + curtop += obj.offsetTop; + } while ((obj = obj.offsetParent)); + } + + objP.top = curtop; + + return objP; + } +}; + +return Touch; +}); \ No newline at end of file diff --git a/www/JBrowse/Util.js b/www/JBrowse/Util.js new file mode 100644 index 00000000..17e4382c --- /dev/null +++ b/www/JBrowse/Util.js @@ -0,0 +1,522 @@ +// MISC +define( [ 'dojo/_base/array', + 'dojox/lang/functional/object', + 'dojox/lang/functional/fold' + ], function( array ) { +var Util; +Util = { + dojof: dojox.lang.functional, + is_ie: navigator.appVersion.indexOf('MSIE') >= 0, + is_ie6: navigator.appVersion.indexOf('MSIE 6') >= 0, + addCommas: function(nStr) { + nStr += ''; + var x = nStr.split('.'); + var x1 = x[0]; + var x2 = x.length > 1 ? '.' + x[1] : ''; + var rgx = /(\d+)(\d{3})/; + while (rgx.test(x1)) { + x1 = x1.replace(rgx, '$1' + ',' + '$2'); + } + return x1 + x2; + }, + + commifyNumber: function() { + return this.addCommas.apply( this, arguments ); + }, + + + /** + * Fast, simple class-maker, used for classes that need speed more + * than they need dojo.declare's nice features. + */ + fastDeclare: function( members, className ) { + var constructor = members.constructor; + var fastDeclareClass = function() { + constructor.apply( this, arguments ); + }; + dojo.mixin( fastDeclareClass.prototype, members ); + return fastDeclareClass; + }, + + isRightButton: function(e) { + if (!e) + var e = window.event; + if (e.which) + return e.which == 3; + else if (e.button) + return e.button == 2; + else + return false; + }, + + getViewportWidth: function() { + var width = 0; + if( document.documentElement && document.documentElement.clientWidth ) { + width = document.documentElement.clientWidth; + } + else if( document.body && document.body.clientWidth ) { + width = document.body.clientWidth; + } + else if( window.innerWidth ) { + width = window.innerWidth - 18; + } + return width; + }, + + getViewportHeight: function() { + var height = 0; + if( document.documentElement && document.documentElement.clientHeight ) { + height = document.documentElement.clientHeight; + } + else if( document.body && document.body.clientHeight ) { + height = document.body.clientHeight; + } + else if( window.innerHeight ) { + height = window.innerHeight - 18; + } + return height; + }, + + findNearest: function(numArray, num) { + var minIndex = 0; + var min = Math.abs(num - numArray[0]); + for (var i = 1; i < numArray.length; i++) { + if (Math.abs(num - numArray[i]) < min) { + minIndex = i; + min = Math.abs(num - numArray[i]); + } + } + return minIndex; + }, + + /** + * replace variables in a template string with values + * @param template String with variable names in curly brackets + * e.g., "http://foo/{bar}?arg={baz} + * @param fillWith object with attribute-value mappings + * e.g., {'bar': 'someurl', 'baz': 'valueforbaz'} + * @returns the template string with variables in fillWith replaced + * e.g., 'htp://foo/someurl?arg=valueforbaz' + */ + fillTemplate: function(template, fillWith) { + return template.replace(/\{([\w\s]+)\}/g, + function(match, group) { + var f = fillWith[group]; + if (f !== undefined) { + if( typeof f == 'function' ) + return f(); + else + return f; + } else { + return "{" + group + "}"; + } + }); + }, + + /** + * function to load a specified resource only once + * @param {Object} dojoXhrArgs object containing arguments for dojo.xhrGet, + * like url and handleAs + * @param {Object} stateObj object that stores the state of the load + * @param {Function} successCallback function to call on a successful load + * @param {Function} errorCallback function to call on an unsuccessful load + */ + maybeLoad: function ( dojoXhrArgs, stateObj, successCallback, errorCallback) { + if (stateObj.state) { + if ("loaded" == stateObj.state) { + successCallback(stateObj.data); + } else if ("error" == stateObj.state) { + errorCallback(); + } else if ("loading" == stateObj.state) { + stateObj.successCallbacks.push(successCallback); + if (errorCallback) stateObj.errorCallbacks.push(errorCallback); + } + } else { + stateObj.state = "loading"; + stateObj.successCallbacks = [successCallback]; + stateObj.errorCallbacks = [errorCallback]; + + var args = dojo.clone( dojoXhrArgs ); + args.load = function(o) { + stateObj.state = "loaded"; + stateObj.data = o; + var cbs = stateObj.successCallbacks; + for (var c = 0; c < cbs.length; c++) cbs[c](o); + }; + args.error = function(error) { + console.error(''+error); + stateObj.state = "error"; + var cbs = stateObj.errorCallbacks; + for (var c = 0; c < cbs.length; c++) cbs[c](); + }; + + dojo.xhrGet( args ); + } + }, + + /** + * updates a with values from b, recursively + */ + deepUpdate: function(a, b) { + for (var prop in b) { + if ((prop in a) + && ("object" == typeof b[prop]) + && ("object" == typeof a[prop]) ) { + Util.deepUpdate(a[prop], b[prop]); + } else if( typeof a[prop] == 'undefined' || typeof b[prop] != 'undefined' ){ + a[prop] = b[prop]; + } + } + return a; + }, + + humanReadableNumber: function( num ) { + num = parseInt(num); + var suffix = ''; + if( num >= 1e12 ) { + num /= 1e12; + suffix = 'T'; + } else if( num >= 1e9 ) { + num /= 1e9; + suffix = 'G'; + } else if( num >= 1e6 ) { + num /= 1e6; + suffix = 'M'; + } else if( num >= 1000 ) { + num /= 1000; + suffix = 'K'; + } + + return (num.toFixed(2)+' '+suffix).replace(/0+ /,' ').replace(/\. /,' '); + }, + + // from http://bugs.dojotoolkit.org/ticket/5794 + resolveUrl: function(baseUrl, relativeUrl) { + // summary: + // This takes a base url and a relative url and resolves the target url. + // For example: + // resolveUrl("http://www.domain.com/path1/path2","../path3") ->"http://www.domain.com/path1/path3" + // + if (relativeUrl.match(/\w+:\/\//)) + return relativeUrl; + if (relativeUrl.charAt(0)=='/') { + baseUrl = baseUrl.match(/.*\/\/[^\/]*/); + return (baseUrl ? baseUrl[0] : '') + relativeUrl; + } + // remove the query string from the base, if any + baseUrl = baseUrl.replace(/\?.*$/,''); + //TODO: handle protocol relative urls: ://www.domain.com + baseUrl = baseUrl.substring(0,baseUrl.length - baseUrl.match(/[^\/]*$/)[0].length);// clean off the trailing path + if (relativeUrl == '.') + return baseUrl; + while (baseUrl && relativeUrl.substring(0,3) == '../') { + baseUrl = baseUrl.substring(0,baseUrl.length - baseUrl.match(/[^\/]*\/$/)[0].length); + relativeUrl = relativeUrl.substring(3); + } + return baseUrl + relativeUrl; + }, + + parseLocString: function( locstring ) { + var inloc = locstring; + if( typeof locstring != 'string' ) + return null; + + locstring = dojo.trim( locstring ); + + // any extra stuff in parens? + var extra = (locstring.match(/\(([^\)]+)\)$/)||[])[1]; + + // parses a number from a locstring that's a coordinate, and + // converts it from 1-based to interbase coordinates + var parseCoord = function( coord ) { + coord = (coord+'').replace(/\D/g,''); + var num = parseInt( coord, 10 ); + return typeof num == 'number' && !isNaN(num) ? num : null; + }; + + var location = {}; + var tokens; + + if( locstring.indexOf(':') != -1 ) { + tokens = locstring.split(':',2); + location.ref = dojo.trim( tokens[0] ); + locstring = tokens[1]; + } + + tokens = locstring.match( /^\s*([\d,]+)\s*\.\.+\s*([\d,]+)/ ); + if( tokens ) { // range of two numbers? + location.start = parseCoord( tokens[1] )-1; + location.end = parseCoord( tokens[2] ); + + // reverse the numbers if necessary + if( location.start > location.end ) { + var t = location.start+1; + location.start = location.end - 1; + location.end = t; + } + } + else { // one number? + tokens = locstring.match( /^\s*([\d,]+)/ ); + if( tokens ) { + location.end = location.start = parseCoord( tokens[1] )-1; + } + else // got nothin + return null; + } + + if( extra ) + location.extra = extra; + + return location; + }, + + basename: function( str, suffixList ) { + if( ! str || ! str.match ) + return undefined; + var m = str.match( /[\/\\]([^\/\\]+)[\/\/\/]*$/ ); + var bn = m ? m[1] || undefined : str; + if( bn && suffixList ) { + if( !( suffixList instanceof Array ) ) + suffixList = [ suffixList ]; + suffixList = array.map( suffixList, function( s ) { + return s.replace( /([\.\?\+])/g, '\\$1' ); + }); + bn = bn.replace( new RegExp( suffixList.join('|')+'$', 'i' ), '' ); + } + return bn; + }, + + assembleLocString: function( loc_in ) { + var s = '', + types = { start: 'number', end: 'number', ref: 'string', strand: 'number' }, + location = {} + ; + + // filter the incoming loc_in to only pay attention to slots that we + // know how to handle + for( var slot in types ) { + if( types[slot] == typeof loc_in[slot] + && (types[slot] != 'number' || !isNaN(loc_in[slot])) //filter any NaNs + ) { + location[slot] = loc_in[slot]; + } + } + + //finally assemble our string + if( 'ref' in location ) { + s += location.ref; + if( location.start || location.end ) + s += ':'; + } + if( 'start' in location ) { + s += (Math.round(location.start)+1).toFixed(0).toLocaleString(); + if( 'end' in location ) + s+= '..'; + } + if( 'end' in location ) + s += Math.round(location.end).toFixed(0).toLocaleString(); + + if( 'strand' in location ) + s += ({'1':' (+ strand)', '-1': ' (- strand)', '0': ' (no strand)' }[ location.strand || '' ]) || ''; + + // add on any extra stuff if it was passed in + if( 'extra' in loc_in ) + s += loc_in.extra; + + return s; + }, + + /** + * Complement a sequence (without reversing). + * @param {String} seqString sequence + * @returns {String} complemented sequence + */ + complement: (function() { + var compl_rx = /[ACGT]/gi; + + // from bioperl: tr/acgtrymkswhbvdnxACGTRYMKSWHBVDNX/tgcayrkmswdvbhnxTGCAYRKMSWDVBHNX/ + // generated with: + // perl -MJSON -E '@l = split "","acgtrymkswhbvdnxACGTRYMKSWHBVDNX"; print to_json({ map { my $in = $_; tr/acgtrymkswhbvdnxACGTRYMKSWHBVDNX/tgcayrkmswdvbhnxTGCAYRKMSWDVBHNX/; $in => $_ } @l})' + var compl_tbl = {"S":"S","w":"w","T":"A","r":"y","a":"t","N":"N","K":"M","x":"x","d":"h","Y":"R","V":"B","y":"r","M":"K","h":"d","k":"m","C":"G","g":"c","t":"a","A":"T","n":"n","W":"W","X":"X","m":"k","v":"b","B":"V","s":"s","H":"D","c":"g","D":"H","b":"v","R":"Y","G":"C"}; + + var nbsp = String.fromCharCode(160); + var compl_func = function(m) { return compl_tbl[m] || nbsp; }; + return function( seqString ) { + return seqString.replace( compl_rx, compl_func ); + }; + })(), + + /** + * Reverse-complement a sequence string. + * @param {String} seqString + * @returns {String} reverse-complemented sequence + */ + revcom: function( seqString ) { + return Util.complement( seqString ).split('').reverse().join(''); + }, + + assembleLocStringWithLength: function( def ) { + var locString = Util.assembleLocString( def ); + var length = def.length || def.end-def.start+1; + return locString + ' ('+Util.humanReadableNumber( length )+'b)'; + }, + + // given a possible reference sequence name and an object as { 'foo': + // , ... }, try to match that reference sequence name + // against the actual name of one of the reference sequences. returns + // the reference sequence record, or null + // if none matched. + matchRefSeqName: function( name, refseqs ) { + for( var ref in refseqs ) { + if( ! refseqs.hasOwnProperty(ref) ) + continue; + + var ucname = name.toUpperCase(); + var ucref = ref.toUpperCase(); + + if( ucname == ucref + || "CHR" + ucname == ucref + || ucname == "CHR" + ucref + ) { + return refseqs[ref]; + } + } + return null; + }, + + /** + * Wrap a handler function to be called 1ms later in a window timeout. + * This will usually give a better stack trace for figuring out where + * errors are happening. + */ + debugHandler: function( context, func ) { + return function() { + var args = arguments; + window.setTimeout( function() { + var f = func; + if( typeof f == 'string' ) + f = context[f]; + f.apply(context,args); + }, 1); + }; + }, + + ucFirst: function(str) { + if( typeof str != 'string') return undefined; + return str.charAt(0).toUpperCase() + str.slice(1); + }, + + /** + * Uniqify an array. + * @param stuff {Array} array of stuff + * @param normalizer {Function} optional function to be called on each + * element. by default, just compares by stringification + */ + uniq: function( stuff, normalizer ) { + normalizer = normalizer || function(t) { + return ''+t; + }; + var result = [], + seen = {}; + dojo.forEach( stuff, function(thing) { + var norm = normalizer(thing); + if( !seen[ normalizer(thing) ] ) + result.push( thing ); + seen[norm] = true; + }); + return result; + }, + + // back-compatible way to remove properties/attributes from DOM + // nodes. IE 7 and older do not support the `delete` operator on + // DOM nodes. + removeAttribute: function( domNode, attrName ) { + try { delete domNode[attrName]; } + catch(e) { + if( domNode.removeAttribute ) + domNode.removeAttribute( attrName ); + } + } +}; + + return Util; +}); + +if (!Array.prototype.map) { + Array.prototype.map = function(fun /*, thisp */) + { + "use strict"; + + if (this === void 0 || this === null) + throw new TypeError(); + + var t = Object(this); + var len = t.length >>> 0; + if (typeof fun !== "function") + throw new TypeError(); + + var res = new Array(len); + var thisp = arguments[1]; + for (var i = 0; i < len; i++) + { + if (i in t) + res[i] = fun.call(thisp, t[i], i, t); + } + + return res; + }; +} + +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function(searchElement /*, fromIndex */) + { + "use strict"; + + if (this === void 0 || this === null) + throw new TypeError(); + + var t = Object(this); + var len = t.length >>> 0; + if (len === 0) + return -1; + + var n = 0; + if (arguments.length > 0) + { + n = Number(arguments[1]); + if (n !== n) // shortcut for verifying if it's NaN + n = 0; + else if (n !== 0 && n !== (1 / 0) && n !== -(1 / 0)) + n = (n > 0 || -1) * Math.floor(Math.abs(n)); + } + + if (n >= len) + return -1; + + var k = n >= 0 + ? n + : Math.max(len - Math.abs(n), 0); + + for (; k < len; k++) + { + if (k in t && t[k] === searchElement) + return k; + } + return -1; + }; +} + + + +/* + +Copyright (c) 2007-2010 The Evolutionary Software Foundation + +Created by Mitchell Skinner + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +*/ diff --git a/www/JBrowse/Util/FastPromise.js b/www/JBrowse/Util/FastPromise.js new file mode 100644 index 00000000..19e3d521 --- /dev/null +++ b/www/JBrowse/Util/FastPromise.js @@ -0,0 +1,32 @@ +/** + * Very minimal and fast implementation of a promise, used in + * performance-critical code. Dojo Deferred is too heavy for some + * uses. + */ + +define([ + ], + function( + ) { + +var fastpromise = function() { + this.callbacks = []; +}; + +fastpromise.prototype.then = function( callback ) { + if( 'value' in this ) + callback( this.value ); + else + this.callbacks.push( callback ); +}; + +fastpromise.prototype.resolve = function( value ) { + this.value = value; + var c = this.callbacks; + delete this.callbacks; + for( var i = 0; i 1; + //this.zoomLoc = zoomLoc; + this.center = + (toScroll.getX() + (toScroll.elem.clientWidth * zoomLoc)) + / toScroll.scrollContainer.clientWidth; + + // initialX and initialLeft can differ when we're scrolling + // using scrollTop and scrollLeft + this.initialX = this.subject.getX(); + this.initialLeft = parseInt(this.toZoom.style.left); +}; + +Zoomer.prototype = new Animation(); + +Zoomer.prototype.step = function(pos) { + var zoomFraction = this.zoomingIn ? pos : 1 - pos; + var newWidth = + ((zoomFraction * zoomFraction) * this.distance) + this.width0; + var newLeft = (this.center * this.initialWidth) - (this.center * newWidth); + this.toZoom.style.width = newWidth + "px"; + this.toZoom.style.left = (this.initialLeft + newLeft) + "px"; + var forceRedraw = this.toZoom.offsetTop; + + if( this.subject.updateStaticElements ) + this.subject.updateStaticElements({ x: this.initialX - newLeft }); +}; + +return Zoomer; +}); \ No newline at end of file diff --git a/www/JBrowse/View/ConfirmDialog.js b/www/JBrowse/View/ConfirmDialog.js new file mode 100644 index 00000000..de2215df --- /dev/null +++ b/www/JBrowse/View/ConfirmDialog.js @@ -0,0 +1,57 @@ +define([ + 'dojo/_base/declare', + 'dijit/focus', + 'JBrowse/View/Dialog/WithActionBar', + 'dojo/on', + 'dijit/form/Button' + ], + function( declare, focus, ActionBarDialog, on, dijitButton ) { + +return declare( ActionBarDialog, + + /** + * Dijit Dialog subclass that pops up a yes/no confirmation + * more pleasant for use as an information popup. + * @lends JBrowse.View.ConfirmDialog + */ +{ + autofocus: false, + + constructor: function( args ) { + this.message = args.message || 'Do you really want to do this?'; + this.confirmLabel = args.confirmLabel || 'Yes'; + this.denyLabel = args.denyLabel || 'No'; + }, + + _fillActionBar: function( actionBar ) { + var thisB = this; + new dijitButton({ className: 'yes', + label: this.confirmLabel, + onClick: function() { + thisB.callback( true ); + thisB.hide(); + } + }) + .placeAt( actionBar); + new dijitButton({ className: 'no', + label: this.denyLabel, + onClick: function() { + thisB.callback( false ); + thisB.hide(); + } + }) + .placeAt( actionBar); + }, + + show: function( callback ) { + this.callback = callback || function() {}; + + this.set('content', this.message ); + + this.inherited( arguments ); + + focus.focus( this.closeButtonNode ); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/DetailsMixin.js b/www/JBrowse/View/DetailsMixin.js new file mode 100644 index 00000000..de06abbc --- /dev/null +++ b/www/JBrowse/View/DetailsMixin.js @@ -0,0 +1,211 @@ +/** + * Mixin that provides generic functions for displaying nested data. + */ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'dojo/query', + 'dojo/dom-construct', + 'dojo/dom-class', + 'dojo/store/Memory', + 'dgrid/OnDemandGrid', + 'dgrid/extensions/DijitRegistry', + 'JBrowse/Util' + ], + function( + declare, + lang, + array, + query, + domConstruct, + domClass, + MemoryStore, + DGrid, + DGridDijitRegistry, + Util + ) { + +// make a DGrid that registers itself as a dijit widget +var Grid = declare([DGrid,DGridDijitRegistry]); + +return declare( null, { + + renderDetailField: function( parentElement, title, val, class_ ) { + if( val === null || val === undefined ) + return ''; + + // if this object has a 'fmtDetailFooField' function, delegate to that + var fieldSpecificFormatter; + if(( fieldSpecificFormatter = this['fmtDetail'+Util.ucFirst(title)+'Field'] )) + return fieldSpecificFormatter.apply( this, arguments ); + + // otherwise, use default formatting + + class_ = class_ || title.replace(/\W/g,'_').toLowerCase(); + + // special case for values that include metadata about their + // meaning, which are formed like { values: [], meta: + // {description: }. break it out, putting the meta description in a `title` + // attr on the field name so that it shows on mouseover, and + // using the values as the new field value. + var fieldMeta; + if( typeof val == 'object' && ('values' in val) ) { + fieldMeta = (val.meta||{}).description; + // join the description if it is an array + if( lang.isArray( fieldMeta ) ) + fieldMeta = fieldMeta.join(', '); + + val = val.values; + } + + var titleAttr = fieldMeta ? ' title="'+fieldMeta+'"' : ''; + var fieldContainer = domConstruct.create( + 'div', + { className: 'field_container', + innerHTML: '

'+title+'

' + }, parentElement ); + var valueContainer = domConstruct.create( + 'div', + { className: 'value_container ' + + class_ + }, fieldContainer ); + + var count = this.renderDetailValue( valueContainer, title, val, class_); + if( typeof count == 'number' && count > 4 ) { + query( 'h2', fieldContainer )[0].innerHTML = title + ' ('+count+')'; + } + + return fieldContainer; + }, + + renderDetailValue: function( parent, title, val, class_ ) { + var thisB = this; + + // if this object has a 'fmtDetailFooValue' function, delegate to that + var fieldSpecificFormatter; + if(( fieldSpecificFormatter = this['fmtDetail'+Util.ucFirst(title)+'Value'] )) + return fieldSpecificFormatter.apply( this, arguments ); + + // otherwise, use default formatting + + var valType = typeof val; + if( typeof val.toHTML == 'function' ) + val = val.toHTML(); + if( valType == 'boolean' ) + val = val ? 'yes' : 'no'; + else if( valType == 'undefined' || val === null ) + return 0; + else if( lang.isArray( val ) ) { + var vals = array.map( val, function(v) { + return this.renderDetailValue( parent, title, v, class_ ); + }, this ); + if( vals.length > 10 ) + domClass.add( parent, 'big' ); + return vals.length; + } else if( valType == 'object' ) { + var keys = Util.dojof.keys( val ).sort(); + var count = keys.length; + if( count > 5 ) { + this.renderDetailValueGrid( + parent, + title, + // iterator + function() { + if( ! keys.length ) + return null; + var k = keys.shift(); + var value = val[k]; + + var item = { id: k }; + + if( typeof value == 'object' ) { + for( var field in value ) { + item[field] = thisB._valToString( value[field] ); + } + } + else { + item.value = value; + } + + return item; + }, + // descriptions object + (function() { + if( ! keys.length ) + return {}; + + var subValue = val[keys[0]]; + var descriptions = {}; + for( var k in subValue ) { + descriptions[k] = subValue[k].meta && subValue[k].meta.description || null; + } + return descriptions; + })() + ); + return count; + } + else { + array.forEach( keys, function( k ) { + return this.renderDetailField( parent, k, val[k], class_ ); + }, this ); + return keys.length; + } + } + + domConstruct.create('div', { className: 'value '+class_, innerHTML: val }, parent ); + return 1; + }, + + renderDetailValueGrid: function( parent, title, iterator, descriptions ) { + var thisB = this; + var rows = []; + var item; + while(( item = iterator() )) + rows.push( item ); + + if( ! rows.length ) + return document.createElement('span'); + + var columns = []; + for( var field in rows[0] ) { + (function(field) { + var column = { + label: { id: 'Name'}[field] || Util.ucFirst( field ), + field: field + }; + column.renderHeaderCell = function( contentNode ) { + if( descriptions[field] ) + contentNode.title = descriptions[field]; + contentNode.appendChild( document.createTextNode( column.label || column.field)); + }; + columns.push( column ); + })(field); + } + + // create the grid + parent.style.overflow = 'hidden'; + parent.style.width = '90%'; + var grid = new Grid({ + columns: columns, + store: new MemoryStore({ data: rows }) + }, parent ); + + return parent; + }, + + _valToString: function( val ) { + if( lang.isArray( val ) ) { + return array.map( val, lang.hitch( this,'_valToString') ).join(' '); + } + else if( typeof val == 'object' ) { + if( 'values' in val ) + return this._valToString( val.values ); + else + return JSON.stringify( val ); + } + return ''+val; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Dialog/QuickHelp.js b/www/JBrowse/View/Dialog/QuickHelp.js new file mode 100644 index 00000000..99daec17 --- /dev/null +++ b/www/JBrowse/View/Dialog/QuickHelp.js @@ -0,0 +1,78 @@ +define( [ + 'dojo/_base/declare', + 'JBrowse/View/InfoDialog' + ], + function( + declare, + InfoDialog + ) { +return declare( InfoDialog, { + + title: "JBrowse Help", + + constructor: function(args) { + this.browser = args.browser; + this.defaultContent = this._makeDefaultContent(); + + if( ! args.content && ! args.href ) { + // make a div containing our help text + this.content = this.defaultContent; + } + }, + + _makeDefaultContent: function() { + return '' + + '
' + + '
' + + + '
' + + '
Moving
' + + '
    ' + + '
  • Move the view by clicking and dragging in the track area, or by clicking or in the navigation bar, or by pressing the left and right arrow keys.
  • ' + + '
  • Center the view at a point by clicking on either the track scale bar or overview bar, or by shift-clicking in the track area.
  • ' + + '
' + + '
Zooming
' + + '
    ' + + '
  • Zoom in and out by clicking or in the navigation bar, or by pressing the up and down arrow keys while holding down "shift".
  • ' + + '
  • Select a region and zoom to it ("rubber-band" zoom) by clicking and dragging in the overview or track scale bar, or shift-clicking and dragging in the track area.
  • ' + + '
' + + '
' + + '
Showing Tracks
' + + '
  • Turn a track on by dragging its track label from the "Available Tracks" area into the genome area, or double-clicking it.
  • ' + + '
  • Turn a track off by dragging its track label from the genome area back into the "Available Tracks" area.
  • ' + + '
' + + '
' + + '
' + + '
' + + + '
' + + '
' + + '
Searching
' + + '
    ' + + '
  • Jump to a feature or reference sequence by typing its name in the location box and pressing Enter.
  • ' + + '
  • Jump to a specific region by typing the region into the location box as: ref:start..end.
  • ' + + '
' + + '
' + + '
Example Searches
' + + '
' + + '
' + + '
uc0031k.2
searches for the feature named uc0031k.2.
' + + '
chr4
jumps to chromosome 4
' + + '
chr4:79,500,000..80,000,000
jumps the region on chromosome 4 between 79.5Mb and 80Mb.
' + + '
5678
centers the display at base 5,678 on the current sequence
' + + '
' + + '
' + + '
JBrowse Documentation
' + + '
' + + '
' + + '
' + + '
' + + '
' + ; + } +}); +}); diff --git a/www/JBrowse/View/Dialog/SetHighlight.js b/www/JBrowse/View/Dialog/SetHighlight.js new file mode 100644 index 00000000..0467540d --- /dev/null +++ b/www/JBrowse/View/Dialog/SetHighlight.js @@ -0,0 +1,83 @@ +define([ + 'dojo/_base/declare', + 'dojo/dom-construct', + 'dijit/focus', + 'dijit/form/TextBox', + 'JBrowse/View/Dialog/WithActionBar', + 'dojo/on', + 'dijit/form/Button', + 'JBrowse/Model/Location' + ], + function( declare, dom, focus, dijitTextBox, ActionBarDialog, on, Button, Location ) { + + +return declare( ActionBarDialog, + + /** + * Dijit Dialog subclass that pops up prompt for the user to + * manually set a new highlight. + * @lends JBrowse.View.InfoDialog + */ +{ + autofocus: false, + title: 'Set highlight', + + constructor: function( args ) { + this.browser = args.browser; + this.setCallback = args.setCallback || function() {}; + this.cancelCallback = args.cancelCallback || function() {}; + }, + + _fillActionBar: function( actionBar ) { + var thisB = this; + new Button({ iconClass: 'dijitIconDelete', label: 'Cancel', + onClick: function() { + thisB.cancelCallback && thisB.cancelCallback(); + thisB.hide(); + } + }) + .placeAt( actionBar ); + new Button({ iconClass: 'dijitIconFilter', + label: 'Highlight', + onClick:function() { + thisB.setCallback && thisB.setCallback( thisB.getLocation() ); + thisB.hide(); + } + }) + .placeAt( actionBar ); + }, + + show: function( callback ) { + var thisB = this; + + dojo.addClass( this.domNode, 'setHighlightDialog' ); + + var visibleLocation = this.browser.view.visibleRegionLocString(); + if( visibleLocation ) + visibleLocation += ' (current view)'; + + this.highlightInput = new dijitTextBox({ + id: 'newhighlight_locstring', + value: (this.browser.getHighlight()||'').toString() || visibleLocation || '', + placeHolder: visibleLocation || 'ctgA:1234..5678' + }); + + this.set('content', [ + dom.create('label', { "for": 'newhighlight_locstring', innerHTML: 'Location' } ), + this.highlightInput.domNode + ] ); + + this.inherited( arguments ); + }, + + getLocation: function() { + // have to use onChange to get the value of the text box to work around a bug in dijit + return new Location( this.highlightInput.get('value') ); + }, + + hide: function() { + this.inherited(arguments); + window.setTimeout( dojo.hitch( this, 'destroyRecursive' ), 500 ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Dialog/WithActionBar.js b/www/JBrowse/View/Dialog/WithActionBar.js new file mode 100644 index 00000000..bc10426e --- /dev/null +++ b/www/JBrowse/View/Dialog/WithActionBar.js @@ -0,0 +1,38 @@ +/** + * A dialog with an action bar at the bottom for buttons. + */ +define([ + 'dojo/_base/declare', + 'dojo/dom-geometry', + 'dijit/Dialog' + ], + function( declare, domGeom, dijitDialog ) { + +return declare( dijitDialog, +{ + constructor: function() { + dojo.connect( this, 'onLoad', this, '_addActionBar' ); + }, + + _addActionBar: function() { + var that = this; + if( this.containerNode && ! this.actionBar ) { + this.actionBar = dojo.create( 'div', { className: 'infoDialogActionBar dijitDialogPaneActionBar' }); + + this._fillActionBar( this.actionBar ); + this.containerNode.appendChild( this.actionBar ); + } + }, + + _fillActionBar: function( actionBar ) { + }, + + show: function( callback ) { + this._addActionBar(); + this.inherited( arguments ); + var titleDims = domGeom.position( this.titleBar ); + this.domNode.style.width = titleDims.w + 'px'; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Export.js b/www/JBrowse/View/Export.js new file mode 100644 index 00000000..cf5fc408 --- /dev/null +++ b/www/JBrowse/View/Export.js @@ -0,0 +1,51 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array' + ], + function( declare, lang, array ) { + +return declare( null, +{ + /** + * Data export driver base class. + * @constructs + */ + constructor: function( args ) { + args = args || {}; + this.printFunc = args.print || function( line ) { this.output += line; }; + this.refSeq = args.refSeq; + this.output = ''; + this.track = args.track; + this.store = args.store; + }, + + // will need to override this if you're not exporting regular features + exportRegion: function( region, callback ) { + var output = ''; + this.store.getFeatures( region, + dojo.hitch( this, 'writeFeature' ), + dojo.hitch(this,function () { + callback( this.output ); + }), + dojo.hitch( this, function( error ) { console.error(error); } ) + ); + }, + + print: function( l ) { + if( lang.isArray( l ) ) { + array.forEach( l, this.printFunc, this ); + } else { + this.printFunc( l ); + } + }, + + /** + * Write the feature to the GFF3 under construction. + * @returns nothing + */ + writeFeature: function(feature) { + this.print( this.formatFeature(feature) ); + } +} +); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Export/BED.js b/www/JBrowse/View/Export/BED.js new file mode 100644 index 00000000..8b753670 --- /dev/null +++ b/www/JBrowse/View/Export/BED.js @@ -0,0 +1,83 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Export' + ], + function( declare, array, ExportBase ) { + +return declare( ExportBase, + + /** + * @lends JBrowse.View.Export.BED.prototype + */ +{ + + /** + * Data export driver for BED format. + * @constructs + */ + constructor: function( args ) { + this._printHeader(); + }, + + /** + * print the BED track definition line + * @private + */ + _printHeader: function() { + // print the BED header + this.print( 'track' ); + if( this.track ) { + if( this.track.name ) + this.print(' name="'+this.track.name+'"'); + var metadata = this.track.getMetadata(); + if( metadata.key ) + this.print(' description="'+metadata.key+'"'); + } + this.print(' useScore=0'); + this.print("\n"); + }, + + bed_field_names: [ + 'seq_id', + 'start', + 'end', + 'name', + 'score', + 'strand', + 'thickStart', + 'thickEnd', + 'itemRgb', + 'blockCount', + 'blockSizes', + 'blockStarts' + ], + + /** + * Format a feature into a string. + * @param {Object} feature feature object (like those returned from JBrowse/Store/SeqFeature/*) + * @returns {String} BED string representation of the feature + */ + formatFeature: function( feature ) { + var fields = array.map( + [ feature.get('seq_id') || this.refSeq.name ] + .concat( dojo.map( this.bed_field_names.slice(1,11), function(field) { + return feature.get( field ); + },this) + ), + function( data ) { + var t = typeof data; + if( t == 'string' || t == 'number' ) + return data; + return ''; + }, + this + ); + + // normalize the strand field + fields[5] = { '1': '+', '-1': '-', '0': '+' }[ fields[5] ] || fields[5]; + return fields.join("\t")+"\n"; + } + +}); + +}); diff --git a/www/JBrowse/View/Export/FASTA.js b/www/JBrowse/View/Export/FASTA.js new file mode 100644 index 00000000..551e3bea --- /dev/null +++ b/www/JBrowse/View/Export/FASTA.js @@ -0,0 +1,42 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Export', + 'JBrowse/Util' + ], + function( declare, array, ExportBase, Util ) { + +return declare( ExportBase, + + /** + * @lends JBrowse.View.Export.FASTA.prototype + */ +{ + + /** + * Data export driver for FASTA format. + * @constructs + */ + constructor: function( args ) { + }, + + // will need to override this if you're not exporting regular features + exportRegion: function( region, callback ) { + this.store.getFeatures( region, + dojo.hitch( this,function ( f ) { + callback( this._formatFASTA( region, f ) ); + })); + }, + + _formatFASTA: function( region, f ) { + return '>' + this.refSeq.name + +' '+Util.assembleLocString(region) + "\n" + + this._wrap( f.get('seq'), 78 ); + }, + + _wrap: function( string, length ) { + length = length || 78; + return string.replace( new RegExp('(.{'+length+'})','g'), "$1\n" ); + } +}); +}); + diff --git a/www/JBrowse/View/Export/GFF3.js b/www/JBrowse/View/Export/GFF3.js new file mode 100644 index 00000000..a722f400 --- /dev/null +++ b/www/JBrowse/View/Export/GFF3.js @@ -0,0 +1,220 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'JBrowse/View/Export' + ], + function( declare, lang, array, ExportBase ) { + +return declare( ExportBase, + /** + * @lends JBrowse.View.Export.GFF3.prototype + */ +{ + + /** + * Data export driver for GFF3 format. + * @constructs + */ + constructor: function( args ) { + this._idCounter = 0; + + this.print( "##gff-version 3\n"); + if( this.refSeq ) + this.print( "##sequence-region "+this.refSeq.name+" "+(this.refSeq.start+1)+" "+this.refSeq.end+"\n" ); + + this.lastSync = 0; + }, + + gff3_field_names: [ + 'seq_id', + 'source', + 'type', + 'start', + 'end', + 'score', + 'strand', + 'phase', + 'attributes' + ], + + gff3_reserved_attributes: [ + 'ID', + 'Name', + 'Alias', + 'Parent', + 'Target', + 'Gap', + 'Derives_from', + 'Note', + 'Dbxref', + 'Ontology_term', + 'Is_circular' + ], + + /** + * @returns false if the field goes in tabular portion of gff3, true otherwise + * @private + */ + _is_not_gff3_tab_field: function( fieldname ) { + if( ! this._gff3_fields_by_name ) { + var fields = {}; + dojo.forEach( this.gff3_field_names, function(f) { + fields[f] = true; + }); + this._gff3_fields_by_name = fields; + } + + return ! this._gff3_fields_by_name[ fieldname.toLowerCase() ]; + }, + + /** + * @returns the capitalized attribute name if the given field name + * corresponds to a GFF3 reserved attribute + * @private + */ + _gff3_reserved_attribute: function( fieldname ) { + if( ! this._gff3_reserved_attributes_by_lcname ) { + var fields = {}; + dojo.forEach( this.gff3_reserved_attributes, function(f) { + fields[f.toLowerCase()] = f; + }); + this._gff3_reserved_attributes_by_lcname = fields; + } + + return this._gff3_reserved_attributes_by_lcname[ fieldname.toLowerCase() ]; + }, + + + /** + * Format a feature into a string. + * @param {Object} feature feature object (like those returned from JBrowse/Store/SeqFeature/*) + * @returns {String} GFF3 string representation of the feature + */ + formatFeature: function( feature, parentID ) { + var fields = dojo.map( + [ feature.get('seq_id') || this.refSeq.name ] + .concat( dojo.map( this.gff3_field_names.slice(1,8), function(field) { + return feature.get( field ); + },this) + ), + function( data ) { + var dt = typeof data; + return this._gff3_escape( dt == 'string' || dt == 'number' ? data : '.' ); + }, + this + ); + + // convert back from interbase + if( typeof parseInt(fields[3]) == 'number' ) + fields[3]++; + // normalize the strand field + fields[6] = { '1': '+', '-1': '-', '0': '.' }[ fields[6] ] || fields[6]; + + // format the attributes + var attr = this._gff3_attributes( feature ); + if( parentID ) + attr.Parent = parentID; + else + delete attr.Parent; + + var subfeatures = array.map( + feature.get('subfeatures') || [], + function(feat) { + if( ! attr.ID ) { + attr.ID = ++this._idCounter; + } + return this.formatFeature( feat, attr.ID ); + }, this); + + // need to format the attrs after doing the subfeatures, + // because the subfeature formatting might have autocreated an + // ID for the parent + fields[8] = this._gff3_format_attributes( attr ); + + var fl = fields.join("\t")+"\n"; + subfeatures.unshift( fl ); + return subfeatures.join(''); + }, + + /** + * Write the feature to the GFF3 under construction. + * @returns nothing + */ + writeFeature: function(feature) { + var fmt = this.formatFeature(feature); + this.print( fmt ); + + // avoid printing sync marks more than every 10 lines + if( this.lastSync >= 9 ) { + this.lastSync = 0; + this.print( "###\n" ); + } else { + this.lastSync += fmt.length || 1; + } + }, + + /** + * Extract a key-value object of gff3 attributes from the given + * feature. Attribute names will have proper capitalization. + * @private + */ + _gff3_attributes: function(feature) { + var tags = array.filter( feature.tags(), dojo.hitch(this, function(f) { + f = f.toLowerCase(); + return this._is_not_gff3_tab_field(f) && f != 'subfeatures'; + })); + var attrs = {}; + array.forEach( tags, function(tag) { + var val = feature.get(tag); + var valtype = typeof val; + if( valtype == 'boolean' ) + val = val ? 1 : 0; + else if( valtype == 'undefined' ) + return; + tag = this._gff3_reserved_attribute(tag) || this._ensure_non_reserved( tag ); + attrs[tag] = val; + },this); + return attrs; + }, + + // ensure that an attribute name is not reserved. currently does + // this by adding a leading underscore to attribute names that + // have initial capital letters. + _ensure_non_reserved: function( str ) { + return str.replace(/^[A-Z]/,function() { return '_'+str[0]; }); + }, + + /** + * @private + * @returns {String} formatted attribute string + */ + _gff3_format_attributes: function( attrs ) { + var attrOrder = []; + for( var tag in attrs ) { + var val = attrs[tag]; + var valstring = val.hasOwnProperty( 'toString' ) + ? this._gff3_escape( val.toString() ) : + val.values + ? function(val) { + return val instanceof Array + ? array.map( val, lang.hitch(this,'_gff3_escape') ).join(',') + : this._gff3_escape( val ); + }.call(this,val.values) : + val instanceof Array + ? array.map( val, lang.hitch(this,'_gff3_escape') ).join(',') + : this._gff3_escape( val ); + attrOrder.push( this._gff3_escape( tag )+'='+valstring); + } + return attrOrder.join(';') || '.'; + }, + + /** + * @returns always an escaped string representation of the passed value + * @private + */ + _gff3_escape: function( val ) { + return (''+val).replace(/[\n\r\t\;\=%&,\x00-\x1f\x7f-\xff]+/g, escape ); + } +}); + +}); diff --git a/www/JBrowse/View/Export/SequinTable.js b/www/JBrowse/View/Export/SequinTable.js new file mode 100644 index 00000000..29e8b50d --- /dev/null +++ b/www/JBrowse/View/Export/SequinTable.js @@ -0,0 +1,78 @@ +/** + * Support for Sequin Feature table export. See + * http://www.ncbi.nlm.nih.gov/Sequin/table.html. + */ + +define([ 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Export' + ], + function( declare, array, ExportBase ) { + +return declare( ExportBase, + +{ + /** + * Data export driver for BED format. + * @constructs + */ + // constructor: function( args ) { + // }, + + /** + * print the BED track definition line + * @private + */ + _printHeader: function( feature ) { + // print the BED header + this.print( '>Feature '+(feature.get('seq_id') || this.refSeq.name)+"\n" ); + return true; + }, + + /** + * Format a feature into a string. + * @param {Object} feature feature object (like those returned from JBrowse/Store/SeqFeature/*) + * @returns {String} BED string representation of the feature + */ + formatFeature: function( feature ) { + var thisB = this; + if( ! this.headerPrinted ) + this.headerPrinted = this._printHeader( feature ); + + var featLine = [ feature.get('start')+1, + feature.get('end'), + feature.get('type') || 'region' + ]; + if( feature.get('strand') == -1 ) { + var t = featLine[0]; + featLine[0] = featLine[1]; + featLine[1] = t; + } + + // make the qualifiers + var qualifiers = array.map( + array.filter( feature.tags(), function(t) { + return ! { start: 1, end: 1, type: 1, strand: 1, seq_id: 1 }[ t.toLowerCase() ]; + }), + function( tag ) { + return [ tag.toLowerCase(), thisB.stringifyAttributeValue( feature.get(tag) ) ]; + }); + + return featLine.join("\t")+"\n" + array.map( qualifiers, function( q ) { return "\t\t\t"+q.join("\t")+"\n"; } ).join(''); + }, + + stringifyAttributeValue: function( val ) { + return val.hasOwnProperty( 'toString' ) + ? val.toString() : + val.values + ? function(val) { + return val instanceof Array + ? val.join(',') + : val; + }.call(this,val.values) : + val instanceof Array + ? val.join(',') + : val; + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Export/Wiggle.js b/www/JBrowse/View/Export/Wiggle.js new file mode 100644 index 00000000..464a4fce --- /dev/null +++ b/www/JBrowse/View/Export/Wiggle.js @@ -0,0 +1,59 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Export' + ], + function( declare, array, ExportBase ) { + +return declare( ExportBase, + /** + * @lends JBrowse.View.Export.Wiggle.prototype + */ +{ + /** + * Data export driver for Wiggle format. + * @constructs + */ + constructor: function( args ) { + // print the track definition + this.print( 'track type=wiggle_0' ); + if( this.track ) { + if( this.track.name ) + this.print(' name="'+this.track.name+'"'); + var metadata = this.track.getMetadata(); + if( metadata.key ) + this.print(' description="'+metadata.key+'"'); + } + this.print("\n"); + }, + + /** + * print the Wiggle step + * @private + */ + _printStep: function( span, ref ) { + this.print( 'variableStep'+ (ref ? ' chrom='+ref : '' ) + ' span='+span+"\n" ); + }, + + exportRegion: function( region, callback ) { + var curspan; + var curref; + this.store.getFeatures( + region, + dojo.hitch( this, function(f) { + var span = f.get('end') - f.get('start'); + var ref = f.get('seq_id'); + if( !( curspan == span && ref == curref ) ) { + this._printStep( span, ref == curref ? null : ref ); + curref = ref; + curspan = span; + } + this.print( (f.get('start')+1) + "\t" + f.get('score') + "\n" ); + }), + dojo.hitch( this, function() { + callback( this.output ); + }) + ); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Export/bedGraph.js b/www/JBrowse/View/Export/bedGraph.js new file mode 100644 index 00000000..8e748f31 --- /dev/null +++ b/www/JBrowse/View/Export/bedGraph.js @@ -0,0 +1,40 @@ +define([ 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Export/BED' + ], + function( declare, array, bedExport ) { + +return declare( bedExport, + /** + * @lends JBrowse.View.Export.bedGraph.prototype + */ +{ + /** + * Data export driver for bedGraph format. + * @constructs + */ + constructor: function( args ) {}, + + _printHeader: function() { + // print the track definition + this.print( 'track type=bedGraph' ); + if( this.track ) { + if( this.track.name ) + this.print(' name="'+this.track.name+'"'); + var metadata = this.track.getMetadata(); + if( metadata.key ) + this.print(' description="'+metadata.key+'"'); + } + this.print("\n"); + }, + + formatFeature: function( f ) { + return [ + f.get('seq_id'), + f.get('start'), + f.get('end'), + f.get('score') + ].join("\t")+"\n"; + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/FASTA.js b/www/JBrowse/View/FASTA.js new file mode 100644 index 00000000..7057e90b --- /dev/null +++ b/www/JBrowse/View/FASTA.js @@ -0,0 +1,40 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/Util' + ], + function( declare, Util ) { + +return declare(null, +{ + + constructor: function( args ) { + this.width = args.width || 78; + this.htmlMaxRows = args.htmlMaxRows || 15; + }, + renderHTML: function( region, seq, container ) { + var text = this.renderText( region, seq ); + var lineCount = text.match( /\n/g ).length + 1; + var textArea = dojo.create('textarea', { + className: 'fasta', + cols: this.width, + rows: Math.min( lineCount, this.htmlMaxRows ), + readonly: true + }, container ); + var c = 0; + textArea.innerHTML = text.replace(/\n/g, function() { return c++ ? '' : "\n"; }); + return textArea; + }, + renderText: function( region, seq ) { + return '>' + region.ref + + ' '+Util.assembleLocString(region) + + ( region.type ? ' class='+region.type : '' ) + + ' length='+(region.end - region.start) + + "\n" + + this._wrap( seq, this.width ); + }, + _wrap: function( string, length ) { + length = length || this.width; + return string.replace( new RegExp('(.{'+length+'})','g'), "$1\n" ); + } +}); +}); diff --git a/www/JBrowse/View/FeatureGlyph.js b/www/JBrowse/View/FeatureGlyph.js new file mode 100644 index 00000000..d524cf82 --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph.js @@ -0,0 +1,189 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/aspect', + 'JBrowse/Component' + ], + function( + declare, + array, + aspect, + Component + ) { + +return declare( Component, { + constructor: function( args ) { + this.track = args.track; + this.booleanAlpha = 0.17; + + + // This allows any features that are completely masked to have their transparency set before being rendered, + // saving the hassle of clearing and rerendering later on. + aspect.before(this, 'renderFeature', + function( context, fRect ) { + if (fRect.m) { + var l = Math.floor(fRect.l); + var w = Math.ceil(fRect.w + fRect.l) - l; + fRect.m.sort(function(a,b) { return a.l - b.l; }); + var m = fRect.m[0]; + if (m.l <= l) { + // Determine whether the feature is entirely masked. + var end = fRect.m[0].l; + for(var i in fRect.m) { + var m = fRect.m[i]; + if(m.l > end) + break; + end = m.l + m.w; + } + if(end >= l + w) { + context.globalAlpha = this.booleanAlpha; + fRect.noMask = true; + } + } + } + }, true); + + // after rendering the features, do masking if required + aspect.after(this, 'renderFeature', + function( context, fRect ) { + if (fRect.m && !fRect.noMask) { + this.maskBySpans( context, fRect ); + } else if ( fRect.noMask) { + delete fRect.noMask; + context.globalAlpha = 1; + } + }, true); + }, + + getStyle: function( feature, name ) { + return this.getConfForFeature( 'style.'+name, feature ); + }, + + /** + * Like getConf, but get a conf value that explicitly can vary + * feature by feature. Provides a uniform function signature for + * user-defined callbacks. + */ + getConfForFeature: function( path, feature ) { + return this.getConf( path, [feature, path, this, this.track ] ); + }, + + mouseoverFeature: function( context, fRect ) { + this.renderFeature( context, fRect ); + + // highlight the feature rectangle if we're moused over + context.fillStyle = this.getStyle( fRect.f, 'mouseovercolor' ); + context.fillRect( fRect.rect.l, fRect.t, fRect.rect.w, fRect.rect.h ); + }, + + /** + * Get the dimensions of the rendered feature in pixels. + */ + _getFeatureRectangle: function( viewInfo, feature ) { + var block = viewInfo.block; + var fRect = { + l: block.bpToX( feature.get('start') ), + h: this._getFeatureHeight( viewArgs, feature ), + viewInfo: viewInfo, + f: feature, + glyph: this + }; + + fRect.w = block.bpToX( feature.get('end') ) - fRect.l; + + this._addMasksToRect( viewInfo, feature, fRect ); + }, + + _addMasksToRect: function( viewArgs, feature, fRect ) { + // if the feature has masks, add them to the fRect. + var block = viewArgs.block; + + if( feature.masks ) { + fRect.m = []; + array.forEach( feature.masks, function(m) { + var tempM = { l: block.bpToX( m.start ) }; + tempM.w = block.bpToX( m.end ) - tempM.l; + fRect.m.push(tempM); + }); + } + + return fRect; + }, + + layoutFeature: function( viewArgs, layout, feature ) { + var fRect = this._getFeatureRectangle( viewArgs, feature ); + + var scale = viewArgs.scale; + var leftBase = viewArgs.leftBase; + var startbp = fRect.l/scale + leftBase; + var endbp = (fRect.l+fRect.w)/scale + leftBase; + fRect.t = layout.addRect( + feature.id(), + startbp, + endbp, + fRect.h, + feature + ); + if( fRect.t === null ) + return null; + + fRect.f = feature; + + return fRect; + }, + + //stub + renderFeature: function( context, fRect ) { + }, + + /* If it's a boolean track, mask accordingly */ + maskBySpans: function( context, fRect ) { + var canvasHeight = context.canvas.height; + + var thisB = this; + + // make a temporary canvas to store image data + var tempCan = dojo.create( 'canvas', {height: canvasHeight, width: context.canvas.width} ); + var ctx2 = tempCan.getContext('2d'); + var l = Math.floor(fRect.l); + var w = Math.ceil(fRect.w + fRect.l) - l; + + /* note on the above: the rightmost pixel is determined + by l+w. If either of these is a float, then canvas + methods will not behave as desired (i.e. clear and + draw will not treat borders in the same way).*/ + array.forEach( fRect.m, function(m) { try { + if ( m.l < l ) { + m.w += m.l-l; + m.l = l; + } + if ( m.w > w ) + m.w = w; + if ( m.l < 0 ) { + m.w += m.l; + m.l = 0; + } + if ( m.l + m.w > l + w ) + m.w = w + l - m.l; + if ( m.l + m.w > context.canvas.width ) + m.w = context.canvas.width-m.l; + ctx2.drawImage(context.canvas, m.l, fRect.t, m.w, fRect.h, m.l, fRect.t, m.w, fRect.h); + context.globalAlpha = thisB.booleanAlpha; + // clear masked region and redraw at lower opacity. + context.clearRect(m.l, fRect.t, m.w, fRect.h); + context.drawImage(tempCan, m.l, fRect.t, m.w, fRect.h, m.l, fRect.t, m.w, fRect.h); + context.globalAlpha = 1; + } catch(e) {}; + }); + }, + + _getFeatureHeight: function( viewArgs, feature ) { + return this.getStyle( feature, 'height'); + }, + + updateStaticElements: function( context, fRect, viewArgs ) { + + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/FeatureGlyph/Alignment.js b/www/JBrowse/View/FeatureGlyph/Alignment.js new file mode 100644 index 00000000..d9a173cb --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/Alignment.js @@ -0,0 +1,126 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/FeatureGlyph/Box', + 'JBrowse/Store/SeqFeature/_MismatchesMixin' + ], + function( + declare, + array, + BoxGlyph, + MismatchesMixin + ) { + +return declare( [BoxGlyph,MismatchesMixin], { + + constructor: function() { + + // if showMismatches is false, stub out this object's + // _drawMismatches to be a no-op + if( ! this.config.style.showMismatches ) + this._drawMismatches = function() {}; + + }, + + _defaultConfig: function() { + return this._mergeConfigs( + dojo.clone( this.inherited(arguments) ), + { + //maxFeatureScreenDensity: 400 + style: { + color: function( feature, path, glyph, track ) { + var missing_mate = feature.get('multi_segment_template') && !feature.get('multi_segment_all_aligned'); + var strand = feature.get('strand'); + return missing_mate ? glyph.getStyle( feature, 'color_missing_mate' ) : + strand == 1 || strand == '+' ? glyph.getStyle( feature, 'color_fwd_strand' ) : + strand == -1 || strand == '-' ? glyph.getStyle( feature, 'color_rev_strand' ) : + track.colorForBase('reference'); + }, + color_fwd_strand: '#EC8B8B', + color_rev_strand: '#898FD8', + color_missing_mate: '#D11919', + border_color: null, + + strandArrow: false, + + height: 7, + marginBottom: 1, + showMismatches: true, + mismatchFont: 'bold 10px Courier New,monospace' + } + } + ); + }, + + renderFeature: function( context, fRect ) { + + this.inherited( arguments ); + + if( fRect.viewInfo.scale > 0.2 ) + this._drawMismatches( context, fRect ); + }, + + _drawMismatches: function( context, fRect ) { + var feature = fRect.f; + var block = fRect.viewInfo.block; + var scale = block.scale; + // recall: scale is pixels/basepair + if ( fRect.w > 1 ) { + var mismatches = this._getMismatches( feature ); + var charSize = this.getCharacterMeasurements( context ); + array.forEach( mismatches, function( mismatch ) { + var start = feature.get('start') + mismatch.start; + var end = start + mismatch.length; + + var mRect = { + h: (fRect.rect||{}).h || fRect.h, + l: block.bpToX( start ), + t: fRect.rect.t + }; + mRect.w = Math.max( block.bpToX( end ) - mRect.l, 1 ); + + if( mismatch.type == 'mismatch' || mismatch.type == 'deletion' ) { + context.fillStyle = this.track.colorForBase( mismatch.type == 'deletion' ? 'deletion' : mismatch.base ); + context.fillRect( mRect.l, mRect.t, mRect.w, mRect.h ); + + if( mRect.w >= charSize.w && mRect.h >= charSize.h-3 ) { + context.font = this.config.style.mismatchFont; + context.fillStyle = mismatch.type == 'deletion' ? 'white' : 'black'; + context.textBaseline = 'middle'; + context.fillText( mismatch.base, mRect.l+(mRect.w-charSize.w)/2+1, mRect.t+mRect.h/2 ); + } + } + else if( mismatch.type == 'insertion' ) { + context.fillStyle = 'purple'; + context.fillRect( mRect.l-1, mRect.t+1, 2, mRect.h-2 ); + context.fillRect( mRect.l-2, mRect.t, 4, 1 ); + context.fillRect( mRect.l-2, mRect.t+mRect.h-1, 4, 1 ); + if( mRect.w >= charSize.w && mRect.h >= charSize.h-3 ) { + context.font = this.config.style.mismatchFont; + context.fillText( '('+mismatch.base+')', mRect.l+2, mRect.t+mRect.h-(mRect.h-charSize.h+4)/2 ); + } + } + else if( mismatch.type == 'skip' ) { + context.clearRect( mRect.l, mRect.t, mRect.w, mRect.h ); + context.fillStyle = '#333'; + context.fillRect( mRect.l, mRect.t+(mRect.h-2)/2, mRect.w, 2 ); + } + },this); + } + }, + + getCharacterMeasurements: function( context ) { + return this.charSize = this.charSize || function() { + var fpx; + + try { + fpx = (this.config.style.mismatchFont.match(/(\d+)px/i)||[])[1]; + } catch(e) {} + + fpx = fpx || Infinity; + return { w: fpx, h: fpx }; + }.call(this); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/FeatureGlyph/Box.js b/www/JBrowse/View/FeatureGlyph/Box.js new file mode 100644 index 00000000..a390244e --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/Box.js @@ -0,0 +1,334 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'JBrowse/Util/FastPromise', + 'JBrowse/View/FeatureGlyph', + './_FeatureLabelMixin' + ], + function( + declare, + array, + lang, + FastPromise, + FeatureGlyph, + FeatureLabelMixin + ) { + + +return declare([ FeatureGlyph, FeatureLabelMixin ], { + + constructor: function() { + this._embeddedImagePromises = {}; + }, + + _defaultConfig: function() { + return this._mergeConfigs( + this.inherited(arguments), + { + style: { + maxDescriptionLength: 70, + + color: 'goldenrod', + mouseovercolor: 'rgba(0,0,0,0.3)', + borderColor: null, + borderWidth: 0.5, + height: 11, + marginBottom: 2, + + strandArrow: true, + + label: 'name, id', + textFont: 'normal 12px Univers,Helvetica,Arial,sans-serif', + textColor: 'black', + text2Color: 'blue', + text2Font: 'normal 12px Univers,Helvetica,Arial,sans-serif', + + description: 'note, description' + } + }); + }, + + _getFeatureHeight: function( viewArgs, feature ) { + var h = this.getStyle( feature, 'height'); + + if( viewArgs.displayMode == 'compact' ) + h = Math.round( 0.45 * h ); + + if( this.getStyle( feature, 'strandArrow' ) ) { + var strand = feature.get('strand'); + if( strand == 1 ) + h = Math.max( this._embeddedImages.plusArrow.height, h ); + else if( strand == -1 ) + h = Math.max( this._embeddedImages.minusArrow.height, h ); + } + + return h; + }, + + _getFeatureRectangle: function( viewArgs, feature ) { + var block = viewArgs.block; + var fRect = { + l: block.bpToX( feature.get('start') ), + h: this._getFeatureHeight(viewArgs, feature), + viewInfo: viewArgs, + f: feature, + glyph: this + }; + + fRect.w = block.bpToX( feature.get('end') ) - fRect.l; + + // save the original rect in `rect` as the dimensions + // we'll use for the rectangle itself + fRect.rect = { l: fRect.l, h: fRect.h, w: Math.max( fRect.w, 2 ), t: 0 }; + fRect.w = fRect.rect.w; // in case it was increased + if( viewArgs.displayMode != 'compact' ) + fRect.h += this.getStyle( feature, 'marginBottom' ) || 0 +; + // if we are showing strand arrowheads, expand the frect a little + if( this.getStyle( feature, 'strandArrow') ) { + var strand = fRect.strandArrow = feature.get('strand'); + + if( strand == -1 ) { + var i = this._embeddedImages.minusArrow; + fRect.w += i.width; + fRect.l -= i.width; + } + else { + var i = this._embeddedImages.plusArrow; + fRect.w += i.width; + } + } + + // no labels or descriptions if displayMode is collapsed, so stop here + if( viewArgs.displayMode == "collapsed") + return fRect; + + this._expandRectangleWithLabels( viewArgs, feature, fRect ); + this._addMasksToRect( viewArgs, feature, fRect ); + + return fRect; + }, + + layoutFeature: function( viewArgs, layout, feature ) { + var rect = this.inherited( arguments ); + if( ! rect ) return rect; + + // need to set the top of the inner rect + rect.rect.t = rect.t; + + return rect; + }, + + // given an under-construction feature layout rectangle, expand it + // to accomodate a label and/or a description + _expandRectangleWithLabels: function( viewArgs, feature, fRect ) { + // maybe get the feature's name, and update the layout box + // accordingly + if( viewArgs.showLabels ) { + var label = this.makeFeatureLabel( feature, fRect ); + if( label ) { + fRect.h += label.h; + fRect.w = Math.max( label.w, fRect.w ); + fRect.label = label; + label.yOffset = fRect.rect.h + label.h; + } + } + + // maybe get the feature's description if available, and + // update the layout box accordingly + if( viewArgs.showDescriptions ) { + var description = this.makeFeatureDescriptionLabel( feature, fRect ); + if( description ) { + fRect.description = description; + fRect.h += description.h; + fRect.w = Math.max( description.w, fRect.w ); + description.yOffset = fRect.h-(this.getStyle( feature, 'marginBottom' ) || 0); + } + } + }, + + _embeddedImages: { + plusArrow: { + data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAYAAACXU8ZrAAAATUlEQVQIW2NkwATGQKFYIG4A4g8gacb///+7AWlBmNq+vj6V4uLiJiD/FRBXA/F8xu7u7kcVFRWyMEVATQz//v0Dcf9CxaYRZxIxbgIARiAhmifVe8UAAAAASUVORK5CYII=", + width: 9, + height: 5 + }, + + minusArrow: { + data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAYAAACXU8ZrAAAASklEQVQIW2NkQAABILMBiBcD8VkkcQZGIAeEE4G4FYjFent764qKiu4gKXoPUjAJiLOggsxMTEwMjIwgYQjo6Oh4TLRJME043QQA+W8UD/sdk9IAAAAASUVORK5CYII=", + width: 9, + height: 5 + } + }, + + /** + * Returns a promise for an Image object for the image with the + * given name. Image data comes from a data URL embedded in this + * source code. + */ + getEmbeddedImage: function( name ) { + return (this._embeddedImagePromises[name] || function() { + var p = new FastPromise(); + var imgRec = this._embeddedImages[ name ]; + if( ! imgRec ) { + p.resolve( null ); + } + else { + var i = new Image(); + var thisB = this; + i.onload = function() { + p.resolve( this ); + }; + i.src = imgRec.data; + } + return this._embeddedImagePromises[name] = p; + }.call(this)); + }, + + renderFeature: function( context, fRect ) { + if( this.track.displayMode != 'collapsed' ) + context.clearRect( Math.floor(fRect.l), fRect.t, Math.ceil(fRect.w-Math.floor(fRect.l)+fRect.l), fRect.h ); + + this.renderBox( context, fRect.viewInfo, fRect.f, fRect.t, fRect.rect.h, fRect.f ); + this.renderLabel( context, fRect ); + this.renderDescription( context, fRect ); + this.renderArrowhead( context, fRect ); + }, + + // top and height are in px + renderBox: function( context, viewInfo, feature, top, overallHeight, parentFeature, style ) { + var left = viewInfo.block.bpToX( feature.get('start') ); + var width = viewInfo.block.bpToX( feature.get('end') ) - left; + //left = Math.round( left ); + //width = Math.round( width ); + + style = style || lang.hitch( this, 'getStyle' ); + + var height = this._getFeatureHeight( viewInfo, feature ); + if( ! height ) + return; + if( height != overallHeight ) + top += Math.round( (overallHeight - height)/2 ); + + // background + var bgcolor = style( feature, 'color' ); + if( bgcolor ) { + context.fillStyle = bgcolor; + context.fillRect( left, top, Math.max(1,width), height ); + } + else { + context.clearRect( left, top, Math.max(1,width), height ); + } + + // foreground border + var borderColor, lineWidth; + if( (borderColor = style( feature, 'borderColor' )) && ( lineWidth = style( feature, 'borderWidth')) ) { + if( width > 3 ) { + context.lineWidth = lineWidth; + context.strokeStyle = borderColor; + + // need to stroke a smaller rectangle to remain within + // the bounds of the feature's overall height and + // width, because of the way stroking is done in + // canvas. thus the +0.5 and -1 business. + context.strokeRect( left+lineWidth/2, top+lineWidth/2, width-lineWidth, height-lineWidth ); + } + else { + context.globalAlpha = lineWidth*2/width; + context.fillStyle = borderColor; + context.fillRect( left, top, Math.max(1,width), height ); + context.globalAlpha = 1; + } + } + }, + + // feature label + renderLabel: function( context, fRect ) { + if( fRect.label ) { + context.font = fRect.label.font; + context.fillStyle = fRect.label.fill; + context.textBaseline = fRect.label.baseline; + context.fillText( fRect.label.text, + fRect.l+(fRect.label.xOffset||0), + fRect.t+(fRect.label.yOffset||0) + ); + } + }, + + // feature description + renderDescription: function( context, fRect ) { + if( fRect.description ) { + context.font = fRect.description.font; + context.fillStyle = fRect.description.fill; + context.textBaseline = fRect.description.baseline; + context.fillText( + fRect.description.text, + fRect.l+(fRect.description.xOffset||0), + fRect.t + (fRect.description.yOffset||0) + ); + } + }, + + // strand arrowhead + renderArrowhead: function( context, fRect ) { + if( fRect.strandArrow ) { + if( fRect.strandArrow == 1 && fRect.rect.l+fRect.rect.w <= context.canvas.width ) { + this.getEmbeddedImage( 'plusArrow' ) + .then( function( img ) { + context.imageSmoothingEnabled = false; + context.drawImage( img, fRect.rect.l + fRect.rect.w, fRect.t + (fRect.rect.h-img.height)/2 ); + }); + } + else if( fRect.strandArrow == -1 && fRect.rect.l >= 0 ) { + this.getEmbeddedImage( 'minusArrow' ) + .then( function( img ) { + context.imageSmoothingEnabled = false; + context.drawImage( img, fRect.rect.l-9, fRect.t + (fRect.rect.h-img.height)/2 ); + }); + } + } + }, + + updateStaticElements: function( context, fRect, viewArgs ) { + var vMin = viewArgs.minVisible; + var vMax = viewArgs.maxVisible; + var block = fRect.viewInfo.block; + + if( !( block.containsBp( vMin ) || block.containsBp( vMax ) ) ) + return; + + var scale = block.scale; + var bpToPx = viewArgs.bpToPx; + var lWidth = viewArgs.lWidth; + var labelBp = lWidth / scale; + var feature = fRect.f; + var fMin = feature.get('start'); + var fMax = feature.get('end'); + + if( fRect.strandArrow ) { + if( fRect.strandArrow == 1 && fMax >= vMax && fMin <= vMax ) { + this.getEmbeddedImage( 'plusArrow' ) + .then( function( img ) { + context.imageSmoothingEnabled = false; + context.drawImage( img, bpToPx(vMax) - bpToPx(vMin) - 9, fRect.t + (fRect.rect.h-img.height)/2 ); + }); + } + else if( fRect.strandArrow == -1 && fMin <= vMin && fMax >= vMin ) { + this.getEmbeddedImage( 'minusArrow' ) + .then( function( img ) { + context.imageSmoothingEnabled = false; + context.drawImage( img, 0, fRect.t + (fRect.rect.h-img.height)/2 ); + }); + } + } + + var fLabelWidth = fRect.label ? fRect.label.w : 0; + var fDescriptionWidth = fRect.description ? fRect.description.w : 0; + var maxLeft = bpToPx( fMax ) - Math.max(fLabelWidth, fDescriptionWidth) - bpToPx( vMin ); + var minLeft = bpToPx( fMin ) - bpToPx( vMin ); + + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/FeatureGlyph/Gene.js b/www/JBrowse/View/FeatureGlyph/Gene.js new file mode 100644 index 00000000..5c37a212 --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/Gene.js @@ -0,0 +1,152 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'JBrowse/View/FeatureGlyph/Box', + 'JBrowse/View/FeatureGlyph/ProcessedTranscript' + ], + function( + declare, + lang, + array, + BoxGlyph, + ProcessedTranscriptGlyph + ) { + +return declare( BoxGlyph, { + +_defaultConfig: function() { + return this._mergeConfigs( + this.inherited(arguments), + { + transcriptType: 'mRNA', + style: { + transcriptLabelFont: 'normal 10px Univers,Helvetica,Arial,sans-serif', + transcriptLabelColor: 'black', + textFont: 'bold 12px Univers,Helvetica,Arial,sans-serif' + }, + labelTranscripts: true, + marginBottom: 0 + }); +}, + +_boxGlyph: function() { + return this.__boxGlyph || ( this.__boxGlyph = new BoxGlyph({ track: this.track, browser: this.browser, config: this.config }) ); +}, +_ptGlyph: function() { + return this.__ptGlyph || ( this.__ptGlyph = new ProcessedTranscriptGlyph({ track: this.track, browser: this.browser, config: this.config }) ); +}, + +_getFeatureRectangle: function( viewArgs, feature ) { + + // lay out rects for each of the subfeatures + var subArgs = lang.mixin( {}, viewArgs ); + subArgs.showDescriptions = subArgs.showLabels = false; + var subfeatures = feature.children(); + + // get the rects for the children + var padding = 1; + var fRect = { + l: 0, + h: 0, + r: 0, + w: 0, + subRects: [], + viewInfo: viewArgs, + f: feature, + glyph: this + }; + if( subfeatures && subfeatures.length ) { + // sort the children by name + subfeatures.sort( function( a, b ) { return (a.get('name') || '').localeCompare( b.get('name')||'' ); } ); + + fRect.l = Infinity; + fRect.r = -Infinity; + + var transcriptType = this.getConfForFeature( 'transcriptType', feature ); + for( var i = 0; i < subfeatures.length; i++ ) { + var subRect = ( subfeatures[i].get('type') == transcriptType + ? this._ptGlyph() + : this._boxGlyph() + )._getFeatureRectangle( subArgs, subfeatures[i] ); + + padding = i == subfeatures.length-1 ? 0 : 1; + subRect.t = subRect.rect.t = fRect.h && viewArgs.displayMode != 'collapsed' ? fRect.h+padding : 0; + + if( viewArgs.showLabels && this.getConfForFeature( 'labelTranscripts', subfeatures[i] ) ) { + var transcriptLabel = this.makeSideLabel( + this.getFeatureLabel(subfeatures[i]), + this.getStyle( subfeatures[i], 'transcriptLabelFont'), + subRect + ); + if( transcriptLabel ) { + transcriptLabel.fill = this.getStyle( subfeatures[i], 'transcriptLabelColor' ); + subRect.label = transcriptLabel; + subRect.l -= transcriptLabel.w; + subRect.w += transcriptLabel.w; + if( transcriptLabel.h > subRect.h ) + subRect.h = transcriptLabel.h; + transcriptLabel.yOffset = Math.floor(subRect.h/2); + transcriptLabel.xOffset = 0; + } + } + + fRect.subRects.push( subRect ); + fRect.r = Math.max( fRect.r, subRect.l+subRect.w-1 ); + fRect.l = Math.min( fRect.l, subRect.l ); + fRect.h = subRect.t+subRect.h+padding; + } + } + + // calculate the width + fRect.w = Math.max( fRect.r - fRect.l + 1, 2 ); + delete fRect.r; + fRect.rect = { l: fRect.l, h: fRect.h, w: fRect.w }; + if( viewArgs.displayMode != 'compact' ) + fRect.h += this.getStyle( feature, 'marginBottom' ) || 0; + + // no labels or descriptions if displayMode is collapsed, so stop here + if( viewArgs.displayMode == "collapsed") + return fRect; + + // expand the fRect to accommodate labels if necessary + this._expandRectangleWithLabels( viewArgs, feature, fRect ); + this._addMasksToRect( viewArgs, feature, fRect ); + + return fRect; +}, + +layoutFeature: function( viewInfo, layout, feature ) { + var fRect = this.inherited( arguments ); + if( fRect ) + array.forEach( fRect.subRects, function( subrect ) { + subrect.t += fRect.t; + subrect.rect.t += fRect.t; + }); + return fRect; +}, + +renderFeature: function( context, fRect ) { + if( fRect.viewInfo.displayMode != 'collapsed' ) + context.clearRect( Math.floor(fRect.l), fRect.t, Math.ceil(fRect.w-Math.floor(fRect.l)+fRect.l), fRect.h ); + + var subRects = fRect.subRects; + for( var i = 0; i < subRects.length; i++ ) { + subRects[i].glyph.renderFeature( context, subRects[i] ); + } + + this.renderLabel( context, fRect ); + this.renderDescription( context, fRect ); +}, + +updateStaticElements: function( context, fRect, viewArgs ) { + this.inherited( arguments ); + + var subRects = fRect.subRects; + for( var i = 0; i < subRects.length; i++ ) { + subRects[i].glyph.updateStaticElements( context, subRects[i], viewArgs ); + } +} + +}); +}); diff --git a/www/JBrowse/View/FeatureGlyph/ProcessedTranscript.js b/www/JBrowse/View/FeatureGlyph/ProcessedTranscript.js new file mode 100644 index 00000000..1c789ac0 --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/ProcessedTranscript.js @@ -0,0 +1,56 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojox/color/Palette', + 'JBrowse/View/FeatureGlyph/Segments' + ], + function( + declare, + array, + Palette, + SegmentsGlyph + ) { + +return declare( SegmentsGlyph, { + +_defaultConfig: function() { + return this._mergeConfigs( + this.inherited(arguments), + { + style: { + utrColor: function( feature, variable, glyph, track ) { + return glyph._utrColor( glyph.getStyle( feature.parent(), 'color' ) ).toString(); + } + }, + subParts: 'CDS, UTR, five_prime_UTR, three_prime_UTR' + }); +}, + +_utrColor: function( baseColor ) { + return (this._palette || (this._palette = Palette.generate( baseColor, "splitComplementary"))).colors[1]; +}, + +_isUTR: function( feature ) { + return /(\bUTR|_UTR|untranslated[_\s]region)\b/.test( feature.get('type') || '' ); +}, + +getStyle: function( feature, name ) { + if( name == 'color' ) { + if( this._isUTR( feature ) ) + return this.getStyle( feature, 'utrColor' ); + } + + return this.inherited(arguments); +}, + +_getFeatureHeight: function( viewInfo, feature ) { + var height = this.inherited( arguments ); + + if( this._isUTR( feature ) ) + return height*0.65; + + return height; +} + +}); +}); diff --git a/www/JBrowse/View/FeatureGlyph/Segments.js b/www/JBrowse/View/FeatureGlyph/Segments.js new file mode 100644 index 00000000..4e0bbe2c --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/Segments.js @@ -0,0 +1,114 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'JBrowse/View/FeatureGlyph/Box' + ], + function( + declare, + lang, + array, + BoxGlyph + ) { + +return declare( BoxGlyph, { + +_defaultConfig: function() { + return this._mergeConfigs( + this.inherited(arguments), + { + style: { + connectorColor: '#333', + connectorThickness: 1, + borderColor: 'rgba( 0, 0, 0, 0.3 )' + }, + subParts: function() { return true; } // accept all subparts by default + }); +}, + +renderFeature: function( context, fRect ) { + if( this.track.displayMode != 'collapsed' ) + context.clearRect( Math.floor(fRect.l), fRect.t, Math.ceil(fRect.w), fRect.h ); + + this.renderConnector( context, fRect ); + this.renderSegments( context, fRect ); + this.renderLabel( context, fRect ); + this.renderDescription( context, fRect ); + this.renderArrowhead( context, fRect ); +}, + +renderConnector: function( context, fRect ) { + // connector + var connectorColor = this.getStyle( fRect.f, 'connectorColor' ); + if( connectorColor ) { + context.fillStyle = connectorColor; + var connectorThickness = this.getStyle( fRect.f, 'connectorThickness' ); + context.fillRect( + fRect.rect.l, // left + Math.round(fRect.rect.t+(fRect.rect.h-connectorThickness)/2), // top + fRect.rect.w, // width + connectorThickness + ); + } +}, + +renderSegments: function( context, fRect ) { + var subfeatures = fRect.f.children(); + if( subfeatures ) { + + var thisB = this; + var parentFeature = fRect.f; + + function style( feature, stylename ) { + if( stylename == 'height' ) + return thisB._getFeatureHeight( fRect.viewInfo, feature ); + + return thisB.getStyle( feature, stylename ) || thisB.getStyle( parentFeature, stylename ); + } + + for( var i = 0; i < subfeatures.length; ++i ) { + if( this._filterSubpart( subfeatures[i] ) ) + this.renderBox( context, fRect.viewInfo, subfeatures[i], fRect.t, fRect.rect.h, fRect.f, style ); + } + } +}, + +_filterSubpart: function( f ) { + return ( this._subpartsFilter || (this._subpartsFilter = this._makeSubpartsFilter()) )(f); +}, + +// make a function that will filter subpart features according to the +// sub_parts conf var +_makeSubpartsFilter: function( f ) { + var filter = this.getConf( 'subParts' ); + + if( typeof filter == 'string' ) + // convert to array + filter = filter.split( /\s*,\s*/ ); + + if( typeof filter == 'object' ) { + // lowercase and make into a function + if( lang.isArray( filter ) ) + filter = function() { + var f = {}; + array.forEach( filter, function(t) { f[t.toLowerCase()] = true; } ); + return f; + }.call(this); + else + filter = function() { + var f = {}; + for( var t in filter ) { + f[t.toLowerCase()] = filter[t]; + } + return f; + }.call(this); + return function(feature) { + return filter[ (feature.get('type')||'').toLowerCase() ]; + }; + } + + return filter; +} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/FeatureGlyph/_FeatureLabelMixin.js b/www/JBrowse/View/FeatureGlyph/_FeatureLabelMixin.js new file mode 100644 index 00000000..2d2d985d --- /dev/null +++ b/www/JBrowse/View/FeatureGlyph/_FeatureLabelMixin.js @@ -0,0 +1,120 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'JBrowse/View/_FeatureDescriptionMixin' + ], + function( + declare, + lang, + FeatureDescriptionMixin + ) { +var fontMeasurementsCache = {}; + +return declare( FeatureDescriptionMixin, { + + /** + * Estimate the height and width, in pixels, of the given + * feature's label text, and trim it if necessary to fit within + * the track's maxFeatureGlyphExpansion limit. + */ + makeFeatureLabel: function( feature, fRect ) { + var text = this.getFeatureLabel( feature ); + if( ! text ) + return null; + var font = this.getStyle( feature, 'textFont' ); + var l = fRect ? this.makeBottomOrTopLabel( text, font, fRect ) : this.makePopupLabel( text, font ); + l.fill = this.getStyle( feature, 'textColor' ); + return l; + }, + + /** + * Estimate the height and width, in pixels, of the given + * feature's description text, and trim it if necessary to fit + * within the track's maxFeatureGlyphExpansion limit. + */ + makeFeatureDescriptionLabel: function( feature, fRect ) { + var text = this.getFeatureDescription( feature ); + if( ! text ) + return null; + var font = this.getStyle( feature, 'text2Font' ); + var l = fRect ? this.makeBottomOrTopLabel( text, font, fRect ) : this.makePopupLabel( text, font ); + l.fill = this.getStyle( feature, 'text2Color' ); + return l; + }, + + /** + * Makes a label that sits on the left or right side of a feature, + * respecting maxFeatureGlyphExpansion. + */ + makeSideLabel: function( text, font, fRect ) { + if( ! text ) return null; + + var dims = this.measureFont( font ); + var excessCharacters = Math.round(( text.length * dims.w - this.track.getConf('maxFeatureGlyphExpansion') ) / dims.w ); + if( excessCharacters > 0 ) + text = text.slice( 0, text.length - excessCharacters - 1 ) + '…'; + + return { + text: text, + font: font, + baseline: 'middle', + w: dims.w * text.length, + h: dims.h + }; + }, + + /** + * Makes a label that lays across the bottom edge of a feature, + * respecting maxFeatureGlyphExpansion. + */ + makeBottomOrTopLabel: function( text, font, fRect ) { + if( ! text ) return null; + + var dims = this.measureFont( font ); + var excessCharacters = Math.round(( text.length * dims.w - fRect.w - this.track.getConf('maxFeatureGlyphExpansion') ) / dims.w ); + if( excessCharacters > 0 ) + text = text.slice( 0, text.length - excessCharacters - 1 ) + '…'; + + return { + text: text, + font: font, + baseline: 'bottom', + w: dims.w * text.length, + h: dims.h + }; + }, + + /** + * Makes a label that can be put in a popup or tooltip, + * not respecting maxFeatureGlyphExpansion or the width of the fRect. + */ + makePopupLabel: function( text, font ) { + if( ! text ) return null; + var dims = this.measureFont( font ); + return { + text: text, + font: font, + w: dims.w * text.length, + h: dims.h + } + }, + + /** + * Return an object with average `h` and `w` of characters in the + * font described by the given string. + */ + measureFont: function( font ) { + return fontMeasurementsCache[ font ] + || ( fontMeasurementsCache[font] = function() { + var ctx = document.createElement('canvas').getContext('2d'); + ctx.font = font; + var testString = "MMMMMMMMMMMMXXXXXXXXXX1234567890-.CGCC12345"; + var m = ctx.measureText( testString ); + return { + h: m.height || parseInt( font.match(/(\d+)px/)[1] ), + w: m.width / testString.length + }; + }.call( this )); + } +}); +}); diff --git a/www/JBrowse/View/FileDialog.js b/www/JBrowse/View/FileDialog.js new file mode 100644 index 00000000..64d51cbe --- /dev/null +++ b/www/JBrowse/View/FileDialog.js @@ -0,0 +1,229 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/aspect', + 'dijit/focus', + 'dijit/form/Button', + 'dijit/form/RadioButton', + 'dojo/dom-construct', + 'dijit/Dialog', + 'dojox/form/Uploader', + 'dojox/form/uploader/plugins/IFrame', + './FileDialog/ResourceList', + './FileDialog/TrackList' + ], + function( + declare, + array, + aspect, + dijitFocus, + Button, + RadioButton, + dom, + Dialog, + Uploaded, + ignore, + ResourceList, + TrackList + ) { + +return declare( null, { + + constructor: function( args ) { + this.browser = args.browser; + this.config = dojo.clone( args.config || {} ); + this.browserSupports = { + dnd: 'draggable' in document.createElement('span') + }; + }, + + _makeActionBar: function( openCallback, cancelCallback ) { + var actionBar = dom.create( + 'div', { + className: 'dijitDialogPaneActionBar' + }); + + var disChoices = this.trackDispositionChoice = [ + new RadioButton({ id: 'openImmediately', + value: 'openImmediately', + checked: true + }), + new RadioButton({ id: 'addToTrackList', + value: 'addToTrackList' + }) + ]; + + var aux = dom.create('div',{className:'aux'},actionBar); + disChoices[0].placeAt(aux); + dom.create('label', { "for": 'openImmediately', innerHTML: 'Open immediately' }, aux ), + disChoices[1].placeAt(aux); + dom.create('label', { "for": 'addToTrackList', innerHTML: 'Add to tracks' }, aux ); + + + new Button({ iconClass: 'dijitIconDelete', label: 'Cancel', + onClick: dojo.hitch( this, function() { + cancelCallback && cancelCallback(); + this.dialog.hide(); + }) + }) + .placeAt( actionBar ); + new Button({ iconClass: 'dijitIconFolderOpen', + label: 'Open', + onClick: dojo.hitch( this, function() { + openCallback && openCallback({ + trackConfs: this.trackList.getTrackConfigurations(), + trackDisposition: this.trackDispositionChoice[0].checked ? this.trackDispositionChoice[0].value : + this.trackDispositionChoice[1].checked ? this.trackDispositionChoice[1].value : + undefined + }); + this.dialog.hide(); + }) + }) + .placeAt( actionBar ); + + return { domNode: actionBar }; + }, + + show: function( args ) { + var dialog = this.dialog = new Dialog( + { title: "Open files", className: 'fileDialog' } + ); + + var localFilesControl = this._makeLocalFilesControl(); + var remoteURLsControl = this._makeRemoteURLsControl(); + var resourceListControl = this._makeResourceListControl(); + var trackListControl = this._makeTrackListControl(); + var actionBar = this._makeActionBar( args.openCallback, args.cancelCallback ); + + // connect the local files control to the resource list + dojo.connect( localFilesControl.uploader, 'onChange', function() { + resourceListControl.addLocalFiles( localFilesControl.uploader._files ); + }); + + // connect the remote URLs control to the resource list + dojo.connect( remoteURLsControl, 'onChange', function( urls ) { + resourceListControl.clearURLs(); + resourceListControl.addURLs( urls ); + }); + + // connect the resource list to the track list + dojo.connect( resourceListControl, 'onChange', function( resources ) { + trackListControl.update( resources ); + }); + + var div = function( attr, children ) { + var d = dom.create('div', attr ); + array.forEach( children, dojo.hitch( d, 'appendChild' )); + return d; + }; + var content = [ + dom.create( 'div', { className: 'intro', innerHTML: 'Add any combination of data files and URLs, and JBrowse will automatically suggest tracks to display their contents.' } ), + div( { className: 'resourceControls' }, + [ localFilesControl.domNode, remoteURLsControl.domNode ] + ), + resourceListControl.domNode, + trackListControl.domNode, + actionBar.domNode + ]; + dialog.set( 'content', content ); + dialog.show(); + + aspect.after( dialog, 'hide', dojo.hitch( this, function() { + dijitFocus.curNode && dijitFocus.curNode.blur(); + setTimeout( function() { dialog.destroyRecursive(); }, 500 ); + })); + }, + + _makeLocalFilesControl: function() { + var container = dom.create('div', { className: 'localFilesControl' }); + + dom.create('h3', { innerHTML: 'Local files' }, container ); + + var dragArea = dom.create('div', { className: 'dragArea' }, container ); + + var fileBox = new dojox.form.Uploader({ + multiple: true + }); + fileBox.placeAt( dragArea ); + + if( this.browserSupports.dnd ) { + // let the uploader process any files dragged into the dialog + fileBox.addDropTarget( this.dialog.domNode ); + + // add a message saying you can drag files in + dom.create( + 'div', { + className: 'dragMessage', + innerHTML: 'Select or drag files here.' + }, dragArea + ); + } + + // little elements used to show pipeline-like connections between the controls + dom.create( 'div', { className: 'connector', innerHTML: ' '}, container ); + + return { domNode: container, uploader: fileBox }; + }, + + _makeRemoteURLsControl: function() { + var container = dom.create('div', { className: 'remoteURLsControl' }); + + // make the input elements + dom.create('h3', { innerHTML: 'Remote URLs - one per line' }, container ); + + // the onChange here will be connected to by the other parts + // of the dialog to propagate changes to the text in the box + var self = { domNode: container, + onChange: function(urls) { + //console.log('urls changed'); + } + }; + self.input = dom.create( 'textarea', { + className: 'urlInput', + placeHolder: "http://paste.urls.here/example.bam", + cols: 25, + rows: 5, + spellcheck: false + }, container ); + + // set up the handlers to propagate changes + var realChange = function() { + var text = dojo.trim( self.input.value ); + var urls = text.length ? text.split( /\s+/ ) : []; + self.onChange( urls ); + }; + // watch the input text for changes. just do it every 700ms + // because there are many ways that text can get changed (like + // pasting), not all of which fire the same events. not using + // the onchange event, because that doesn't fire until the + // textarea loses focus. + var previousText = ''; + var checkFrequency = 900; + var checkForChange = function() { + // compare with all whitespace changed to commas so that + // we are insensitive to changes in whitespace + if( self.input.value.replace(/\s+/g,',') != previousText ) { + realChange(); + previousText = self.input.value.replace(/\s+/g,','); + } + window.setTimeout( checkForChange, checkFrequency ); + }; + window.setTimeout( checkForChange, checkFrequency ); + + // little elements used to show pipeline-like connections between the controls + dom.create( 'div', { className: 'connector', innerHTML: ' '}, container ); + + return self; + }, + + _makeResourceListControl: function () { + var rl = new ResourceList({ dialog: this }); + return rl; + }, + _makeTrackListControl: function() { + var tl = new TrackList({ browser: this.browser }); + this.trackList = tl; + return tl; + } +}); +}); diff --git a/www/JBrowse/View/FileDialog/ResourceList.js b/www/JBrowse/View/FileDialog/ResourceList.js new file mode 100644 index 00000000..70c53526 --- /dev/null +++ b/www/JBrowse/View/FileDialog/ResourceList.js @@ -0,0 +1,156 @@ +define( ['dojo/_base/declare', + 'dojo/_base/array', + 'dojo/dom-construct', + 'dijit/form/Select' + ], + function( declare, array, dom, Select ) { + +return declare( null, { + + constructor: function( args ) { + this.dialog = args.dialog; + this.domNode = dom.create( 'div', { className: 'resourceList' } ); + this._updateView(); + }, + + clearLocalFiles: function() { + this._resources = array.filter( this._resources || [], function(res) { + return ! res.file; + }); + this._notifyChange(); + }, + + _notifyChange: function() { + this.onChange( array.map( this._resources || [], function( res ) { + var r = {}; + if( res.file ) + r.file = res.file; + if( res.url ) + r.url = res.url; + r.type = res.type.get('value'); + return r; + })); + }, + + _addResources: function( resources ) { + var seenFile = {}; + var allRes = ( this._resources||[] ).concat( resources ); + this._resources = array.filter( allRes.reverse(), function( res ) { + var key = res.file && res.file.name || res.url; + if( seenFile[key] ) { + return false; + } + seenFile[key] = true; + return true; + }).reverse(); + + this._updateView(); + this._notifyChange(); + }, + + addLocalFiles: function( fileList ) { + this._addResources( array.map( fileList, function(file) { + return { file: file }; + })); + }, + + clearURLs: function() { + this._resources = array.filter( this._resources || [], function(res) { + return ! res.url; + }); + this._notifyChange(); + }, + addURLs: function( urls ) { + this._addResources( array.map( urls, function(u) { + return { url: u }; + })); + }, + + // old-style handler stub + onChange: function() { }, + + _updateView: function() { + var container = this.domNode; + dom.empty( container ); + + dom.create('h3', { innerHTML: 'Files and URLs' }, container ); + + if( (this._resources||[]).length ) { + var table = dom.create('table',{}, container ); + + // render rows in the resource table for each resource in our + // list + array.forEach( this._resources, function( res, i){ + var that = this; + var tr = dom.create('tr', {}, table ); + var name = res.url || res.file.name; + + // make a selector for the resource's type + var typeSelect = new Select({ + options: [ + { label: 'file type?', value: null }, + { label: "GFF3", value: "gff3" }, + { label: "BigWig", value: "bigwig" }, + { label: "BAM", value: "bam" }, + { label: "BAM index", value: "bai" }, + { label: "VCF+bgzip", value: "vcf.gz" }, + { label: "Tabix index", value: "tbi" } + ], + value: this.guessType( name ), + onChange: function() { + that._rememberedTypes = that._rememberedTypes||{}; + that._rememberedTypes[name] = this.get('value'); + that._notifyChange(); + } + }); + typeSelect.placeAt( dojo.create('td',{ width: '4%'},tr) ); + res.type = typeSelect; + + dojo.create( 'td', { + width: '1%', + innerHTML: '
' + },tr); + dojo.create('td',{ innerHTML: name },tr); + dojo.create('td',{ + width: '1%', + innerHTML: '
', + onclick: function(e) { + e.preventDefault && e.preventDefault(); + that.deleteResource( res ); + } + }, tr); + }, this); + } + else { + dom.create('div', { className: 'emptyMessage', + innerHTML: 'Add files and URLs using the controls above.' + }, + container); + } + + // little elements used to show pipeline-like connections between the controls + dom.create( 'div', { className: 'connector', innerHTML: ' '}, container ); + }, + + deleteResource: function( resource ) { + this._resources = array.filter( this._resources || [], function(res) { + return res !== resource; + }); + this._updateView(); + this._notifyChange(); + }, + + guessType: function( name ) { + return ( this._rememberedTypes||{} )[name] || ( + /\.bam$/i.test( name ) ? 'bam' : + /\.bai$/i.test( name ) ? 'bai' : + /\.gff3?$/i.test( name ) ? 'gff3' : + /\.(bw|bigwig)$/i.test( name ) ? 'bigwig' : + /\.vcf\.gz$/i.test( name ) ? 'vcf.gz' : + /\.tbi$/i.test( name ) ? 'tbi' : + null + ); + } + +}); +}); diff --git a/www/JBrowse/View/FileDialog/TrackList.js b/www/JBrowse/View/FileDialog/TrackList.js new file mode 100644 index 00000000..0753bb94 --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList.js @@ -0,0 +1,176 @@ +define(['dojo/_base/declare', + 'dojo/_base/array', + 'dojo/dom-construct', + 'JBrowse/Util', + 'dijit/form/TextBox', + 'dijit/form/Select', + 'dijit/form/Button', + './TrackList/BAMDriver', + './TrackList/BigWigDriver', + './TrackList/GFF3Driver', + './TrackList/VCFTabixDriver', + 'JBrowse/View/TrackConfigEditor' + ], + function( + declare, + array, + dom, + Util, + TextBox, + Select, + Button, + BAMDriver, + BigWigDriver, + GFF3Driver, + VCFTabixDriver, + TrackConfigEditor + ) { + +var uniqCounter = 0; + +return declare( null, { + +constructor: function( args ) { + this.browser = args.browser; + this.fileDialog = args.dialog; + this.domNode = dom.create('div', { className: 'trackList', innerHTML: 'track list!' }); + this.types = [ new BAMDriver(), new BigWigDriver(), new GFF3Driver(), new VCFTabixDriver() ]; + + this._updateDisplay(); +}, + + +getTrackConfigurations: function() { + return Util.dojof.values( this.trackConfs || {} ); +}, + +update: function( resources ) { + this.storeConfs = {}; + this.trackConfs = {}; + + this._makeStoreConfs( resources ); + + // make some track configurations from the store configurations + this._makeTrackConfs(); + + this._updateDisplay(); +}, + +_makeStoreConfs: function( resources ) { + // when called, rebuild the store and track configurations that we are going to add + this.storeConfs = this.storeConfs || {}; + + // anneal the given resources into a set of data store + // configurations by offering each file to each type driver in + // turn until no more are being accepted + var lastLength = 0; + while( resources.length && resources.length != lastLength ) { + resources = array.filter( resources, function( resource ) { + return ! array.some( this.types, function( typeDriver ) { + return typeDriver.tryResource( this.storeConfs, resource ); + },this); + },this); + + lastLength = resources.length; + } + + array.forEach( this.types, function( typeDriver ) { + typeDriver.finalizeConfiguration( this.storeConfs ); + },this); + + if( resources.length ) + console.warn( "Not all resources could be assigned to tracks. Unused resources:", resources ); +}, + +_makeTrackConfs: function() { + // object that maps store type -> default track type to use for the store + var typeMap = this.browser.getTrackTypes().trackTypeDefaults; + + for( var n in this.storeConfs ) { + var store = this.storeConfs[n]; + var trackType = typeMap[store.type] || 'JBrowse/View/Track/HTMLFeatures'; + + this.trackConfs = this.trackConfs || {}; + + this.trackConfs[ n ] = { + store: this.storeConfs[n], + label: n, + key: n.replace(/_\d+$/,'').replace(/_/g,' '), + type: trackType, + + autoscale: "local" // make locally-opened BigWig tracks default to local autoscaling + }; + } +}, + +_delete: function( trackname ) { + delete (this.trackConfs||{})[trackname]; + this._updateDisplay(); +}, + +_updateDisplay: function() { + var that = this; + + // clear it + dom.empty( this.domNode ); + + dom.create('h3', { innerHTML: 'New Tracks' }, this.domNode ); + + if( ! Util.dojof.keys( this.trackConfs||{} ).length ) { + dom.create('div', { className: 'emptyMessage', + innerHTML: 'None' + },this.domNode); + } else { + var table = dom.create('table', { innerHTML: 'NameDisplay'}, this.domNode ); + + var trackTypes = this.browser.getTrackTypes(); + + for( var n in this.trackConfs ) { + var t = this.trackConfs[n]; + var r = dom.create('tr', {}, table ); + new TextBox({ + value: t.key, + onChange: function() { t.key = this.get('value'); } + }).placeAt( dom.create('td',{ className: 'name' }, r ) ); + new Select({ + options: array.map( trackTypes.knownTrackTypes, function( t ) { + var l = trackTypes.trackTypeLabels[t] + || t.replace('JBrowse/View/Track/','').replace(/\//g, ' '); + return { label: l, value: t }; + }), + value: t.type, + onChange: function() { + t.type = this.get('value'); + } + }).placeAt( dom.create('td',{ className: 'type' }, r ) ); + + new Button({ + className: 'edit', + title: 'edit configuration', + innerHTML: 'Edit Configuration', + onClick: function() { + new TrackConfigEditor( t ) + .show( function( result) { + dojo.mixin( t, result.conf ); + that._updateDisplay(); + }); + } + }).placeAt( dom.create('td', { className: 'edit' }, r ) ); + + dojo.create('td',{ + width: '1%', + innerHTML: '
', + onclick: function(e) { + e.preventDefault && e.preventDefault(); + that._delete( n ); + } + }, r); + + dom.create('td',{ className: 'type' }, r ); + } + } +} + +}); +}); + diff --git a/www/JBrowse/View/FileDialog/TrackList/BAMDriver.js b/www/JBrowse/View/FileDialog/TrackList/BAMDriver.js new file mode 100644 index 00000000..0de79b99 --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList/BAMDriver.js @@ -0,0 +1,19 @@ +define([ + 'dojo/_base/declare', + './_IndexedFileDriver' + ], + function( declare, IndexedFileDriver ) { +return declare( IndexedFileDriver, { + name: 'BAM', + storeType: 'JBrowse/Store/SeqFeature/BAM', + + fileExtension: 'bam', + fileConfKey: 'bam', + fileUrlConfKey: 'urlTemplate', + + indexExtension: 'bai', + indexConfKey: 'bai', + indexUrlConfKey: 'baiUrlTemplate' +}); + +}); diff --git a/www/JBrowse/View/FileDialog/TrackList/BigWigDriver.js b/www/JBrowse/View/FileDialog/TrackList/BigWigDriver.js new file mode 100644 index 00000000..f6839c7b --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList/BigWigDriver.js @@ -0,0 +1,53 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/Util', + 'JBrowse/Model/FileBlob', + 'JBrowse/Model/XHRBlob' + ], + function( declare, Util, FileBlob, XHRBlob ) { +var uniqCounter = 0; +return declare( null, { + + storeType: 'JBrowse/Store/SeqFeature/BigWig', + + tryResource: function( configs, resource ) { + if( resource.type == 'bigwig' ) { + var basename = Util.basename( + resource.file ? resource.file.name : + resource.url ? resource.url : + '' + ); + if( !basename ) + return false; + + var newName = 'BigWig_'+basename+'_'+uniqCounter++; + configs[newName] = { + type: this.storeType, + blob: this._makeBlob( resource ), + name: newName + }; + return true; + } + else + return false; + }, + + // try to merge any singleton BAM and BAI stores. currently can only do this if there is one of each + finalizeConfiguration: function( configs ) { + }, + + _makeBlob: function( resource ) { + var r = resource.file ? new FileBlob( resource.file ) : + resource.url ? new XHRBlob( resource.url ) : + null; + if( ! r ) + throw 'unknown resource type'; + return r; + + }, + + confIsValid: function( conf ) { + return conf.blob || conf.urlTemplate; + } +}); +}); diff --git a/www/JBrowse/View/FileDialog/TrackList/GFF3Driver.js b/www/JBrowse/View/FileDialog/TrackList/GFF3Driver.js new file mode 100644 index 00000000..1c81037b --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList/GFF3Driver.js @@ -0,0 +1,53 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/Util', + 'JBrowse/Model/FileBlob', + 'JBrowse/Model/XHRBlob' + ], + function( declare, Util, FileBlob, XHRBlob ) { +var uniqCounter = 0; +return declare( null, { + + storeType: 'JBrowse/Store/SeqFeature/GFF3', + + tryResource: function( configs, resource ) { + if( resource.type == 'gff3' ) { + var basename = Util.basename( + resource.file ? resource.file.name : + resource.url ? resource.url : + '' + ); + if( !basename ) + return false; + + var newName = 'GFF3_'+basename+'_'+uniqCounter++; + configs[newName] = { + type: this.storeType, + blob: this._makeBlob( resource ), + name: newName + }; + return true; + } + else + return false; + }, + + // try to merge any singleton BAM and BAI stores. currently can only do this if there is one of each + finalizeConfiguration: function( configs ) { + }, + + _makeBlob: function( resource ) { + var r = resource.file ? new FileBlob( resource.file ) : + resource.url ? new XHRBlob( resource.url ) : + null; + if( ! r ) + throw 'unknown resource type'; + return r; + + }, + + confIsValid: function( conf ) { + return conf.blob || conf.urlTemplate; + } +}); +}); diff --git a/www/JBrowse/View/FileDialog/TrackList/VCFTabixDriver.js b/www/JBrowse/View/FileDialog/TrackList/VCFTabixDriver.js new file mode 100644 index 00000000..fc41898b --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList/VCFTabixDriver.js @@ -0,0 +1,19 @@ +define([ + 'dojo/_base/declare', + './_IndexedFileDriver' + ], + function( declare, IndexedFileDriver ) { +return declare( IndexedFileDriver, { + name: 'VCF+Tabix', + storeType: 'JBrowse/Store/SeqFeature/VCFTabix', + + fileExtension: 'vcf.gz', + fileConfKey: 'file', + fileUrlConfKey: 'urlTemplate', + + indexExtension: 'tbi', + indexConfKey: 'tbi', + indexUrlConfKey: 'tbiUrlTemplate' +}); + +}); diff --git a/www/JBrowse/View/FileDialog/TrackList/_IndexedFileDriver.js b/www/JBrowse/View/FileDialog/TrackList/_IndexedFileDriver.js new file mode 100644 index 00000000..23185d4d --- /dev/null +++ b/www/JBrowse/View/FileDialog/TrackList/_IndexedFileDriver.js @@ -0,0 +1,155 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/Util', + 'JBrowse/Model/FileBlob', + 'JBrowse/Model/XHRBlob' + ], + function( declare, Util, FileBlob, XHRBlob ) { +var uniqCounter = 0; +return declare( null, { + + tryResource: function( configs, resource ) { + if( resource.type == this.fileExtension ) { + var basename = Util.basename( + resource.file ? resource.file.name : + resource.url ? resource.url : + '' + ); + if( !basename ) + return false; + + // go through the configs and see if there is one for an index that seems to match + for( var n in configs ) { + var c = configs[n]; + if( Util.basename( c[ this.indexConfKey ] ? c[ this.indexConfKey ].url || c[this.indexConfKey].blob.name : c[this.indexUrlConfKey], '.'+this.indexExtension ) == basename ) { + // it's a match, put it in + c[this.fileConfKey] = this._makeBlob( resource ); + return true; + } + } + // go through again and look for index files that don't have the base extension in them + basename = Util.basename( basename, '.'+this.fileExtension ); + for( var n in configs ) { + var c = configs[n]; + if( Util.basename( c[this.indexConfKey] ? c[this.indexConfKey].url || c[this.indexConfKey].blob.name : c[this.indexUrlConfKey], '.'+this.indexExtension ) == basename ) { + // it's a match, put it in + c[this.fileConfKey] = this._makeBlob( resource ); + return true; + } + } + + // otherwise make a new store config for it + var newName = this.name+'_'+basename+'_'+uniqCounter++; + configs[newName] = { + type: this.storeType, + name: newName + }; + configs[newName][this.fileConfKey] = this._makeBlob( resource ); + + return true; + } else if( resource.type == this.indexExtension ) { + var basename = Util.basename( + resource.file ? resource.file.name : + resource.url ? resource.url : + '' + , '.'+this.indexExtension + ); + if( !basename ) + return false; + + // go through the configs and look for data files that match like zee.bam -> zee.bam.bai + for( var n in configs ) { + var c = configs[n]; + if( Util.basename( c[this.fileConfKey] ? c[this.fileConfKey].url || c[this.fileConfKey].blob.name : c[this.fileUrlConfKey] ) == basename ) { + // it's a match, put it in + c[this.indexConfKey] = this._makeBlob( resource ); + return true; + } + } + // go through again and look for data files that match like zee.bam -> zee.bai + for( var n in configs ) { + var c = configs[n]; + if( Util.basename( c[this.fileConfKey] ? c[this.fileConfKey].url || c[this.fileConfKey].blob.name : c[this.fileUrlConfKey], '.'+this.fileExtension ) == basename ) { + // it's a match, put it in + c[this.indexConfKey] = this._makeBlob( resource ); + return true; + } + } + + // otherwise make a new store + var newName = this.name+'_'+Util.basename(basename,'.'+this.fileExtension)+'_'+uniqCounter++; + configs[newName] = { + name: newName, + type: this.storeType + }; + + configs[newName][this.indexConfKey] = this._makeBlob( resource ); + return true; + } + else + return false; + }, + + // try to merge any singleton file and index stores. currently can only do this if there is one of each + finalizeConfiguration: function( configs ) { + var singletonIndexes = {}; + var singletonIndexCount = 0; + var singletonFiles = {}; + var singletonFileCount = 0; + for( var n in configs ) { + var conf = configs[n]; + if( (conf.bai || conf[this.indexUrlConfKey]) && ! ( conf.bam || conf[this.fileUrlConfKey] ) ) { + // singleton Index + singletonIndexCount++; + singletonIndexes[n] = conf; + } + else if(( conf.bam || conf[this.fileUrlConfKey] ) && ! ( conf.bai || conf[this.indexUrlConfKey]) ) { + // singleton File + singletonFileCount++; + singletonFiles[n] = conf; + } + } + + // if we have a single File and single Index left at the end, + // stick them together and we'll see what happens + if( singletonFileCount == 1 && singletonIndexCount == 1 ) { + for( var indexName in singletonIndexes ) { + for( var fileName in singletonFiles ) { + if( singletonIndexes[indexName][this.indexUrlConfKey] ) + singletonFiles[fileName][this.indexUrlConfKey] = singletonIndexes[indexName][this.indexUrlConfKey]; + if( singletonIndexes[indexName].bai ) + singletonFiles[fileName].bai = singletonIndexes[indexName].bai; + + delete configs[indexName]; + } + } + } + + // delete any remaining singleton Indexes, since they don't have + // a hope of working + for( var indexName in singletonIndexes ) { + delete configs[indexName]; + } + + // delete any remaining singleton Files, unless they are URLs + for( var fileName in singletonFiles ) { + if( ! configs[fileName][this.fileUrlConfKey] ) + delete configs[fileName]; + } + }, + + _makeBlob: function( resource ) { + var r = resource.file ? new FileBlob( resource.file ) : + resource.url ? new XHRBlob( resource.url ) : + null; + if( ! r ) + throw 'unknown resource type'; + return r; + + }, + + confIsValid: function( conf ) { + return (conf[this.fileConfKey] || conf[this.fileUrlConfKey]) && ( conf[this.indexConfKey] || conf[this.indexUrlConfKey] || conf[this.fileUrlConfKey] ); + } +}); +}); diff --git a/www/JBrowse/View/GranularRectLayout.js b/www/JBrowse/View/GranularRectLayout.js new file mode 100644 index 00000000..abe501f1 --- /dev/null +++ b/www/JBrowse/View/GranularRectLayout.js @@ -0,0 +1,199 @@ +/** + * Rectangle-layout manager that lays out rectangles using bitmaps at + * resolution that, for efficiency, may be somewhat lower than that of + * the coordinate system for the rectangles being laid out. `pitchX` + * and `pitchY` are the ratios of input scale resolution to internal + * bitmap resolution. + */ + +define( + ['dojo/_base/declare'], + function( declare ) { +return declare( null, +{ + + + /** + * @param args.pitchX layout grid pitch in the X direction + * @param args.pitchY layout grid pitch in the Y direction + * @param args.maxHeight maximum layout height, default Infinity (no max) + */ + constructor: function( args ) { + this.pitchX = args.pitchX || 10; + this.pitchY = args.pitchY || 10; + + this.displayMode = args.displayMode; + + // reduce the pitchY to try and pack the features tighter + if( this.displayMode == 'compact' ) { + this.pitchY = Math.round( this.pitchY/4 ) || 1; + this.pitchX = Math.round( this.pitchX/4 ) || 1; + } + + this.bitmap = []; + this.rectangles = {}; + this.maxHeight = Math.ceil( ( args.maxHeight || Infinity ) / this.pitchY ); + this.pTotalHeight = 0; // total height, in units of bitmap squares (px/pitchY) + }, + + /** + * @returns {Number} top position for the rect, or Null if laying out the rect would exceed maxHeight + */ + addRect: function( id, left, right, height, data ) { + + // if we have already laid it out, return its layout + if( id in this.rectangles ) { + var storedRec = this.rectangles[id]; + if( storedRec.top === null ) + return null; + + // add it to the bitmap again, since that bitmap range may have been discarded + this._addRectToBitmap( storedRec, data ); + return storedRec.top * this.pitchY; + } + + var pLeft = Math.floor( left / this.pitchX ); + var pRight = Math.floor( right / this.pitchX ); + var pHeight = Math.ceil( height / this.pitchY ); + + var midX = Math.floor((pLeft+pRight)/2); + var rectangle = { id: id, l: pLeft, r: pRight, mX: midX, h: pHeight }; + if( data ) + rectangle.data = data; + + var maxTop = this.maxHeight - pHeight; + for(var top = 0; top <= maxTop; top++ ){ + if( ! this._collides( rectangle, top ) ) + break; + } + + if( top > maxTop ) { + rectangle.top = top = null; + this.rectangles[id] = rectangle; + this.pTotalHeight = Math.max( this.pTotalHeight||0, top+pHeight ); + return null; + } + else { + rectangle.top = top; + this._addRectToBitmap( rectangle, data ); + this.rectangles[id] = rectangle; + this.pTotalHeight = Math.max( this.pTotalHeight||0, top+pHeight ); + return top * this.pitchY; + } + }, + + _collides: function( rect, top ) { + if( this.displayMode == "collapsed" ) + return false; + + var bitmap = this.bitmap; + //var mY = top + rect.h/2; // Y midpoint: ( top+height + top ) / 2 + + // test the left first, then right, then middle + var mRow = bitmap[top]; + if( mRow && ( mRow[rect.l] || mRow[rect.r] || mRow[rect.mX]) ) + return true; + + // finally, test exhaustively + var maxY = top+rect.h; + for( var y = top; y < maxY; y++ ) { + var row = bitmap[y]; + if( row ) { + if( row.allFilled ) + return true; + if( row.length > rect.l ) + for( var x = rect.l; x <= rect.r; x++ ) + if( row[x] ) + return true; + } + } + + return false; + }, + + /** + * make a subarray if it does not exist + * @private + */ + _autovivify: function( array, subscript ) { + return array[subscript] || + (function() { var a = []; array[subscript] = a; return a; })(); + }, + + _addRectToBitmap: function( rect, data ) { + if( rect.top === null ) + return; + + data = data || true; + var bitmap = this.bitmap; + var av = this._autovivify; + var yEnd = rect.top+rect.h; + if( rect.r-rect.l > 20000 ) { + // the rect is very big in relation to the view size, just + // pretend, for the purposes of layout, that it extends + // infinitely. this will cause weird layout if a user + // scrolls manually for a very, very long time along the + // genome at the same zoom level. but most users will not + // do that. hopefully. + for( var y = rect.top; y < yEnd; y++ ) { + av(bitmap,y).allFilled = data; + } + } + else { + for( var y = rect.top; y < yEnd; y++ ) { + var row = av(bitmap,y); + for( var x = rect.l; x <= rect.r; x++ ) + row[x] = data; + } + } + }, + + /** + * Given a range of X coordinates, deletes all data dealing with + * the features. + */ + discardRange: function( left, right ) { + //console.log( 'discard', left, right ); + var pLeft = Math.floor( left / this.pitchX ); + var pRight = Math.floor( right / this.pitchX ); + var bitmap = this.bitmap; + for( var y = 0; y < bitmap.length; ++y ) { + var row = bitmap[y]; + if( row ) + for( var x = pLeft; x <= pRight; ++x ) { + delete row[x]; + } + } + }, + + hasSeen: function( id ) { + return !! this.rectangles[id]; + }, + + getByCoord: function( x, y ) { + var pY = Math.floor( y / this.pitchY ); + var r = this.bitmap[pY]; + if( ! r ) return undefined; + return r.allFilled || function() { + var pX = Math.floor( x / this.pitchX ); + return r[pX]; + }.call(this); + }, + + getByID: function( id ) { + var r = this.rectangles[id]; + if( r ) { + return r.data || true; + } + return undefined; + }, + + cleanup: function() { + }, + + getTotalHeight: function() { + return this.pTotalHeight * this.pitchY; + } +} +); +}); \ No newline at end of file diff --git a/www/JBrowse/View/InfoDialog.js b/www/JBrowse/View/InfoDialog.js new file mode 100644 index 00000000..46e66b7b --- /dev/null +++ b/www/JBrowse/View/InfoDialog.js @@ -0,0 +1,75 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dijit/focus', + 'JBrowse/View/Dialog/WithActionBar', + 'dojo/on', + 'dijit/form/Button' + ], + function( declare, array, focus, ActionBarDialog, on, dijitButton ) { + +return declare( ActionBarDialog, + + /** + * JBrowse ActionDialog subclass with a few customizations that make it + * more pleasant for use as an information popup. + * @lends JBrowse.View.InfoDialog + */ +{ + refocus: false, + autofocus: false, + + _fillActionBar: function( actionBar ) { + new dijitButton({ + className: 'OK', + label: 'OK', + onClick: dojo.hitch(this,'hide'), + focus: false + }) + .placeAt( actionBar); + }, + + show: function() { + + this.inherited( arguments ); + + var thisB = this; + + // holds the handles for the extra events we are registering + // so we can clean them up in the hide() method + this._extraEvents = []; + + // make it so that clicking outside the dialog (on the underlay) will close it + var underlay = ((dijit||{})._underlay||{}).domNode; + if( underlay ) { + this._extraEvents.push( + on( underlay, 'click', dojo.hitch( this, 'hideIfVisible' )) + ); + } + + // also make ESCAPE or ENTER close the dialog box + this._extraEvents.push( + on( document.body, 'keydown', function( evt ) { + if( [ dojo.keys.ESCAPE, dojo.keys.ENTER ].indexOf( evt.keyCode ) >= 0 ) + thisB.hideIfVisible(); + }) + ); + + focus.focus( this.closeButtonNode ); + }, + + hideIfVisible: function() { + if( this.get('open') ) + this.hide(); + }, + + hide: function() { + this.inherited(arguments); + + array.forEach( this._extraEvents, function( e ) { + e.remove(); + }); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/LocationChoiceDialog.js b/www/JBrowse/View/LocationChoiceDialog.js new file mode 100644 index 00000000..7749b6a5 --- /dev/null +++ b/www/JBrowse/View/LocationChoiceDialog.js @@ -0,0 +1,108 @@ +/** + * Dialog box that prompts the user to choose between several + * different available locations to navigate to. + */ + +define([ + 'dojo/_base/declare', + 'dojo/dom-construct', + 'dojo/aspect', + 'dijit/Dialog', + 'dijit/form/Button', + 'dijit/focus', + 'JBrowse/View/LocationList' + ], + function( + declare, + dom, + aspect, + Dialog, + dijitButton, + dijitFocus, + LocationListView + ) { +return declare( null, { + + /** + * @param args.browser the Browser object + * @param args.locationChoices [Array] array of Location objects + * to choose from. The locations can optionally have 'label', + * 'description', and/or 'score' attributes, which will be + * displayed as columns. + * @param args.title optional title of the dialog box. + * @param args.prompt optional text prompt to show at the top of the dialog. + * @param args.goCallback optional function to call for executing a 'Go' action. gets ( location, value, node, options ) + * @param args.showCallback optional function to call for executing a 'Show' action. gets ( location, value, node, options) + */ + constructor: function( args ) { + this.browser = args.browser; + this.config = dojo.clone( args.config || {} ); + this.locationChoices = args.locationChoices || []; + this.title = args.title || 'Choose location'; + this.prompt = args.prompt; + this.goCallback = args.goCallback; + this.showCallback = args.showCallback; + }, + + show: function() { + var dialog = this.dialog = new Dialog( + { title: this.title, + className: 'locationChoiceDialog', + style: { width: '70%' } + } + ); + var container = dom.create('div',{}); + + // show the description if there is one + if( this.prompt ) { + dom.create('div', { + className: 'prompt', + innerHTML: this.prompt + }, container ); + } + + var browser = this.browser; + this.locationListView = new LocationListView( + { browser: browser, + locations: this.locationChoices, + buttons: [ + { + className: 'show', + innerHTML: 'Show', + onClick: this.showCallback || function( location ) { + browser.showRegionWithHighlight( location ); + } + }, + { + className: 'go', + innerHTML: 'Go', + onClick: this.goCallback || function( location ) { + dialog.hide(); + browser.showRegionWithHighlight( location ); + } + } + ] + }, + dom.create( 'div', { + className: 'locationList', + style: { maxHeight: 0.5*this.browser.container.offsetHeight+'px'} + },container) + ); + + this.actionBar = dojo.create( 'div', { className: 'infoDialogActionBar dijitDialogPaneActionBar' }); + new dijitButton({ iconClass: 'dijitIconDelete', + label: 'Cancel', onClick: dojo.hitch( dialog, 'hide' ) + }) + .placeAt(this.actionBar); + + dialog.set( 'content', [ container, this.actionBar ] ); + dialog.show(); + aspect.after( dialog, 'hide', dojo.hitch( this, function() { + dijitFocus.curNode && dijitFocus.curNode.blur(); + setTimeout( function() { + dialog.destroyRecursive(); + }, 500 ); + })); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/LocationList.js b/www/JBrowse/View/LocationList.js new file mode 100644 index 00000000..83942871 --- /dev/null +++ b/www/JBrowse/View/LocationList.js @@ -0,0 +1,84 @@ +/** + * Generic component that displays a list of genomic locations, along + * with buttons to execute actions on them. + */ + +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/dom-construct', + 'dijit/form/Button', + 'JBrowse/Util', + 'dojo/store/Memory', + 'dgrid/OnDemandGrid', + 'dgrid/extensions/DijitRegistry' + ], + function( + declare, + array, + dom, + dijitButton, + Util, + MemoryStore, + DGrid, + DGridDijitRegistry + ) { + +var Grid = declare([DGrid,DGridDijitRegistry]); + +return declare(null,{ + constructor: function( args, parent ) { + var thisB = this; + this.browser = args.browser; + + // transform our data first, so that it's sortable. + var locations = array.map( args.locations || [], function(l) { + return { locstring: Util.assembleLocString( l ), + location: l, + label: l.label || l.objectName, + description: l.description, + score: l.score, + tracks: array.map( array.filter( l.tracks || [], function(t) { return t; }), // remove nulls + function(t) { + return t.key || t.name || t.label || t; + }) + .join(', ') + }; + }); + + // build the column list + var columns = []; + if( array.some( locations, function(l) { return l.label; }) ) + columns.unshift( { label: 'Name', field: 'label' } ); + if( array.some( locations, function(l) { return l.description; }) ) + columns.unshift( { label: 'Description', field: 'description' } ); + if( array.some( locations, function(l) { return l.score; }) ) + columns.unshift( { label: 'Score', field: 'score' } ); + columns.push({ label: 'Location', field: 'locstring' }); + if( locations.length && locations[0].tracks ) + columns.push({ label: 'Track', field: 'tracks' }); + if( args.buttons ) { + columns.push({ + label: '', + className: 'goButtonColumn', + renderCell: function( object, value, node, options ) { + var container = dom.create('div'); + array.forEach( args.buttons, function( button ) { + var buttonArgs = dojo.mixin( {}, button ); + buttonArgs.onClick = function() { button.onClick( object.location, value, node, options ); }; + new dijitButton( buttonArgs ).placeAt( container ); + }); + return container; + } + }); + } + + + // create the grid + this.grid = new Grid({ + columns: columns, + store: new MemoryStore({ data: locations } ) + }, parent ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Ruler.js b/www/JBrowse/View/Ruler.js new file mode 100644 index 00000000..d9a7d3a4 --- /dev/null +++ b/www/JBrowse/View/Ruler.js @@ -0,0 +1,84 @@ +define( [ + 'dojo/query', + 'dojox/charting/Chart', + 'dojox/charting/axis2d/Default', + 'dojox/charting/plot2d/Bubble', + 'dojo/NodeList-dom', + 'dojo/number' + ], + function( query, Chart ) { +/** + * Ruler, with ticks and numbers, drawn with HTML elements. Can be + * stretched to any length. + * + * @class + * @constructor + * + * @param {Number} args.min + * @param {Number} args.max + * @param {String} [args.direction="up"] The direction of increasing numbers. + * Either "up" or "down". + * @param {Boolean} args.leftBottom=true Should the ticks and labels be on the right + * or the left. + * + */ + +function Ruler(args) { + dojo.mixin( this, args ); +}; + +Ruler.prototype.render_to = function( target_div ) { + if( typeof target_div == 'string' ) + target_div = dojo.byId( target_div ); + + var target_dims = dojo.position( target_div ); + + + // make an inner container that's styled to compensate for the + // 12px edge-padding that dojox.charting has builtin that we can't + // change, making the tick marks align correctly with the images + var label_digits = Math.floor( Math.log(this.max+1)/Math.log(10))+1; + + var container = dojo.create( + 'div', { + style: { + position: 'absolute', + left: "-9px", + bottom: "-9px", + width: '10px', + height: (target_dims.h+18)+"px" + } + }, + target_div ); + + try { + var chart1 = new Chart( container, {fill: 'transparent'} ); + chart1.addAxis( "y", { + vertical: true, + fill: 'transparent', + min: this.min, + max: this.max, + fixLower: this.fixBounds ? 'major' : 'none', + fixUpper: this.fixBounds ? 'major' : 'none', + leftBottom: this.leftBottom + // minorTickStep: 0.5, + // majorTickStep: 1 + //labels: [{value: 1, text: "One"}, {value: 3, text: "Ten"}] + }); + chart1.addPlot("default", {type: "Bubble", fill: 'transparent'}); + chart1.render(); + + // hack to remove undesirable opaque white rectangles. do + // this a little bit later + query('svg rect', chart1.domNode ).orphan(); + + this.scaler = chart1.axes.y.scaler; + } catch (x) { + console.error(x+''); + console.error("Failed to draw Ruler with SVG, your browser may not support the necessary technology."); + target_div.removeChild( container ); + } +}; + +return Ruler; +}); diff --git a/www/JBrowse/View/Track/Alignments.js b/www/JBrowse/View/Track/Alignments.js new file mode 100644 index 00000000..5a981ef3 --- /dev/null +++ b/www/JBrowse/View/Track/Alignments.js @@ -0,0 +1,154 @@ +define( ['dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Util', + 'JBrowse/View/Track/HTMLFeatures', + 'JBrowse/View/Track/_AlignmentsMixin' + ], + function( declare, array, Util, HTMLFeatures, AlignmentsMixin ) { + +// return declare( HTMLFeatures, +return declare( [ HTMLFeatures, AlignmentsMixin], +/** + * @lends JBrowse.View.Track.Alignments + */ +{ + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + maxFeatureScreenDensity: 1.5, + layoutPitchY: 4, + style: { + _defaultLabelScale: 50, + className: 'alignment', + arrowheadClass: 'arrowhead', + centerChildrenVertically: true, + showMismatches: true, + showSubfeatures: false + } + } + ); + }, + + renderFeature: function( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ) { + var featDiv = this.inherited( arguments ); + if( ! featDiv ) + return null; + + var displayStart = Math.max( feature.get('start'), containerStart ); + var displayEnd = Math.min( feature.get('end'), containerEnd ); + if( this.config.style.showMismatches ) { + this._drawMismatches( feature, featDiv, scale, displayStart, displayEnd ); + } + + // if this feature is part of a multi-segment read, and not + // all of its segments are aligned, add missing_mate to its + // class + if( feature.get('multi_segment_template') && !feature.get('multi_segment_all_aligned') ) + featDiv.className += ' missing_mate'; + + return featDiv; + }, + + + handleSubFeatures: function( feature, featDiv, + displayStart, displayEnd, block ) { + if( this.config.style.showSubfeatures ) { + this.inherited(arguments); + } + }, + + /** + * draw base-mismatches on the feature + */ + _drawMismatches: function( feature, featDiv, scale, displayStart, displayEnd ) { + var featLength = displayEnd - displayStart; + // recall: scale is pixels/basepair + if ( featLength*scale > 1 ) { + var mismatches = this._getMismatches( feature ); + var charSize = this.getCharacterMeasurements(); + var drawChars = scale >= charSize.w; + array.forEach( mismatches, function( mismatch ) { + var start = feature.get('start') + mismatch.start; + var end = start + mismatch.length; + + // if the feature has been truncated to where it doesn't cover + // this mismatch anymore, just skip this mismatch + if ( end <= displayStart || start >= displayEnd ) + return; + + var base = mismatch.base; + var mDisplayStart = Math.max( start, displayStart ); + var mDisplayEnd = Math.min( end, displayEnd ); + var mDisplayWidth = mDisplayEnd - mDisplayStart; + var overall = dojo.create('span', { + className: mismatch.type + ' base_'+base.toLowerCase(), + style: { + position: 'absolute', + left: 100 * ( mDisplayStart - displayStart)/featLength + '%', + width: scale*mDisplayWidth>1 ? 100 * mDisplayWidth/featLength + '%' : '1px' + } + }, featDiv ); + + // give the mismatch a mouseover if not drawing a character with the mismatch base + if( ! drawChars ) + overall.title = base; + + if( drawChars && mismatch.length <= 20 ) { + for( var i = 0; i= mDisplayStart && basePosition <= mDisplayEnd ) { + dojo.create('span',{ + className: 'base base_'+base.toLowerCase(), + style: { + position: 'absolute', + width: scale+'px', + left: (basePosition-mDisplayStart)/mDisplayWidth*100 + '%' + }, + innerHTML: base + }, overall ); + } + } + } + }, this ); + } + }, + + /** + * @returns {Object} containing h and w, + * in pixels, of the characters being used for sequences + */ + getCharacterMeasurements: function() { + if( !this._measurements ) + this._measurements = this._measureSequenceCharacterSize( this.div ); + return this._measurements; + }, + + /** + * Conducts a test with DOM elements to measure sequence text width + * and height. + */ + _measureSequenceCharacterSize: function( containerElement ) { + var widthTest = dojo.create('div', { + innerHTML: 'A' + +'C' + +'T' + +'G' + +'N', + style: { + visibility: 'hidden', + position: 'absolute', + left: '0px' + } + }, containerElement ); + var result = { + w: widthTest.clientWidth / 5, + h: widthTest.clientHeight + }; + containerElement.removeChild(widthTest); + return result; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Alignments2.js b/www/JBrowse/View/Track/Alignments2.js new file mode 100644 index 00000000..11b0a2c0 --- /dev/null +++ b/www/JBrowse/View/Track/Alignments2.js @@ -0,0 +1,29 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Util', + 'JBrowse/View/Track/CanvasFeatures', + 'JBrowse/View/Track/_AlignmentsMixin' + ], + function( declare, array, Util, CanvasFeatureTrack, AlignmentsMixin ) { + +return declare( [ CanvasFeatureTrack, AlignmentsMixin ], { + + constructor: function() { + }, + + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + glyph: 'JBrowse/View/FeatureGlyph/Alignment', + maxFeatureGlyphExpansion: 0, + style: { + showLabels: false + } + } + ); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/BlockBased.js b/www/JBrowse/View/Track/BlockBased.js new file mode 100644 index 00000000..dee9aa48 --- /dev/null +++ b/www/JBrowse/View/Track/BlockBased.js @@ -0,0 +1,1133 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/_base/array', + 'dojo/json', + 'dojo/aspect', + 'dojo/dom-construct', + 'dojo/dom-geometry', + 'dojo/dom-class', + 'dojo/dom-style', + 'dojo/query', + 'dojo/on', + 'dijit/Destroyable', + 'JBrowse/View/InfoDialog', + 'dijit/Dialog', + 'dijit/Menu', + 'dijit/PopupMenuItem', + 'dijit/MenuItem', + 'dijit/CheckedMenuItem', + 'dijit/MenuSeparator', + 'JBrowse/Util', + 'JBrowse/Component', + 'JBrowse/FeatureFiltererMixin', + 'JBrowse/Errors', + 'JBrowse/View/TrackConfigEditor', + 'JBrowse/View/ConfirmDialog', + 'JBrowse/View/Track/BlockBased/Block', + 'JBrowse/View/DetailsMixin' + ], + function( declare, + lang, + array, + JSON, + aspect, + domConstruct, + domGeom, + domClass, + domStyle, + query, + on, + Destroyable, + InfoDialog, + Dialog, + dijitMenu, + dijitPopupMenuItem, + dijitMenuItem, + dijitCheckedMenuItem, + dijitMenuSeparator, + Util, + Component, + FeatureFiltererMixin, + Errors, + TrackConfigEditor, + ConfirmDialog, + Block, + DetailsMixin + ) { + +// we get `own` and `destroy` from Destroyable, see dijit/Destroyable docs + +return declare( [Component,DetailsMixin,FeatureFiltererMixin,Destroyable], +/** + * @lends JBrowse.View.Track.BlockBased.prototype + */ +{ + /** + * Base class for all JBrowse tracks. + * @constructs + */ + constructor: function( args ) { + args = args || {}; + + this.refSeq = args.refSeq; + this.name = args.label || this.config.label; + this.key = args.key || this.config.key || this.name; + + this._changedCallback = args.changeCallback || function(){}; + // initial height may be derived from track config + this.height = this.config.height || 0; + this.shown = true; + this.empty = false; + this.browser = args.browser; + + this.setFeatureFilterParentComponent( this.browser.view ); + + this.store = args.store; + }, + + /** + * Returns object holding the default configuration for this track + * type. Might want to override in subclasses. + * @private + */ + _defaultConfig: function() { + return { + maxFeatureSizeForUnderlyingRefSeq: 250000 + }; + }, + + heightUpdate: function(height, blockIndex) { + + if (!this.shown) { + this.heightUpdateCallback(0); + return; + } + + if (blockIndex !== undefined) + this.blockHeights[blockIndex] = height; + + this.height = Math.max( this.height, height ); + + if ( ! this.inShowRange ) { + this.heightUpdateCallback( Math.max( this.labelHeight, this.height ) ); + + // reposition any height-overflow markers in our blocks + query( '.height_overflow_message', this.div ) + .style( 'top', this.height - 16 + 'px' ); + } + }, + + setViewInfo: function( genomeView, heightUpdate, numBlocks, + trackDiv, + widthPct, widthPx, scale) { + this.genomeView = genomeView; + this.heightUpdateCallback = heightUpdate; + this.div = trackDiv; + this.widthPct = widthPct; + this.widthPx = widthPx; + + this.leftBlank = document.createElement("div"); + this.leftBlank.className = "blank-block"; + this.rightBlank = document.createElement("div"); + this.rightBlank.className = "blank-block"; + this.div.appendChild(this.rightBlank); + this.div.appendChild(this.leftBlank); + + this.sizeInit(numBlocks, widthPct); + this.labelHTML = ""; + this.labelHeight = 0; + + if( this.config.pinned ) + this.setPinned( true ); + + if( ! this.label ) { + this.makeTrackLabel(); + } + this.setLabel( this.key ); + }, + + makeTrackLabel: function() { + var className = "track-label"; + if (!this.config.pinned) { + className = className + " dojoDndHandle" + } + var labelDiv = dojo.create( + 'div', { + className: className, + id: "label_" + this.name, + style: { + position: 'absolute', + top: 0 + } + },this.div); + + this.label = labelDiv; + + if ( ( this.config.style || {} ).trackLabelCss){ + labelDiv.style.cssText += ";" + trackConfig.style.trackLabelCss; + } + + var labelText = dojo.create('span', { className: 'track-label-text' }, labelDiv ); + }, + + + hide: function() { + if (this.shown) { + this.div.style.display = "none"; + this.shown = false; + } + }, + + show: function() { + if (!this.shown) { + this.div.style.display = "block"; + this.shown = true; + } + }, + + initBlocks: function() { + this.blocks = new Array(this.numBlocks); + this.blockHeights = new Array(this.numBlocks); + for (var i = 0; i < this.numBlocks; i++) this.blockHeights[i] = 0; + this.firstAttached = null; + this.lastAttached = null; + this._adjustBlanks(); + }, + + clear: function() { + if (this.blocks) { + for (var i = 0; i < this.numBlocks; i++) + this._hideBlock(i); + } + this.initBlocks(); + this.makeTrackMenu(); + }, + + setLabel: function(newHTML) { + if (this.label === undefined || this.labelHTML == newHTML ) + return; + + this.labelHTML = newHTML; + query('.track-label-text',this.label) + .forEach(function(n){ n.innerHTML = newHTML; }); + this.labelHeight = this.label.offsetHeight; + }, + + /** + * Stub. + */ + transfer: function() {}, + + /** + * Stub. + */ + startZoom: function(destScale, destStart, destEnd) {}, + + /** + * Stub. + */ + endZoom: function(destScale, destBlockBases) { + }, + + + showRange: function(first, last, startBase, bpPerBlock, scale, + containerStart, containerEnd) { + + if( this.fatalError ) { + this.showFatalError( this.fatalError ); + return; + } + + if ( this.blocks === undefined || ! this.blocks.length ) + return; + + // this might make more sense in setViewInfo, but the label element + // isn't in the DOM tree yet at that point + if ((this.labelHeight == 0) && this.label) + this.labelHeight = this.label.offsetHeight; + + this.inShowRange = true; + this.height = this.height || this.labelHeight; + + var firstAttached = (null == this.firstAttached ? last + 1 : this.firstAttached); + var lastAttached = (null == this.lastAttached ? first - 1 : this.lastAttached); + + var i, leftBase; + var maxHeight = 0; + //fill left, including existing blocks (to get their heights) + for (i = lastAttached; i >= first; i--) { + leftBase = startBase + (bpPerBlock * (i - first)); + this._showBlock(i, leftBase, leftBase + bpPerBlock, scale, + containerStart, containerEnd); + } + //fill right + for (i = lastAttached + 1; i <= last; i++) { + leftBase = startBase + (bpPerBlock * (i - first)); + this._showBlock(i, leftBase, leftBase + bpPerBlock, scale, + containerStart, containerEnd); + } + + //detach left blocks + var destBlock = this.blocks[first]; + for (i = firstAttached; i < first; i++) { + this.transfer(this.blocks[i], destBlock, scale, + containerStart, containerEnd); + this.cleanupBlock(this.blocks[i]); + this._hideBlock(i); + } + //detach right blocks + destBlock = this.blocks[last]; + for (i = lastAttached; i > last; i--) { + this.transfer(this.blocks[i], destBlock, scale, + containerStart, containerEnd); + this.cleanupBlock(this.blocks[i]); + this._hideBlock(i); + } + + this.firstAttached = first; + this.lastAttached = last; + this._adjustBlanks(); + this.inShowRange = false; + + this.heightUpdate(this.height); + this.updateStaticElements( this.genomeView.getPosition() ); + }, + + cleanupBlock: function( block ) { + if( block ) + block.destroy(); + }, + + /** + * Called when this track object is destroyed. Cleans up things + * to avoid memory leaks. + */ + destroy: function() { + array.forEach( this.blocks || [], function( block ) { + this.cleanupBlock( block ); + }, this); + delete this.blocks; + delete this.div; + + this.inherited( arguments ); + }, + + _hideBlock: function(blockIndex) { + if (this.blocks[blockIndex]) { + this.div.removeChild( this.blocks[blockIndex].domNode ); + this.cleanupBlock( this.blocks[blockIndex] ); + this.blocks[blockIndex] = undefined; + this.blockHeights[blockIndex] = 0; + } + }, + + _adjustBlanks: function() { + if ((this.firstAttached === null) + || (this.lastAttached === null)) { + this.leftBlank.style.left = "0px"; + this.leftBlank.style.width = "50%"; + this.rightBlank.style.left = "50%"; + this.rightBlank.style.width = "50%"; + } else { + this.leftBlank.style.width = (this.firstAttached * this.widthPct) + "%"; + this.rightBlank.style.left = ((this.lastAttached + 1) + * this.widthPct) + "%"; + this.rightBlank.style.width = ((this.numBlocks - this.lastAttached - 1) + * this.widthPct) + "%"; + } + }, + + hideAll: function() { + if (null == this.firstAttached) return; + for (var i = this.firstAttached; i <= this.lastAttached; i++) + this._hideBlock(i); + + + this.firstAttached = null; + this.lastAttached = null; + this._adjustBlanks(); + }, + + // hides all blocks that overlap the given region/location + hideRegion: function( location ) { + if (null == this.firstAttached) return; + // hide all blocks that overlap the given region + for (var i = this.firstAttached; i <= this.lastAttached; i++) + if( this.blocks[i] && location.ref == this.refSeq.name && !( this.blocks[i].leftBase > location.end || this.blocks[i].rightBase < location.start ) ) + this._hideBlock(i); + + this._adjustBlanks(); + }, + + /** + * _changeCallback invoked here is passed in constructor, + * and typically is GenomeView.showVisibleBlocks() + */ + changed: function() { + this.hideAll(); + if( this._changedCallback ) + this._changedCallback(); + }, + + _makeLoadingMessage: function() { + var msgDiv = dojo.create( + 'div', { + className: 'loading', + innerHTML: '
Loading', + title: 'Loading data...', + style: { visibility: 'hidden' } + }); + window.setTimeout(function() { msgDiv.style.visibility = 'visible'; }, 200); + return msgDiv; + }, + + showFatalError: function( error ) { + query( '.block', this.div ) + .concat( query( '.blank-block', this.div ) ) + .concat( query( '.error', this.div ) ) + .orphan(); + this.blocks = []; + this.blockHeights = []; + + this.fatalErrorMessageElement = this._renderErrorMessage( error || this.fatalError, this.div ); + this.heightUpdate( domGeom.position( this.fatalErrorMessageElement ).h ); + this.updateStaticElements( this.genomeView.getPosition() ); + }, + + // generic handler for all types of errors + _handleError: function( error, viewArgs ) { + console.error( ''+error, error.stack, error ); + + var errorContext = dojo.mixin( {}, error ); + dojo.mixin( errorContext, viewArgs ); + + var isObject = typeof error == 'object'; + + if( isObject && error instanceof Errors.TimeOut && errorContext.block ) + this.fillBlockTimeout( errorContext.blockIndex, errorContext.block, error ); + else if( isObject && error instanceof Errors.DataOverflow ) { + if( errorContext.block ) + this.fillTooManyFeaturesMessage( errorContext.blockIndex, errorContext.block, error ); + else + array.forEach( this.blocks, function( block, blockIndex ) { + if( block ) + this.fillTooManyFeaturesMessage( blockIndex, block, error ); + },this); + } + else { + this.fatalError = error; + this.showFatalError( error ); + } + }, + + + fillBlockError: function( blockIndex, block, error ) { + error = error || this.fatalError || this.error; + + domConstruct.empty( block.domNode ); + var msgDiv = this._renderErrorMessage( error, block.domNode ); + this.heightUpdate( dojo.position(msgDiv).h, blockIndex ); + }, + + _renderErrorMessage: function( message, parent ) { + return domConstruct.create( + 'div', { + className: 'error', + innerHTML: '

Error

An error was encountered when displaying this track.
' + +( message ? '
Diagnostic message
'+message+'' : '' ), + title: 'An error occurred' + }, parent ); + }, + + fillTooManyFeaturesMessage: function( blockIndex, block, scale ) { + this.fillMessage( + blockIndex, + block, + 'Too much data to show' + + (scale >= this.browser.view.maxPxPerBp ? '': '; zoom in to see detail') + + '.' + ); + }, + + redraw: function() { + this.clear(); + this.genomeView.showVisibleBlocks(true); + }, + + markBlockHeightOverflow: function( block ) { + if( block.heightOverflowed ) + return; + + block.heightOverflowed = true; + domClass.add( block.domNode, 'height_overflow' ); + domConstruct.create( 'div', { + className: 'height_overflow_message', + innerHTML: 'Max height reached', + style: { + top: (this.height-16) + 'px', + height: '16px' + } + }, block.domNode ); + }, + + _showBlock: function(blockIndex, startBase, endBase, scale, + containerStart, containerEnd) { + if ( this.empty || this.fatalError ) { + this.heightUpdate( this.labelHeight ); + return; + } + + if (this.blocks[blockIndex]) { + this.heightUpdate(this.blockHeights[blockIndex], blockIndex); + return; + } + + var block = new Block({ + startBase: startBase, + endBase: endBase, + scale: scale, + node: { + className: 'block', + style: { + left: (blockIndex * this.widthPct) + "%", + width: this.widthPct + "%" + } + } + }); + this.blocks[blockIndex] = block; + this.div.appendChild( block.domNode ); + + var args = [blockIndex, + block, + this.blocks[blockIndex - 1], + this.blocks[blockIndex + 1], + startBase, + endBase, + scale, + this.widthPx, + containerStart, + containerEnd]; + + if( this.fatalError ) { + this.fillBlockError( blockIndex, block ); + return; + } + + // loadMessage is an opaque mask div that we place over the + // block until the fillBlock finishes + var loadMessage = this._makeLoadingMessage(); + block.domNode.appendChild( loadMessage ); + + var finish = function() { + if( block && loadMessage.parentNode ) + block.domNode.removeChild( loadMessage ); + }; + + try { + this.fillBlock({ + blockIndex: blockIndex, + block: block, + leftBlock: this.blocks[blockIndex - 1], + rightBlock: this.blocks[blockIndex + 1], + leftBase: startBase, + rightBase: endBase, + scale: scale, + stripeWidth: this.widthPx, + containerStart: containerStart, + containerEnd: containerEnd, + finishCallback: finish + }); + } catch( e ) { + console.error( e, e.stack ); + this.fillBlockError( blockIndex, block, e ); + finish(); + } + }, + + moveBlocks: function(delta) { + var newBlocks = new Array(this.numBlocks); + var newHeights = new Array(this.numBlocks); + var i; + for (i = 0; i < this.numBlocks; i++) + newHeights[i] = 0; + + var destBlock; + if ((this.lastAttached + delta < 0) + || (this.firstAttached + delta >= this.numBlocks)) { + this.firstAttached = null; + this.lastAttached = null; + } else { + this.firstAttached = Math.max(0, Math.min(this.numBlocks - 1, + this.firstAttached + delta)); + this.lastAttached = Math.max(0, Math.min(this.numBlocks - 1, + this.lastAttached + delta)); + if (delta < 0) + destBlock = this.blocks[this.firstAttached - delta]; + else + destBlock = this.blocks[this.lastAttached - delta]; + } + + for (i = 0; i < this.blocks.length; i++) { + var newIndex = i + delta; + if ((newIndex < 0) || (newIndex >= this.numBlocks)) { + //We're not keeping this block around, so delete + //the old one. + if (destBlock && this.blocks[i]) + this.transfer(this.blocks[i], destBlock); + this._hideBlock(i); + } else { + //move block + newBlocks[newIndex] = this.blocks[i]; + if (newBlocks[newIndex]) + newBlocks[newIndex].domNode.style.left = + ((newIndex) * this.widthPct) + "%"; + + newHeights[newIndex] = this.blockHeights[i]; + } + } + this.blocks = newBlocks; + this.blockHeights = newHeights; + this._adjustBlanks(); + }, + + sizeInit: function(numBlocks, widthPct, blockDelta) { + var i, oldLast; + this.numBlocks = numBlocks; + this.widthPct = widthPct; + if (blockDelta) this.moveBlocks(-blockDelta); + if (this.blocks && (this.blocks.length > 0)) { + //if we're shrinking, clear out the end blocks + var destBlock = this.blocks[numBlocks - 1]; + for (i = numBlocks; i < this.blocks.length; i++) { + if (destBlock && this.blocks[i]) + this.transfer(this.blocks[i], destBlock); + this._hideBlock(i); + } + oldLast = this.blocks.length; + this.blocks.length = numBlocks; + this.blockHeights.length = numBlocks; + //if we're expanding, set new blocks to be not there + for (i = oldLast; i < numBlocks; i++) { + this.blocks[i] = undefined; + this.blockHeights[i] = 0; + } + this.lastAttached = Math.min(this.lastAttached, numBlocks - 1); + if (this.firstAttached > this.lastAttached) { + //not sure if this can happen + this.firstAttached = null; + this.lastAttached = null; + } + + if( this.blocks.length != numBlocks ) + throw new Error( + "block number mismatch: should be " + + numBlocks + "; blocks.length: " + + this.blocks.length + ); + + for (i = 0; i < numBlocks; i++) { + if (this.blocks[i]) { + //if (!this.blocks[i].style) console.log(this.blocks); + this.blocks[i].domNode.style.left = (i * widthPct) + "%"; + this.blocks[i].domNode.style.width = widthPct + "%"; + } + } + } else { + this.initBlocks(); + } + + this.makeTrackMenu(); + }, + + fillMessage: function( blockIndex, block, message, class_ ) { + domConstruct.empty( block.domNode ); + var msgDiv = dojo.create( + 'div', { + className: class_ || 'message', + innerHTML: message + }, block.domNode ); + this.heightUpdate( domGeom.getMarginBox(msgDiv, domStyle.getComputedStyle(msgDiv)).h, blockIndex ); + }, + + /** + * Called by GenomeView when the view is scrolled: communicates the + * new x, y, width, and height of the view. This is needed by tracks + * for positioning stationary things like axis labels. + */ + updateStaticElements: function( /**Object*/ coords ) { + this.window_info = dojo.mixin( this.window_info || {}, coords ); + if( this.fatalErrorMessageElement ) { + this.fatalErrorMessageElement.style.width = this.window_info.width * 0.6 + 'px'; + if( 'x' in coords ) + this.fatalErrorMessageElement.style.left = coords.x+this.window_info.width * 0.2 +'px'; + } + + if( this.label && 'x' in coords ) + this.label.style.left = coords.x+'px'; + }, + + /** + * Render a dijit menu from a specification object. + * + * @param menuTemplate definition of the menu's structure + * @param context {Object} optional object containing the context + * in which any click handlers defined in the menu should be + * invoked, containing thing like what feature is being operated + * upon, the track object that is involved, etc. + * @param parent {dijit.Menu|...} parent menu, if this is a submenu + */ + _renderContextMenu: function( /**Object*/ menuStructure, /** Object */ context, /** dijit.Menu */ parent ) { + if ( !parent ) { + parent = new dijitMenu(); + this.own( parent ); + } + + for ( key in menuStructure ) { + var spec = menuStructure [ key ]; + try { + if ( spec.children ) { + var child = new dijitMenu(); + parent.addChild( child ); + parent.addChild( new dijitPopupMenuItem( + { + popup : child, + label : spec.label + })); + this._renderContextMenu( spec.children, context, child ); + } + else { + var menuConf = dojo.clone( spec ); + if( menuConf.action || menuConf.url || menuConf.href ) { + menuConf.onClick = this._makeClickHandler( spec, context ); + } + // only draw other menu items if they do something when clicked. + // drawing menu items that do nothing when clicked + // would frustrate users. + if( menuConf.label && !menuConf.onClick ) + menuConf.disabled = true; + + // currently can only use preloaded types + var class_ = { + 'dijit/MenuItem': dijitMenuItem, + 'dijit/CheckedMenuItem': dijitCheckedMenuItem, + 'dijit/MenuSeparator': dijitMenuSeparator + }[spec.type] || dijitMenuItem; + + parent.addChild( new class_( menuConf ) ); + } + } catch(e) { + console.error('failed to render menu item: '+e); + } + } + return parent; + }, + + _makeClickHandler: function( inputSpec, context ) { + var track = this; + + if( typeof inputSpec == 'function' ) { + inputSpec = { action: inputSpec }; + } + else if( typeof inputSpec == 'undefined' ) { + console.error("Undefined click specification, cannot make click handler"); + return function() {}; + } + + var handler = function ( evt ) { + if( track.genomeView.dragging ) + return; + + var ctx = context || this; + var spec = track._processMenuSpec( dojo.clone( inputSpec ), ctx ); + var url = spec.url || spec.href; + spec.url = url; + var style = dojo.clone( spec.style || {} ); + + // try to understand the `action` setting + spec.action = spec.action || + ( url ? 'iframeDialog' : + spec.content ? 'contentDialog' : + false + ); + spec.title = spec.title || spec.label; + + if( typeof spec.action == 'string' ) { + // treat `action` case-insensitively + spec.action = { + iframedialog: 'iframeDialog', + iframe: 'iframeDialog', + contentdialog: 'contentDialog', + content: 'contentDialog', + baredialog: 'bareDialog', + bare: 'bareDialog', + xhrdialog: 'xhrDialog', + xhr: 'xhrDialog', + newwindow: 'newWindow', + "_blank": 'newWindow' + }[(''+spec.action).toLowerCase()]; + + if( spec.action == 'newWindow' ) + window.open( url, '_blank' ); + else if( spec.action in { iframeDialog:1, contentDialog:1, xhrDialog:1, bareDialog: 1} ) + track._openDialog( spec, evt, ctx ); + } + else if( typeof spec.action == 'function' ) { + spec.action.call( ctx, evt ); + } + else { + return; + } + }; + + // if there is a label, set it on the handler so that it's + // accessible for tooltips or whatever. + if( inputSpec.label ) + handler.label = inputSpec.label; + + return handler; + }, + + /** + * @returns {Object} DOM element containing a rendering of the + * detailed metadata about this track + */ + _trackDetailsContent: function( additional ) { + var details = domConstruct.create('div', { className: 'detail' }); + var fmt = lang.hitch(this, 'renderDetailField', details ); + fmt( 'Name', this.key || this.name ); + var metadata = lang.clone( this.getMetadata() ); + lang.mixin( metadata, additional ); + delete metadata.key; + delete metadata.label; + if( typeof metadata.conf == 'object' ) + delete metadata.conf; + + var md_keys = []; + for( var k in metadata ) + md_keys.push(k); + // TODO: maybe do some intelligent sorting of the keys here? + array.forEach( md_keys, function(key) { + fmt( Util.ucFirst(key), metadata[key] ); + }); + + return details; + }, + + getMetadata: function() { + return ( this.browser && this.browser.trackMetaDataStore ? this.browser.trackMetaDataStore.getItem(this.name) : + this.config.metadata ? this.config.metadata : + {} ) || {}; + }, + + setPinned: function( p ) { + this.config.pinned = !!p; + + if( this.config.pinned ) + domClass.add( this.div, 'pinned' ); + else + domClass.remove( this.div, 'pinned' ); + + return this.config.pinned; + }, + isPinned: function() { + return !! this.config.pinned; + }, + + /** + * @returns {Array} menu options for this track's menu (usually contains save as, etc) + */ + _trackMenuOptions: function() { + var that = this; + return [ + { label: 'About this track', + title: 'About track: '+(this.key||this.name), + iconClass: 'jbrowseIconHelp', + action: 'contentDialog', + content: dojo.hitch(this,'_trackDetailsContent') + }, + { label: 'Pin to top', + type: 'dijit/CheckedMenuItem', + title: "make this track always visible at the top of the view", + checked: that.isPinned(), + //iconClass: 'dijitIconDelete', + onClick: function() { + that.browser.publish( '/jbrowse/v1/v/tracks/'+( this.checked ? 'pin' : 'unpin' ), [ that.name ] ); + } + }, + { label: 'Edit config', + title: "edit this track's configuration", + iconClass: 'dijitIconConfigure', + action: function() { + new TrackConfigEditor( that.config ) + .show( function( result ) { + // replace this track's configuration + that.browser.publish( '/jbrowse/v1/v/tracks/replace', [result.conf] ); + }); + } + } + ]; + }, + + + _processMenuSpec: function( spec, context ) { + for( var x in spec ) { + if( spec.hasOwnProperty(x) ) { + if( typeof spec[x] == 'object' ) + spec[x] = this._processMenuSpec( spec[x], context ); + else + spec[x] = this.template( context.feature, this._evalConf( context, spec[x], x ) ); + } + } + return spec; + }, + + /** + * Get the value of a conf variable, evaluating it if it is a + * function. Note: does not template it, that is a separate step. + * + * @private + */ + _evalConf: function( context, confVal, confKey ) { + + // list of conf vals that should not be run immediately on the + // feature data if they are functions + var dontRunImmediately = { + action: 1, + click: 1, + content: 1 + }; + + return typeof confVal == 'function' && !dontRunImmediately[confKey] + ? confVal.apply( context, context.callbackArgs || [] ) + : confVal; + }, + + /** + * Like getConf, but get a conf value that explicitly can vary + * feature by feature. Provides a uniform function signature for + * user-defined callbacks. + */ + getConfForFeature: function( path, feature ) { + return this.getConf( path, [feature, path, null, null, this ] ); + }, + + isFeatureHighlighted: function( feature, name ) { + var highlight = this.browser.getHighlight(); + return highlight + && ( highlight.objectName && highlight.objectName == name ) + && highlight.ref == this.refSeq.name + && !( feature.get('start') > highlight.end || feature.get('end') < highlight.start ); + }, + + _openDialog: function( spec, evt, context ) { + context = context || {}; + var type = spec.action; + type = type.replace(/Dialog/,''); + var featureName = context.feature && (context.feature.get('name')||context.feature.get('id')); + var dialogOpts = { + "class": "popup-dialog popup-dialog-"+type, + title: spec.title || spec.label || ( featureName ? featureName +' details' : "Details"), + style: dojo.clone( spec.style || {} ) + }; + if( spec.dialog ) + declare.safeMixin( dialogOpts, spec.dialog ); + + var dialog; + + function setContent( dialog, content ) { + // content can be a promise or Deferred + if( typeof content.then == 'function' ) + content.then( function( c ) { dialog.set( 'content', c ); } ); + // or maybe it's just a regular object + else + dialog.set( 'content', content ); + } + + // if dialog == xhr, open the link in a dialog + // with the html from the URL just shoved in it + if( type == 'xhr' || type == 'content' ) { + if( type == 'xhr' ) + dialogOpts.href = spec.url; + + dialog = new InfoDialog( dialogOpts ); + context.dialog = dialog; + + if( type == 'content' ) + setContent( dialog, this._evalConf( context, spec.content, null ) ); + + Util.removeAttribute( context, 'dialog' ); + } + else if( type == 'bare' ) { + dialog = new Dialog( dialogOpts ); + context.dialog = dialog; + + setContent( dialog, this._evalConf( context, spec.content, null ) ); + + Util.removeAttribute( context, 'dialog' ); + } + // open the link in a dialog with an iframe + else if( type == 'iframe' ) { + var iframeDims = function() { + var d = domGeom.position( this.browser.container ); + return { h: Math.round(d.h * 0.8), w: Math.round( d.w * 0.8 ) }; + }.call(this); + + dialog = new Dialog( dialogOpts ); + + var iframe = dojo.create( + 'iframe', { + tabindex: "0", + width: iframeDims.w, + height: iframeDims.h, + style: { border: 'none' }, + src: spec.url + }); + + dialog.set( 'content', iframe ); + dojo.create( 'a', { + href: spec.url, + target: '_blank', + className: 'dialog-new-window', + title: 'open in new window', + onclick: dojo.hitch(dialog,'hide'), + innerHTML: spec.url + }, dialog.titleBar ); + var updateIframeSize = function() { + // hitch a ride on the dialog box's + // layout function, which is called on + // initial display, and when the window + // is resized, to keep the iframe + // sized to fit exactly in it. + var cDims = domGeom.position( dialog.containerNode ); + var width = cDims.w; + var height = cDims.h - domGeom.position(dialog.titleBar).h; + iframe.width = width; + iframe.height = height; + }; + aspect.after( dialog, 'layout', updateIframeSize ); + aspect.after( dialog, 'show', updateIframeSize ); + } + + // destroy the dialog after it is hidden + aspect.after( dialog, 'hide', function() { + setTimeout(function() { + dialog.destroyRecursive(); + }, 500 ); + }); + + // show the dialog + dialog.show(); + }, + + /** + * Given a string with template callouts, interpolate them with + * data from the given object. For example, "{foo}" is replaced + * with whatever is returned by obj.get('foo') + */ + template: function( /** Object */ obj, /** String */ template ) { + if( typeof template != 'string' || !obj ) + return template; + + var valid = true; + if ( template ) { + return template.replace( + /\{([^}]+)\}/g, + function(match, group) { + var val = obj ? obj.get( group.toLowerCase() ) : undefined; + if (val !== undefined) + return val; + else { + return ''; + } + }); + } + return undefined; + }, + + /** + * Makes and installs the dropdown menu showing operations available for this track. + * @private + */ + makeTrackMenu: function() { + var options = this._trackMenuOptions(); + if( options && options.length && this.label && this.labelMenuButton ) { + + // remove our old track menu if we have one + if( this.trackMenu ) + this.trackMenu.destroyRecursive(); + + // render and bind our track menu + var menu = this._renderContextMenu( options, { menuButton: this.labelMenuButton, track: this, browser: this.browser, refSeq: this.refSeq } ); + menu.startup(); + menu.set('leftClickToOpen', true ); + menu.bindDomNode( this.labelMenuButton ); + menu.set('leftClickToOpen', false); + menu.bindDomNode( this.label ); + this.trackMenu = menu; + this.own( this.trackMenu ); + } + }, + + + // display a rendering-timeout message + fillBlockTimeout: function( blockIndex, block ) { + domConstruct.empty( block.domNode ); + domClass.add( block.domNode, 'timed_out' ); + this.fillMessage( blockIndex, block, + 'This region took too long' + + ' to display, possibly because' + + ' it contains too much data.' + + ' Try zooming in to show a smaller region.' + ); + }, + + renderRegionHighlight: function( args, highlight ) { + // do nothing if the highlight does not overlap this region + if( highlight.start > args.rightBase || highlight.end < args.leftBase ) + return; + + var block_span = args.rightBase - args.leftBase; + + var left = highlight.start; + var right = highlight.end; + + // trim left and right to avoid making a huge element that can cause problems + var trimLeft = args.leftBase - left; + if( trimLeft > 0 ) { + left += trimLeft; + } + var trimRight = right - args.rightBase; + if( trimRight > 0 ) { + right -= trimRight; + } + + var width = (right-left)*100/block_span; + left = (left - args.leftBase)*100/block_span; + var el = domConstruct.create('div', { + className: 'global_highlight' + + (trimLeft <= 0 ? ' left' : '') + + (trimRight <= 0 ? ' right' : '' ), + style: { + left: left+'%', + width: width+'%', + height: '100%' + } + }, args.block.domNode ); + } + +}); +}); + +/* + +Copyright (c) 2007-2009 The Evolutionary Software Foundation + +Created by Mitchell Skinner + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +*/ diff --git a/www/JBrowse/View/Track/BlockBased/Block.js b/www/JBrowse/View/Track/BlockBased/Block.js new file mode 100644 index 00000000..ae6376a6 --- /dev/null +++ b/www/JBrowse/View/Track/BlockBased/Block.js @@ -0,0 +1,39 @@ +define([ + 'dojo/_base/declare', + 'dijit/Destroyable', + 'JBrowse/Util' + ], + function( + declare, + Destroyable, + Util + ) { +return declare( Destroyable, { + + constructor: function( args ) { + dojo.mixin( this, args ); + var nodeArgs = this.node || {}; + delete this.node; + this.domNode = dojo.create( 'div', nodeArgs ); + this.domNode.block = this; + }, + + containsBp: function( bp ) { + return this.startBase <= bp && this.endBase >= bp; + }, + + bpToX: function( coord ) { + return (coord-this.startBase)*this.scale; + }, + + toString: function() { + return this.startBase+'..'+this.endBase; + }, + + destroy: function() { + if( this.domNode ) + Util.removeAttribute( this.domNode, 'block' ); + this.inherited( arguments ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/CanvasFeatures.js b/www/JBrowse/View/Track/CanvasFeatures.js new file mode 100644 index 00000000..6cc4e743 --- /dev/null +++ b/www/JBrowse/View/Track/CanvasFeatures.js @@ -0,0 +1,767 @@ +/** + * Feature track that draws features using HTML5 canvas elements. + */ + +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'dojo/_base/event', + 'dojo/mouse', + 'dojo/dom-construct', + 'dojo/Deferred', + 'dojo/on', + 'JBrowse/has', + 'JBrowse/View/GranularRectLayout', + 'JBrowse/View/Track/BlockBased', + 'JBrowse/View/Track/ExportMixin', + 'JBrowse/Errors', + 'JBrowse/View/Track/FeatureDetailMixin', + 'JBrowse/View/Track/_FeatureContextMenusMixin', + 'JBrowse/Model/Location' + ], + function( + declare, + array, + lang, + domEvent, + mouse, + domConstruct, + Deferred, + on, + has, + Layout, + BlockBasedTrack, + ExportMixin, + Errors, + FeatureDetailMixin, + FeatureContextMenuMixin, + Location + ) { + +/** + * inner class that indexes feature layout rectangles (fRects) (which + * include features) by unique ID. + * + * We have one of these indexes in each block. + */ +var FRectIndex = declare( null, { + constructor: function( args ) { + var height = args.h; + var width = args.w; + + this.dims = { h: height, w: width }; + + this.byID = {}; + }, + + getByID: function( id ) { + return this.byID[id]; + }, + + addAll: function( fRects ) { + var byID = this.byID; + var cW = this.dims.w; + var cH = this.dims.h; + array.forEach( fRects, function( fRect ) { + if( ! fRect ) + return; + + // by ID + byID[ fRect.f.id() ] = fRect; + }, this ); + }, + + getAll: function( ) { + var fRects = []; + for( var id in this.byID ) { + fRects.push( this.byID[id] ); + } + return fRects; + } +}); + +return declare( [BlockBasedTrack,FeatureDetailMixin,ExportMixin,FeatureContextMenuMixin], { + + constructor: function( args ) { + this.glyphsLoaded = {}; + this.glyphsBeingLoaded = {}; + this.regionStats = {}; + this.showLabels = this.config.style.showLabels; + this.showTooltips = this.config.style.showTooltips; + this.displayMode = this.config.displayMode; + + this._setupEventHandlers(); + }, + + _defaultConfig: function() { + return { + maxFeatureScreenDensity: 400, + + // default glyph class to use + glyph: lang.hitch( this, 'guessGlyphType' ), + + // maximum number of pixels on each side of a + // feature's bounding coordinates that a glyph is + // allowed to use + maxFeatureGlyphExpansion: 500, + + // maximum height of the track, in pixels + maxHeight: 600, + + style: { + // not configured by users + _defaultHistScale: 4, + _defaultLabelScale: 30, + _defaultDescriptionScale: 120, + + showLabels: true, + showTooltips: true + }, + + displayMode: 'normal', + + events: { + contextmenu: function( feature, fRect, block, track, evt ) { + evt = domEvent.fix( evt ); + if( fRect && fRect.contextMenu ) + fRect.contextMenu._openMyself({ target: block.featureCanvas, coords: { x: evt.pageX, y: evt.pageY }} ); + domEvent.stop( evt ); + } + }, + + menuTemplate: [ + { label: 'View details', + title: '{type} {name}', + action: 'contentDialog', + iconClass: 'dijitIconTask', + content: dojo.hitch( this, 'defaultFeatureDetail' ) + }, + { label: function() { + return 'Highlight this ' + +( this.feature && this.feature.get('type') ? this.feature.get('type') + : 'feature' + ); + }, + action: function() { + var loc = new Location({ feature: this.feature, tracks: [this.track] }); + this.track.browser.setHighlightAndRedraw(loc); + }, + iconClass: 'dijitIconFilter' + } + ] + }; + }, + + setViewInfo: function( genomeView, heightUpdate, numBlocks, trackDiv, widthPct, widthPx, scale ) { + this.inherited( arguments ); + this.staticCanvas = domConstruct.create('canvas', { style: { height: "100%", cursor: "default", position: "absolute", zIndex: 15 }}, trackDiv); + this.staticCanvas.height = this.staticCanvas.offsetHeight; + + this._makeLabelTooltip( ); + }, + + guessGlyphType: function(feature) { + return 'JBrowse/View/FeatureGlyph/'+( {'gene': 'Gene', 'mRNA': 'ProcessedTranscript' }[feature.get('type')] || 'Box' ); + }, + + fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + + if( ! has('canvas') ) { + this.fatalError = 'This browser does not support HTML canvas elements.'; + this.fillBlockError( blockIndex, block, this.fatalError ); + return; + } + + var fill = dojo.hitch( this, function( stats ) { + + // calculate some additional view parameters that + // might depend on the feature stats and add them to + // the view args we pass down + var renderHints = dojo.mixin( + { + stats: stats, + displayMode: this.displayMode, + showFeatures: scale >= ( this.config.style.featureScale + || (stats.featureDensity||0) / this.config.maxFeatureScreenDensity ), + showLabels: this.showLabels && this.displayMode == "normal" + && scale >= ( this.config.style.labelScale + || (stats.featureDensity||0) * this.config.style._defaultLabelScale ), + showDescriptions: this.showLabels && this.displayMode == "normal" + && scale >= ( this.config.style.descriptionScale + || (stats.featureDensity||0) * this.config.style._defaultDescriptionScale) + }, + args + ); + + if( renderHints.showFeatures ) { + this.fillFeatures( dojo.mixin( renderHints, args ) ); + } + else { + this.fillTooManyFeaturesMessage( + blockIndex, + block, + scale + ); + args.finishCallback(); + } + }); + + this.store.getGlobalStats( + fill, + dojo.hitch( this, function(e) { + this._handleError( e, args ); + args.finishCallback(e); + }) + ); + }, + + // create the layout if we need to, and if we can + _getLayout: function( scale ) { + if( ! this.layout || this._layoutpitchX != 4/scale ) { + // if no layoutPitchY configured, calculate it from the + // height and marginBottom (parseInt in case one or both are functions), or default to 3 if the + // calculation didn't result in anything sensible. + var pitchY = this.getConf('layoutPitchY') || 4; + this.layout = new Layout({ pitchX: 4/scale, pitchY: pitchY, maxHeight: this.getConf('maxHeight'), displayMode: this.displayMode }); + this._layoutpitchX = 4/scale; + } + + return this.layout; + }, + + _clearLayout: function() { + delete this.layout; + }, + + hideAll: function() { + this._clearLayout(); + return this.inherited( arguments ); + }, + + /** + * Returns a promise for the appropriate glyph for the given + * feature and args. + */ + getGlyph: function( viewArgs, feature, callback, errorCallback ) { + var glyphClassName = this.getConfForFeature( 'glyph', feature ); + var glyph, interestedParties; + if(( glyph = this.glyphsLoaded[glyphClassName] )) { + callback( glyph ); + } + else if(( interestedParties = this.glyphsBeingLoaded[glyphClassName] )) { + interestedParties.push( callback ); + } + else { + var thisB = this; + this.glyphsBeingLoaded[glyphClassName] = [callback]; + require( [glyphClassName], function( GlyphClass ) { + + glyph = thisB.glyphsLoaded[glyphClassName] = + new GlyphClass({ track: thisB, config: thisB.config, browser: thisB.browser }); + + array.forEach( thisB.glyphsBeingLoaded[glyphClassName], function( cb ) { + cb( glyph ); + }); + + delete thisB.glyphsBeingLoaded[glyphClassName]; + + }); + } + }, + + fillFeatures: function( args ) { + var thisB = this; + + var blockIndex = args.blockIndex; + var block = args.block; + var blockWidthPx = block.domNode.offsetWidth; + var scale = args.scale; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var finishCallback = args.finishCallback; + + var fRects = []; + + // count of how many features are queued up to be laid out + var featuresInProgress = 0; + // promise that resolved when all the features have gotten laid out by their glyphs + var featuresLaidOut = new Deferred(); + // flag that tells when all features have been read from the + // store (not necessarily laid out yet) + var allFeaturesRead = false; + + var errorCallback = dojo.hitch( thisB, function( e ) { + this._handleError( e, args ); + finishCallback(e); + }); + + var layout = this._getLayout( scale ); + + // query for a slightly larger region than the block, so that + // we can draw any pieces of glyphs that overlap this block, + // but the feature of which does not actually lie in the block + // (long labels that extend outside the feature's bounds, for + // example) + var bpExpansion = Math.round( this.config.maxFeatureGlyphExpansion / scale ); + + var region = { ref: this.refSeq.name, + start: Math.max( 0, leftBase - bpExpansion ), + end: rightBase + bpExpansion + }; + this.store.getFeatures( region, + function( feature ) { + if( thisB.destroyed || ! thisB.filterFeature( feature ) ) + return; + fRects.push( null ); // put a placeholder in the fRects array + featuresInProgress++; + var rectNumber = fRects.length-1; + + // get the appropriate glyph object to render this feature + thisB.getGlyph( + args, + feature, + function( glyph ) { + // have the glyph attempt + // to add a rendering of + // this feature to the + // layout + var fRect = glyph.layoutFeature( + args, + layout, + feature + ); + if( fRect === null ) { + // could not lay out, would exceed our configured maxHeight + // mark the block as exceeding the max height + block.maxHeightExceeded = true; + } + else { + // laid out successfully + if( !( fRect.l >= blockWidthPx || fRect.l+fRect.w < 0 ) ) + fRects[rectNumber] = fRect; + } + + // this might happen after all the features have been sent from the store + if( ! --featuresInProgress && allFeaturesRead ) { + featuresLaidOut.resolve(); + } + }, + errorCallback + ); + }, + + // callback when all features sent + function () { + if( thisB.destroyed ) + return; + + allFeaturesRead = true; + if( ! featuresInProgress && ! featuresLaidOut.isFulfilled() ) { + featuresLaidOut.resolve(); + } + + featuresLaidOut.then( function() { + + var totalHeight = layout.getTotalHeight(); + var c = block.featureCanvas = + domConstruct.create( + 'canvas', + { height: totalHeight, + width: block.domNode.offsetWidth+1, + style: { + cursor: 'default', + height: totalHeight+'px', + position: 'absolute' + }, + innerHTML: 'Your web browser cannot display this type of track.', + className: 'canvas-track' + }, + block.domNode + ); + + if( block.maxHeightExceeded ) + thisB.markBlockHeightOverflow( block ); + + thisB.heightUpdate( totalHeight, + blockIndex ); + + + thisB.renderFeatures( args, fRects ); + + thisB.renderClickMap( args, fRects ); + + finishCallback(); + }); + }, + errorCallback + ); + }, + + startZoom: function() { + this.inherited( arguments ); + + array.forEach( this.blocks, function(b) { + try { + b.featureCanvas.style.width = '100%'; + } catch(e) {}; + }); + }, + + endZoom: function() { + array.forEach( this.blocks, function(b) { + try { + delete b.featureCanvas.style.width; + } catch(e) {}; + }); + + this.clear(); + this.inherited( arguments ); + }, + + renderClickMap: function( args, fRects ) { + var block = args.block; + + // make an index of the fRects by ID, and by coordinate, and + // store it in the block + var index = new FRectIndex({ h: block.featureCanvas.height, w: block.featureCanvas.width }); + block.fRectIndex = index; + index.addAll( fRects ); + + if( ! block.featureCanvas || ! block.featureCanvas.getContext('2d') ) { + console.warn( "No 2d context available from canvas" ); + return; + } + + this._attachMouseOverEvents( ); + + // connect up the event handlers + this._connectEventHandlers( block ); + + this.updateStaticElements( { x: this.browser.view.getX() } ); + }, + + _attachMouseOverEvents: function( ) { + var gv = this.browser.view; + var thisB = this; + + if( this.displayMode == 'collapsed' ) { + if( this._mouseoverEvent ) { + this._mouseoverEvent.remove(); + delete this._mouseoverEvent; + } + + if( this._mouseoutEvent ) { + this._mouseoutEvent.remove(); + delete this._mouseoutEvent; + } + } else { + if( !this._mouseoverEvent ) { + this._mouseoverEvent = this.own( on( this.staticCanvas, 'mousemove', function( evt ) { + evt = domEvent.fix( evt ); + var bpX = gv.absXtoBp( evt.clientX ); + var feature = thisB.layout.getByCoord( bpX, ( evt.offsetY === undefined ? evt.layerY : evt.offsetY ) ); + thisB.mouseoverFeature( feature, evt ); + }))[0]; + } + + if( !this._mouseoutEvent ) { + this._mouseoutEvent = this.own( on( this.staticCanvas, 'mouseout', function( evt) { + thisB.mouseoverFeature( undefined ); + }))[0]; + } + } + }, + + _makeLabelTooltip: function( ) { + + if( !this.showTooltips || this.labelTooltip ) + return; + + var labelTooltip = this.labelTooltip = domConstruct.create( + 'div', { + className: 'featureTooltip', + style: { + position: 'fixed', + display: 'none', + zIndex: 19 + } + }, document.body ); + domConstruct.create( + 'span', { + className: 'tooltipLabel', + style: { + display: 'block' + } + }, labelTooltip); + domConstruct.create( + 'span', { + className: 'tooltipDescription', + style: { + display: 'block' + } + }, labelTooltip); + }, + + _connectEventHandlers: function( block ) { + for( var event in this.eventHandlers ) { + var handler = this.eventHandlers[event]; + (function( event, handler ) { + var thisB = this; + block.own( + on( this.staticCanvas, event, function( evt ) { + evt = domEvent.fix( evt ); + var bpX = thisB.browser.view.absXtoBp( evt.clientX ); + if( block.containsBp( bpX ) ) { + var feature = thisB.layout.getByCoord( bpX, ( evt.offsetY === undefined ? evt.layerY : evt.offsetY ) ); + if( feature ) { + var fRect = block.fRectIndex.getByID( feature.id() ); + handler.call({ + track: thisB, + feature: feature, + fRect: fRect, + block: block, + callbackArgs: [ thisB, feature, fRect ] + }, + feature, + fRect, + block, + thisB, + evt + ); + } + } + }) + ); + }).call( this, event, handler ); + } + }, + + getRenderingContext: function( viewArgs ) { + if( ! viewArgs.block || ! viewArgs.block.featureCanvas ) + return null; + try { + var ctx = viewArgs.block.featureCanvas.getContext('2d'); + // ctx.translate( viewArgs.block.offsetLeft - this.featureCanvas.offsetLeft, 0 ); + // console.log( viewArgs.blockIndex, 'block offset', viewArgs.block.offsetLeft - this.featureCanvas.offsetLeft ); + return ctx; + } catch(e) { + console.error(e, e.stack); + return null; + } + }, + + // draw the features on the canvas + renderFeatures: function( args, fRects ) { + var context = this.getRenderingContext( args ); + if( context ) { + var thisB = this; + array.forEach( fRects, function( fRect ) { + if( fRect ) + thisB.renderFeature( context, fRect ); + }); + } + }, + + // given viewargs and a feature object, highlight that feature in + // all blocks. if feature is undefined or null, unhighlight any currently + // highlighted feature + mouseoverFeature: function( feature, evt ) { + + if( this.lastMouseover == feature ) + return; + + if( evt ) + var bpX = this.browser.view.absXtoBp( evt.clientX ); + + if( this.labelTooltip) + this.labelTooltip.style.display = 'none'; + + array.forEach( this.blocks, function( block, i ) { + if( ! block ) + return; + var context = this.getRenderingContext({ block: block, leftBase: block.startBase, scale: block.scale }); + if( ! context ) + return; + + if( this.lastMouseover ) { + var r = block.fRectIndex.getByID( this.lastMouseover.id() ); + if( r ) + this.renderFeature( context, r ); + } + + if( block.tooltipTimeout ) + window.clearTimeout( block.tooltipTimeout ); + + if( feature ) { + var fRect = block.fRectIndex.getByID( feature.id() ); + if( ! fRect ) + return; + + if( block.containsBp( bpX ) ) { + var renderTooltip = dojo.hitch( this, function() { + if( !this.labelTooltip ) + return; + var label = fRect.label || fRect.glyph.makeFeatureLabel( feature ); + var description = fRect.description || fRect.glyph.makeFeatureDescriptionLabel( feature ); + + if( ( !label && !description ) ) + return; + + if( !this.ignoreTooltipTimeout ) { + this.labelTooltip.style.left = evt.clientX + "px"; + this.labelTooltip.style.top = (evt.clientY + 15) + "px"; + } + this.ignoreTooltipTimeout = true; + this.labelTooltip.style.display = 'block'; + if( label ) { + var labelSpan = this.labelTooltip.childNodes[0]; + labelSpan.style.font = label.font; + labelSpan.style.color = label.fill; + labelSpan.innerHTML = label.text; + } + if( description ) { + var descriptionSpan = this.labelTooltip.childNodes[1]; + descriptionSpan.style.font = description.font; + descriptionSpan.style.color = description.fill; + descriptionSpan.innerHTML = description.text; + } + }); + if( this.ignoreTooltipTimeout ) + renderTooltip(); + else + block.tooltipTimeout = window.setTimeout( renderTooltip, 600); + } + + fRect.glyph.mouseoverFeature( context, fRect ); + this._refreshContextMenu( fRect ); + } else { + block.tooltipTimeout = window.setTimeout( dojo.hitch(this, function() { this.ignoreTooltipTimeout = false; }), 200); + } + }, this ); + + this.lastMouseover = feature; + }, + + cleanupBlock: function(block) { + this.inherited( arguments ); + + // garbage collect the layout + if ( block && this.layout ) + this.layout.discardRange( block.startBase, block.endBase ); + }, + + // draw each feature + renderFeature: function( context, fRect ) { + fRect.glyph.renderFeature( context, fRect ); + }, + + _trackMenuOptions: function () { + var opts = this.inherited(arguments); + var thisB = this; + + var displayModeList = ["normal", "compact", "collapsed"]; + this.displayModeMenuItems = displayModeList.map(function(displayMode) { + return { + label: displayMode, + type: 'dijit/CheckedMenuItem', + title: "Render this track in " + displayMode + " mode", + checked: thisB.displayMode == displayMode, + onClick: function() { + thisB.displayMode = displayMode; + thisB._clearLayout(); + thisB.hideAll(); + thisB.genomeView.showVisibleBlocks(true); + thisB.makeTrackMenu(); + } + }; + }); + + var updateMenuItems = dojo.hitch(this, function() { + for(var index in this.displayModeMenuItems) { + this.displayModeMenuItems[index].checked = (this.displayMode == this.displayModeMenuItems[index].label); + } + }); + + opts.push.apply( + opts, + [ + { type: 'dijit/MenuSeparator' }, + { + label: "Display mode", + iconClass: "dijitIconPackage", + title: "Make features take up more or less space", + children: this.displayModeMenuItems + }, + { label: 'Show labels', + type: 'dijit/CheckedMenuItem', + checked: !!( 'showLabels' in this ? this.showLabels : this.config.style.showLabels ), + onClick: function(event) { + thisB.showLabels = this.checked; + thisB.changed(); + } + } + ] + ); + + return opts; + }, + + _exportFormats: function() { + return [ {name: 'GFF3', label: 'GFF3', fileExt: 'gff3'}, {name: 'BED', label: 'BED', fileExt: 'bed'}, { name: 'SequinTable', label: 'Sequin Table', fileExt: 'sqn' } ]; + }, + + updateStaticElements: function( coords ) { + this.inherited( arguments ); + + if( coords.hasOwnProperty("x") ) { + var context = this.staticCanvas.getContext('2d'); + + this.staticCanvas.width = this.browser.view.elem.clientWidth; + this.staticCanvas.style.left = coords.x + "px"; + context.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height); + + var minVisible = this.browser.view.minVisible(); + var maxVisible = this.browser.view.maxVisible(); + var viewArgs = { + minVisible: minVisible, + maxVisible: maxVisible, + bpToPx: dojo.hitch(this.browser.view, "bpToPx"), + lWidth: this.label.offsetWidth + }; + + array.forEach( this.blocks, function(block) { + if( !block || !block.fRectIndex ) + return; + + var idx = block.fRectIndex.byID; + for( var id in idx ) { + var fRect = idx[id]; + fRect.glyph.updateStaticElements( context, fRect, viewArgs ); + } + }, this ); + } + }, + + heightUpdate: function( height, blockIndex ) { + this.inherited( arguments ); + this.staticCanvas.height = this.staticCanvas.offsetHeight; + }, + + destroy: function() { + this.destroyed = true; + + domConstruct.destroy( this.staticCanvas ); + delete this.staticCanvas; + + delete this.layout; + delete this.glyphsLoaded; + this.inherited( arguments ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Combination.js b/www/JBrowse/View/Track/Combination.js new file mode 100644 index 00000000..4c017854 --- /dev/null +++ b/www/JBrowse/View/Track/Combination.js @@ -0,0 +1,1034 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/on', + 'dojo/dom-construct', + 'dojo/dom-class', + 'dojo/Deferred', + 'dojo/promise/all', + 'dojo/when', + './Combination/CombinationDialog', + 'dijit/Dialog', + 'JBrowse/View/Track/BlockBased', + 'JBrowse/Model/BinaryTreeNode', + 'dojo/dnd/move', + 'dojo/dnd/Source', + 'dojo/dnd/Manager', + 'JBrowse/Util', + 'JBrowse/View/TrackConfigEditor', + 'JBrowse/View/Track/ExportMixin' + ], + function( + declare, + lang, + on, + dom, + domClass, + Deferred, + all, + when, + CombinationDialog, + Dialog, + BlockBased, + TreeNode, + dndMove, + dndSource, + dndManager, + Util, + TrackConfigEditor, + ExportMixin + ) { +return declare([BlockBased, ExportMixin], +{ + +/** + * Creates a track with a drag-and-drop interface allowing users to drag other tracks into it. + * Users select (using a dialog) a way to combine these tracks, and they are combined. + * Certain tracks (e.g. HTMLFeatures tracks) may be combined set-theoretically (union, intersection,etc ), + * while others (e.g. BigWig tracks) may be combined quantitatively (add scores, subtract scores, etc...). + * If one of the tracks is a set-based track and the other is not, track masking operations may be applied. + * @constructs + */ +constructor: function( args ) { + // The "default" track of each type is the one at + // index 0 of the resultsTypes array. + // Many different kinds of tracks can be added. + // Each is supported by a different store, and + // some can be rendered in several ways. + // The trackClasses object stores information about what can be done with each of these types. + this.trackClasses = + { + "set": { + resultsTypes: [{ + name: "HTMLFeatures", + path: "JBrowse/View/Track/HTMLFeatures" + } + ], + store: "JBrowse/Store/SeqFeature/Combination", + allowedOps: ["&", "U", "X", "S"], + defaultOp : "&" + }, + "quantitative": { + resultsTypes: [{ + name: "XYPlot", + path: "JBrowse/View/Track/Wiggle/XYPlot" + }, + { + name: "Density", + path: "JBrowse/View/Track/Wiggle/Density" + }], + store: "JBrowse/Store/SeqFeature/QuantitativeCombination", + allowedOps: ["+", "-", "*", "/"], + defaultOp: "+" + }, + "mask": { + resultsTypes: [{ + name: "XYPlot", + path: "JBrowse/View/Track/Wiggle/XYPlot" + }, + { + name: "Density", + path: "JBrowse/View/Track/Wiggle/Density" + }], + store: "JBrowse/Store/SeqFeature/Mask", + allowedOps: ["M", "N"], + defaultOp: "M" + }, + "BAM": { + resultsTypes: [{ + name: "Detail", + path: "JBrowse/View/Track/Alignments2" + }, + { + name: "Summary", + path: "JBrowse/View/Track/SNPCoverage" //For now + }], + store: "JBrowse/Store/SeqFeature/BAMCombination", + allowedOps: ["U"], + defaultOp: "U" + } + }; + + this.errorCallback = dojo.hitch(this, function(error) { + this._handleError(error, {}); + }); + + // inWords just stores, in words, what each possible operation does. This is helpful for dialogs and menus that + // allow selection of different operations. + this.inWords = + { + // These one-character codes symbolize operations between stores. + "+": "addition", + "-": "subtraction", + "*": "multiplication", + "/": "division", + "&": "intersection", + "U": "union", + "X": "XOR", + "S": "set subtraction", + "M": "regular mask", + "N": "inverse mask", + // These four-digit codes are used by the CombinationDialog object to differentiate different types of masking operations. + "0000": "combine without masking", + "0020": "use new track as mask", + "0002": "use old track as mask", + "1111": "merge tracks", + "1001": "add new track to old track's displayed data", + "1010": "add new track to old track's mask", + "0101": "add old track to new track's displayed data", + "0110": "add old track to new track's mask" + }; + + // Each store becomes associated with the name of a track that uses that store, so that users can read more easily. + if(!this.config.storeToKey) + this.config.storeToKey = {}; + + // Shows which track or store types qualify as set-based, quantitative, etc. + this.supportedBy = + { + "JBrowse/View/Track/HTMLFeatures": "set", + "JBrowse/View/Track/HTMLVariants": "set", + "JBrowse/View/Track/CanvasFeatures": "set", + "JBrowse/View/Track/Alignments2": "BAM", + "JBrowse/View/Track/SNPCoverage": "BAM", + "JBrowse/Store/BigWig": "quantitative", + "JBrowse/Store/SeqFeature/BAM": "BAM", + "JBrowse/Store/SeqFeature/BAMCombination": "BAM", + "JBrowse/Store/SeqFeature/Combination": "set", + "JBrowse/Store/SeqFeature/QuantitativeCombination": "quantitative", + "JBrowse/Store/SeqFeature/Mask": "mask" + }; + + this.loaded = true; + + // For CSS customization of the outer + this.divClass = args.divClass || "combination"; + + // Sets a bunch of variables to their initial values + this.reinitialize(); + + + // When other tracks are dragged onto the combination, they don't disappear from their respective sources + // (in case the user wants to add the track separately, by itself). These variables will be used in the DnD + // methods to support this functionality + this.currentDndSource = undefined; + this.sourceWasCopyOnly = undefined; + + // This is used to avoid creating a feedback loop in the height-updating process. + this.onlyRefreshOuter = false; + + this.heightResults = 0; + + this.height = args.height || 0; + + // This variable (which will later be a deferred) ensures that when multiple tracks are added simultaneously, + // The dialogs for each one don't render all at once. + this.lastDialogDone = [true]; + +}, + +setViewInfo: function( genomeView, heightUpdate, numBlocks, + trackDiv, + widthPct, widthPx, scale) { + + this.inherited( arguments ); + domClass.add( this.div, 'combination_track empty' ); + + this.scale = scale; + + // This track has a dnd source (to support dragging tracks into and out of it). + this.dnd = new dndSource( this.div, + { + accept: ["track"], //Accepts only tracks + isSource: false, + withHandles: true, + creator: dojo.hitch( this, function( trackConfig, hint ) { + // Renders the results track div (or avatar, depending). + // Code for ensuring that we don't have several results tracks + // is handled later in the file. + var data = trackConfig; + if(trackConfig.resultsTrack) { + data = trackConfig.resultsTrack; + data.storeToKey = trackConfig.storeToKey; + } + return { + data: data, + type: ["track"], + node: this.addTrack(data) + }; + }) + }); + + // Attach dnd events + this._attachDndEvents(); + + // If config contains a config for the results track, use it. (This allows reloading when the track config is edited. ) + if(this.config.resultsTrack) { + this.reloadStoreNames = true; + this.dnd.insertNodes(false, [this.config.resultsTrack]); + } +}, + +// This function ensure that the combination track's drag-and-drop interface works correctly. +_attachDndEvents: function() { + var thisB = this; + + // What to do at the beginning of dnd process + on(thisB.dnd, "DndStart", function(source, nodes, copy) { + // Stores the information about whether the source was copy-only, for future reference + thisB.currentDndSource = source; + thisB.sourceWasCopyOnly = source.copyOnly; + }); + + // When other tracks are dragged onto the combination, they don't disappear from their respective sources + on(thisB.dnd, "DraggingOver", function() { + if(thisB.currentDndSource) { + // Tracks being dragged onto this track are copied, not moved. + thisB.currentDndSource.copyOnly = true; + } + this.currentlyOver = true; + }); + + var dragEndingEvents = ["DraggingOut", "DndDrop", "DndCancel"]; + + for(var eventName in dragEndingEvents) + on(thisB.dnd, dragEndingEvents[eventName], function() { + if(thisB.currentDndSource) { + // Makes sure that the dndSource isn't permanently set to CopyOnly + thisB.currentDndSource.copyOnly = thisB.sourceWasCopyOnly; + } + this.currentlyOver = false; + }); + + // Bug fixer + dojo.subscribe("/dnd/drop/before", function(source, nodes, copy, target) { + if(target == thisB.dnd && nodes[0]) { + thisB.dnd.current = null; + } + }); + + on(thisB.dnd, "OutEvent", function() { + // Fixes a glitch wherein the trackContainer is disabled when the track we're dragging leaves the combination track + dndManager.manager().overSource(thisB.genomeView.trackDndWidget); + }); + + on(thisB.dnd, "DndSourceOver", function(source) { + // Fixes a glitch wherein tracks dragged into the combination track sometimes go to the trackContainer instead. + if(source != this && this.currentlyOver) { + dndManager.manager().overSource(this); + } + }); + + // Further restricts what categories of tracks may be added to this track + // Should re-examine this + + var oldCheckAcceptance = this.dnd.checkAcceptance; + this.dnd.checkAcceptance = function(source, nodes) { + // If the original acceptance checker fails, this one will too. + var accept = oldCheckAcceptance.call(thisB.dnd, source, nodes); + + // Additional logic to disqualify bad tracks - if one node is unacceptable, the whole group is disqualified + for(var i = 0; accept && nodes[i]; i++) { + var trackConfig = source.getItem(nodes[i].id).data; + accept = accept && (trackConfig.resultsTrack || thisB.supportedBy[trackConfig.storeClass] || thisB.supportedBy[trackConfig.type]); + } + + return accept; + }; +}, + +// Reset a bunch of variables +reinitialize: function() { + if(this.dnd) { + this.dnd.selectAll().deleteSelectedNodes(); + } + + // While there is no results track, we cannot export. + this.config.noExport = true; + this.exportFormats = []; + + this.resultsDiv = undefined; + this.resultsTrack = undefined; + this.storeType = undefined; + this.oldType = undefined; + this.classIndex = {}; + this.storeToShow = 0; + this.displayStore = undefined; + this.maskStore = undefined; + this.store = undefined; + this.opTree = undefined; +}, + +// Modifies the results track when a new track is added +addTrack: function(trackConfig) { + // Connect the track's name to its store for easy reading by user + if(trackConfig && trackConfig.key && trackConfig.store) { + this.config.storeToKey[trackConfig.store] = trackConfig.key; + } + + if(trackConfig && trackConfig.storeToKey) { + lang.mixin(this.config.storeToKey, trackConfig.storeToKey); + } + + // Creates the results div, if it hasn't already been created + if(!this.resultsDiv) { + this.resultsDiv = dom.create("div"); + this.resultsDiv.className = "track"; + this.resultsDiv.id = this.name + "_resultsDiv"; + domClass.remove( this.div, 'empty' ); + } + + // Carry on the process of adding the track + this._addTrackStore(trackConfig); + + // Because _addTrackStore has deferreds, the dnd node must be returned before it is filled + return this.resultsDiv; +}, + +// Obtains the store of the track that was just added. +_addTrackStore: function(trackConfig) { + var storeName = trackConfig.store; + var thisB = this; + var haveStore = (function() { + var d = new Deferred(); + thisB.browser.getStore(storeName, function(store) { + if(store) { + d.resolve(store,true); + } else { + d.reject("store " + storeName + " not found", true); + } + }); + return d.promise; + })(); + // Once we have the store, it's time to open the dialog. + haveStore.then(function(store){ + thisB.runDialog(trackConfig, store); + }); +}, + +// Runs the dialog that asks the user how to combine the track. +runDialog: function(trackConfig, store) { + // If this is the first track being added, it's not being combined with anything, so we don't need to ask - just adds the track alone + if(this.storeType === undefined) { + // Figure out which type of track (set, quant, etc) the user is adding + this.currType = this.supportedBy[trackConfig.storeClass] || this.supportedBy[trackConfig.type]; + this.storeType = this.currType; + // What type of Combination store corresponds to the track just added + this.storeClass = this.trackClasses[this.currType].store; + + // opTree can be directly reloaded from track config. This is important (e.g.) when changing reference sequences + // to make sure that the right combinations of tracks are still included in this track. + if( store.isCombinationStore && !store.opTree && this.config.opTree ) { + this.loadTree( this.config.opTree ).then( dojo.hitch( this, function(tree){ + this.opTree = tree; + this.displayType = this.config.displayType; + if( this.getClassIndex( this.displayType || this.storeType ) == undefined ) { + this.setTrackClass( trackConfig.type, this.displayType || this.storeType ); + } + this._adjustStores( store, this.oldType, this.currType, this.config.store, this.config.maskStore, this.config.displayStore ); + })); + return; + } + var opTree = store.isCombinationStore ? store.opTree.clone() : new TreeNode({Value: store, leaf: true}); + this.displayType = (this.currType == "mask") ? this.supportedBy[store.stores.display.config.type] : undefined; + if( this.getClassIndex( this.displayType || this.storeType ) == undefined ) { + this.setTrackClass( trackConfig.type, this.displayType || this.storeType ); + } + this.opTree = opTree; + if(this.reloadStoreNames) { + this.reloadStoreNames = false; + this._adjustStores( store, this.oldType, this.currType, this.config.store, this.config.maskStore, this.config.displayStore ); + return; + } + this._adjustStores( store, this.oldType, this.currType ); + return; + } + var d = new Deferred(); + + this.lastDialogDone.push(d); + // Once the last dialog has closed, opens a new one + when( this.lastDialogDone.shift(), + dojo.hitch( this, function() { + if(this.preferencesDialog) + this.preferencesDialog.destroyRecursive(); + // Figure out which type of track (set, quant, etc) the user is adding + this.currType = this.supportedBy[trackConfig.storeClass] || this.supportedBy[trackConfig.type]; + this.oldType = this.storeType; + // What type of Combination store corresponds to the track just added + this.storeClass = this.trackClasses[this.currType].store; + this.preferencesDialog = new CombinationDialog({ + trackConfig: trackConfig, + store: store, + track: this + }); + // Once the results of the dialog are back, uses them to continue the process of rendering the results track + this.preferencesDialog.run(dojo.hitch(this, function(opTree, newstore, displayType) { + this.opTree = opTree; + this.displayType = displayType; + this.storeType = ( this.oldType == "mask" || this.opTree.get() == "M" || + this.opTree.get() == "N" ) ? "mask" : this.currType; + if( this.getClassIndex( this.displayType || this.storeType ) == undefined ) { + this.setTrackClass( trackConfig.type, this.displayType || this.storeType ); + } + this._adjustStores(newstore, this.oldType, this.currType); + d.resolve(true); + }), dojo.hitch(this, function() { + d.resolve(true); + })); + })); +}, + +// If this track contains masked data, it uses three stores. Otherwise, it uses one. +// This function ensures that all secondary stores (one for the mask, one for the display) have been loaded. +// If not, it loads them itself. This function tries not to waste stores - if a store of a certain type already exists, +// it uses it rather than creating a new one. +_adjustStores: function ( store, oldType, currType, storeName, maskStoreName, displayStoreName ) { + var d = new Deferred(); + if( oldType == "mask" ) { + this.maskStore.reload( this.opTree.leftChild ); + this.displayStore.reload( this.opTree.rightChild ); + this.store.reload( this.opTree, this.maskStore, this.displayStore ); + d.resolve( true ); + } else if( currType == "mask" || this.opTree.get( ) == "M" || this.opTree.get( ) == "N" ) { + var haveMaskStore = this._createStore( "set", maskStoreName ); + haveMaskStore.then( dojo.hitch( this, function( newstore ) { + this.maskStore = newstore; + this.maskStore.reload( this.opTree.leftChild ); + } ) ); + var haveDisplayStore = this._createStore( this.displayType, displayStoreName ); + + + haveDisplayStore.then( dojo.hitch( this, function( newStore ){ + this.displayStore = newStore; + this.displayStore.reload( this.opTree.rightChild ); + } ) ); + this.store = undefined; + d = all( [haveMaskStore, haveDisplayStore] ); + } else { + d.resolve( true ); + } + d.then( dojo.hitch( this, function() { + this.createStore( storeName ); + })); +}, + +// Checks if the primary store has been created yet. If it hasn't, calls "_createStore" and makes it. +createStore: function( storeName ) { + var d = new Deferred(); + var thisB = this; + + if( !this.store ) { + d = this._createStore( undefined, storeName ); + } else { + d.resolve( this.store, true ); + } + d.then( function(store) { + // All stores are now in place. Make sure the operation tree of the store matches that of this track, + // and then we can render the results track. + thisB.store = store; + thisB.store.reload( thisB.opTree, thisB.maskStore, thisB.displayStore ); + thisB.renderResultsTrack(); + }); +}, + +// Creates a store config and passes it to the browser, which creates the store and returns its name. +_createStore: function( storeType, storeName ) { + var d = new Deferred(); + if( !storeName ) { + var storeConf = this._storeConfig( storeType ); + storeName = this.browser._addStoreConfig( undefined, storeConf ); + storeConf.name = storeName; + } + + this.browser.getStore( storeName, function( store ) { + d.resolve( store, true ); + }); + return d.promise; +}, + +// Uses the current settings of the combination track to create a store +_storeConfig: function( storeType ) { + if(!storeType) + storeType = this.storeType; + var storeClass = this.trackClasses[storeType].store; + this.config.storeClass = storeClass; + + var op = this.trackClasses[storeType].defaultOp; + return { + browser: this.browser, + refSeq: this.browser.refSeq.name, + type: storeClass, + op: op + }; +}, + +// This method is particularly useful when masked data is being displayed, and returns data which depends on +// which of (data, mask, masked data) is being currently displayed. +_visible: function() { + var which = [this.displayType || this.storeType, "set", this.displayType]; + + var allTypes = [{ store: this.store, + tree: this.opTree }, + { store: this.maskStore, + tree: this.opTree ? this.opTree.leftChild : undefined }, + { store: this.displayStore, + tree: this.opTree ? this.opTree.rightChild : undefined }]; + for(var i in which) { + allTypes[i].which = which[i]; + if(which[i]) { + var storeType = (i == 0 && this.storeType == "mask") ? "mask" : which[i]; + allTypes[i].allowedOps = this.trackClasses[storeType].allowedOps; + allTypes[i].trackType = this.trackClasses[which[i]].resultsTypes[this.getClassIndex(which[i]) || 0].path; + } + } + if(this.storeType != "mask") return allTypes[0]; + return allTypes[this.storeToShow]; +}, + +// Time to actually render the results track. +renderResultsTrack: function() { + if(this.resultsTrack) { + // Destroys the results track currently in place if it exists. We're going to create a new one. + this.resultsTrack.clear(); + this.resultsTrack.destroy(); + while(this.resultsDiv.firstChild) { // Use dojo.empty instead? + this.resultsDiv.removeChild(this.resultsDiv.firstChild); + } + } + // Checks one last time to ensure we have a store before proceeding + if(this._visible().store) { + // Gets the path of the track to create + var trackClassName = this._visible().trackType; + var trackClass; + + var thisB = this; + var config = this._resultsTrackConfig(trackClassName); + + trackClassName = config.type; + + // Once we have the object for the type of track we're creating, call this. + var makeTrack = function(){ + // Construct a track with the relevant parameters + thisB.resultsTrack = new trackClass({ + config: config, + browser: thisB.browser, + changeCallback: thisB._changedCallback, + refSeq: thisB.refSeq, + store: thisB._visible().store, + trackPadding: 0}); + + // Removes all options from the results track's context menu. + thisB.resultsTrackMenuOptions = thisB.resultsTrack._trackMenuOptions; + + thisB.resultsTrack._trackMenuOptions = function(){ return []; }; + + // This will be what happens when the results track updates its height - makes necessary changes to + // outer track's height and then passes up to the heightUpdate callback specified as a parameter to this object + var resultsHeightUpdate = function(height) { + thisB.resultsDiv.style.height = height + "px"; + thisB.heightResults = height; + thisB.height = height; + thisB.onlyRefreshOuter = true; + thisB.refresh(); + thisB.onlyRefreshOuter = false; + thisB.heightUpdate(thisB.height); + thisB.div.style.height = thisB.height + "px"; + }; + + // destroy the makeTrackLabel function of the results track, so that to the user it is exactly the same as the outer track + + thisB.resultsTrack.makeTrackLabel = function() {}; + + // setViewInfo on results track + thisB.resultsTrack.setViewInfo (thisB.genomeView, resultsHeightUpdate, + thisB.numBlocks, thisB.resultsDiv, thisB.widthPct, thisB.widthPx, thisB.scale); + + // Only do this when the masked data is selected + // (we don't want editing the config to suddenly remove the data or the mask) + thisB.config.opTree = thisB.flatten(thisB.opTree); + thisB.config.store = thisB.store.name; + thisB.config.maskStore = thisB.maskStore ? thisB.maskStore.name : undefined; + thisB.config.displayStore = thisB.displayStore ? thisB.displayStore.name : undefined; + + if(thisB._visible().store == thisB.store) { + // Refresh results track config, so that the track can be recreated when the config is edited + thisB.config.resultsTrack = thisB.resultsTrack.config; + thisB.config.displayType = thisB.displayType; + + thisB.browser.replaceTracks([ thisB.config ]); + + if(typeof thisB.resultsTrack._exportFormats == 'function') { + thisB.config.noExport = false; + thisB.exportFormats = thisB.resultsTrack._exportFormats(); + } else { + thisB.config.noExport = true; + } + } + + thisB.refresh(); + }; + + // Loads the track class from the specified path + require([trackClassName], function(tc) { + trackClass = tc; + if(trackClass) makeTrack(); + }); + } +}, + +// Generate the config of the results track +_resultsTrackConfig: function(trackClass) { + var config = { + store: this.store.name, + storeClass: this.store.config.type, + feature: ["match"], + key: "Results", + label: this.name + "_results", + metadata: { description: "This track was created from a combination track."}, + type: trackClass, + autoscale: "local" + }; + + if(this.config.resultsTrack) { + if((this.config.resultsTrack.storeClass == config.storeClass || this.supportedBy[this.config.resultsTrack.storeClass] == this.displayType) + && (this._visible().store != this.maskStore)) { + config = this.config.resultsTrack; + config.store = this.store.name; + config.storeClass = this.store.config.type; + return config; + } + config.key = this.config.resultsTrack.key; + config.label = this.config.resultsTrack.label; + config.metadata = this.config.resultsTrack.metadata; + } + return config; +}, + +// Refresh what the user sees on the screen for this track +refresh: function(track) { + if(!track) { + track = this; + } + if(this._visible().store && !this.onlyRefreshOuter) { + // Reload the store if it's not too much trouble + this._visible().store.reload(this._visible().tree, this.maskStore, this.displayStore); + } + else { + if(!this.onlyRefreshOuter) { + // Causes the resultsTrack to be removed from the config when it has been removed + delete this.config.resultsTrack; + delete this.config.opTree; + } + } + + // once the store is properly reloaded, make sure the track is showing data correctly + if(this.range) { + track.clear(); + track.showRange(this.range.f, this.range.l, this.range.st, this.range.b, + this.range.sc, this.range.cs, this.range.ce); + } + this.makeTrackMenu(); +}, + +clear: function() { + this.inherited(arguments); + if(this.resultsTrack && !this.onlyRefreshOuter) { + this.resultsTrack.clear(); + } +}, + +hideAll: function() { + this.inherited(arguments); + if(this.resultsTrack && !this.onlyRefreshOuter) { + this.resultsTrack.hideAll(); + } +}, + +hideRegion: function( location ) { + this.inherited(arguments); + if(this.resultsTrack && !this.onlyRefreshOuter) { + this.resultsTrack.hideRegion( location ); + } +}, + +sizeInit: function( numBlocks, widthPct, blockDelta ) { + this.inherited(arguments); + if(this.resultsTrack && !this.onlyRefreshOuter) { + this.resultsTrack.sizeInit( numBlocks, widthPct, blockDelta); + } +}, + +// Extends the BlockBased track's showRange function. +showRange: function(first, last, startBase, bpPerBlock, scale, containerStart, containerEnd) { + + this.range = {f: first, l: last, st: startBase, + b: bpPerBlock, sc: scale, + cs: containerStart, ce: containerEnd}; + if(this.resultsTrack && !this.onlyRefreshOuter) { + // This is a workaround to a glitch that causes an opaque white rectangle to appear sometimes when a quantitative + // track is loaded. + var needsDiv = !this.resultsDiv.parentNode; + if(needsDiv) { + this.div.appendChild(this.resultsDiv); + } + + var loadedRegions = []; + var stores = [this.store, this.maskStore, this.displayStore]; + for(var i in stores) { + if(stores[i] && typeof stores[i].loadRegion == 'function') { + var start = startBase; + var end = startBase + (last + 1 - first)*bpPerBlock; + var loadedRegion = stores[i].loadRegion({ref: this.refSeq.name, start: start, end: end}) + loadedRegions.push(loadedRegion); + loadedRegion.then(function(){}, this.errorCallback); // Add error callbacks to all deferred rejections + } + } + when(all(loadedRegions), dojo.hitch(this, function(reloadedStores){ + if(reloadedStores.length && reloadedStores.indexOf(this._visible().store) != -1) { + this.resultsTrack.clear(); + } + this.resultsTrack.showRange(first, last, startBase, bpPerBlock, scale, containerStart, containerEnd); + }), + this.errorCallback); + + if(needsDiv) { + this.div.removeChild(this.resultsDiv); + } + } + // Run the method from BlockBased.js + this.inherited(arguments); + // Make sure the height of this track is right + this.heightUpdate(this.height); + this.div.style.height = this.height + "px"; +}, + +// If moveBlocks is called on this track, should be called on the results track as well +moveBlocks: function(delta) { + this.inherited(arguments); + if(this.resultsTrack) + this.resultsTrack.moveBlocks(delta); +}, + +// fillBlock in this renders all the relevant borders etc that surround the results track and let the user know +// that this is a combination track +fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + + if( !this.resultsTrack ) { + this.fillMessage( blockIndex, block, 'Drag tracks here to combine them.' ); + } + else { + this.heightUpdate( this.heightResults, blockIndex); + } + args.finishCallback(); +}, + +// endZoom is passed down to resultsTrack +endZoom: function(destScale, destBlockBases) { + this.clear(); // Necessary? + if(this.resultsTrack) + this.resultsTrack.endZoom(); +}, + +// updateStaticElements passed down to resultsTrack +updateStaticElements: function(args) { + this.inherited(arguments); + if(this.resultsTrack) + this.resultsTrack.updateStaticElements(args); +}, + +// When the results track can be shown in multiple different classes +// (e.g. XYPlot or Density), this allows users to choose between them +setClassIndex: function(index, type) { + if(!type) + type = this._visible().which; + if(type == "mask" && this.displayStore) + type = this.supportedBy[this.displayStore.config.type]; + this.classIndex[type] = index; +}, + +// Like the setClassIndex function, but accepts the actual file path of the track in question +setTrackClass: function( tclass, type ) { + var allPaths = this.trackClasses[ type ].resultsTypes.map( function( item ) { return item.path; } ); + var index = allPaths.indexOf( tclass ); + if( index >= 0 ) { + this.setClassIndex( index, type ); + } +}, + +// When the results track can be shown in multiple different classes +// (e.g. XYPlot or Density), this tells us which one is currently +// chosen +getClassIndex: function(type) { + if(type == "mask" && this.displayStore) + type = this.supportedBy[this.displayStore.config.type]; + return this.classIndex[type]; +}, + +// Adds options to the track context menu +_trackMenuOptions: function() { + + // Allows the combination track to "mimic" the menu options of its results track + var resultsTrackOptions = ( this.resultsTrackMenuOptions || function() { return undefined; } ).call( this.resultsTrack ); + resultsTrackOptions = resultsTrackOptions || []; + + var inheritedOptions = this.inherited( arguments ); + var inheritedLabels = inheritedOptions.map( function( menuItem ) { + return menuItem.label; + }); + + for( var i = 0; i < resultsTrackOptions.length; i++ ) { + if(resultsTrackOptions[i].label && inheritedLabels.indexOf( resultsTrackOptions[i].label ) != -1) { + resultsTrackOptions.splice( i--, 1); + } + } + var o = inheritedOptions.concat( resultsTrackOptions ); + + //var o = this.inherited(arguments); + + + var combTrack = this; + + // If no tracks are added, we don't need to add any more options + if( !this.storeType ) + return o; + + if( this.storeType == "mask" ) { + // If a masking track, enables users to toggle between viewing data, mask, and masked data + var maskOrDisplay = ["masked data", "mask", "data only"]; + var maskOrDisplayItems = + Object.keys(maskOrDisplay) + .map( function(i) { + return { + type: 'dijit/CheckedMenuItem', + checked: (combTrack.storeToShow == i), + label: maskOrDisplay[i], + title: "View " + maskOrDisplay[i], + action: function() { + combTrack.storeToShow = i; + combTrack.renderResultsTrack(); + } + }; + }); + o.push.apply( + o, + [{ + type: 'dijit/MenuSeparator' + }, + { + children: maskOrDisplayItems, + label: "View", + title: "switch between the mask, display data and masked data for this masking track" + }]); + } + + // User may choose which class to render results track (e.g. XYPlot or Density) if multiple options exist + var classes = this.trackClasses[this._visible().which].resultsTypes; + + var classItems = Object.keys(classes).map(function(i){ + return { + type: 'dijit/CheckedMenuItem', + label: classes[i].name, + checked: (combTrack.classIndex[combTrack._visible().which] == i), + title: "Display as " + classes[i].name + " track", + action: function() + { + combTrack.setClassIndex(i); + delete combTrack.config.resultsTrack; + combTrack.renderResultsTrack(); + } + }; + }); + o.push.apply( + o, + [ + { type: 'dijit/MenuSeparator' }, + { + children: classItems, + label: "Track display", + title: "Change what type of track is being displayed" + } + ]); + + // Allow user to view the current track formula. + if(this.opTree) { + o.push.apply( + o, + [{ label: 'View formula', + title: 'View the formula specifying this combination track', + action: function() { + var formulaDialog = new Dialog({title: "View Formula"}); + var content = []; + var formulaDiv = dom.create("div", + {innerHTML: "No operation formula defined", className: "formulaPreview"}); + content.push(formulaDiv); + if(combTrack.opTree) { + formulaDiv.innerHTML = combTrack._generateTreeFormula(combTrack.opTree); + } + formulaDialog.set("content", content); + formulaDialog.show(); + } + }]); + } + + // If the current view contains more than one track combined, user may change the last operation applied + if(this._visible().tree && this._visible().tree.getLeaves().length > 1) { + var operationItems = this._visible().allowedOps.map( + function(op) { + return { + type: 'dijit/CheckedMenuItem', + checked: (combTrack._visible().tree.get() == op), + label: combTrack.inWords[op], + title: "change operation of last track to " + combTrack.inWords[op], + action: function() { + if(combTrack.opTree) { + combTrack._visible().tree.set(op); + combTrack.refresh(); + } + } + }; + }); + o.push.apply( + o, + [{ children: operationItems, + label: "Change last operation", + title: "change the operation applied to the last track added" + }] + ); + } + + return o; +}, + + // Turns an opTree into a formula to be better understood by the user. +_generateTreeFormula: function(tree) { + if(!tree || tree === undefined){ + return 'NULL'; + } + if(tree.isLeaf()){ + return '' + (tree.get().name ? (this.config.storeToKey[tree.get().name] ? this.config.storeToKey[tree.get().name] : tree.get().name) + : tree.get()) + ''; + } + return '(' + this._generateTreeFormula(tree.left()) +' '+ tree.get() +" " + this._generateTreeFormula(tree.right()) +")"; +}, + +_exportFormats: function() { + return this.exportFormats || []; + + +}, + +// These methods are not currently in use, but they allow direct loading of the opTree into the config. + +flatten: function(tree) { + var newTree = { + leaf: tree.leaf + }; + if(tree.leftChild) + newTree.leftChild = this.flatten(tree.leftChild); + if(tree.rightChild) + newTree.rightChild = this.flatten(tree.rightChild); + if(tree.get().name) + newTree.store = tree.get().name; + else + newTree.op = tree.get(); + return newTree; +}, + + +loadTree: function(tree) { + var d = new Deferred(); + var haveLeft = undefined; + var haveRight = undefined; + var thisB = this; + + if(!tree) { + d.resolve(undefined, true); + return d.promise; + } + + if(tree.leftChild) { + haveLeft = this.loadTree(tree.leftChild); + } + if(tree.rightChild) { + haveRight = this.loadTree(tree.rightChild); + } + when(all([haveLeft, haveRight]), function(results) { + var newTree = new TreeNode({ leftChild: results[0], rightChild: results[1], leaf: tree.leaf}); + if(tree.store) { + thisB.browser.getStore(tree.store, function(store) { + newTree.set(store); + }); + d.resolve(newTree, true); + } else { + newTree.set(tree.op); + d.resolve(newTree, true); + } + }); + return d.promise; +} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Combination/CombinationDialog.js b/www/JBrowse/View/Track/Combination/CombinationDialog.js new file mode 100644 index 00000000..56357e6f --- /dev/null +++ b/www/JBrowse/View/Track/Combination/CombinationDialog.js @@ -0,0 +1,466 @@ +define([ + 'dojo/_base/declare', + 'dijit/Dialog', + 'dijit/form/RadioButton', + 'dijit/form/Button', + 'dojo/dom-construct', + 'JBrowse/Model/BinaryTreeNode' + ], + function(declare, Dialog, RadioButton, Button, dom, TreeNode) { + +return declare(null, { + +// Produces a dialog box in which a user may enter settings for how they would like to combine tracks in a Combination Track. +constructor: function( args ) { + + this.newTrackKey = args.trackConfig ? args.trackConfig.key : args.key; + this.track = args.track; + this.newStore = args.store; + this.opTree = this.track.opTree; + + this.currType = this.track.currType; + this.oldType = this.track.oldType; + this.supportedBy = this.track.supportedBy; + this.displayType = this.track.displayType; + + this.storeToKey = this.track.config.storeToKey; + + this.newDisplayType = this.displayType; + + this.inWords = this.track.inWords; + + this.trackClasses = this.track.trackClasses; + + this.dialog = new Dialog( + { + title: "Combine with " + this.newTrackKey, + style: "width: 475px;", + className: "combinationDialog" + }); + var content = this._dialogContent(this.newStore); + this.dialog.set('content', content); +}, + +_dialogContent: function(store) { + var nodesToAdd = []; + + var opList = this._allAllowedOperations(store); + if(!opList.length) { + nodesToAdd.push( + dom.create("div", {innerHTML: "No operations are possible for this track."}) + ); + var actionBar = this._createActionBar(false); + nodesToAdd.push(actionBar); + return nodesToAdd; + } + + nodesToAdd.push( + dom.create( "div", { className: 'intro', innerHTML: "Adding " + this.currType + " track " + this.newTrackKey + " to the combination." }) + ); + + var maskOpListDiv = dom.create("div", {id: this.track.name + "_maskOpList"}); + + var thisB = this; + + var maskOps = this._makeUnique(opList.map(function(item) { return item.substring(0, 4); })); + nodesToAdd.push(maskOpListDiv); + + this.changingOpPanel = dom.create("div", {id: this.track.name + "_suffixLists"}); + nodesToAdd.push(this.changingOpPanel); + + nodesToAdd.push(dom.create("h2", {innerHTML: "Combination formula"})); + + this.formulaPreview = dom.create("div", {innerHTML: "(nothing currently selected)", className: "formulaPreview"}); + nodesToAdd.push(this.formulaPreview); + + this.maskOpButtons = []; + + for(var i in maskOps) { + var opButton = this._renderRadioButton(maskOpListDiv, maskOps[i], this.inWords[maskOps[i]]); + this.maskOpButtons.push(opButton); + + opButton.on("change", function(isSelected) { + if(isSelected) { + delete this.whichArg; + delete this.opValue; + + thisB.maskOpValue = this.value; + + var numOpLists = thisB.maskOpValue == "1111" ? 3 : 1; + thisB.opListDivs = []; + thisB.whichArgDivs = []; + + thisB.opValue = []; + thisB.whichArg = []; + + thisB.changingOpPanel.innerHTML = ""; + + for(var i = 0; i < numOpLists; i++) { + + var opDiv = dom.create("div", {id: thisB.track.name + "_suffix" + i, + style: {display: "inline-block", "padding-left": "15px", "vertical-align": "top"}}, thisB.changingOpPanel); + if(numOpLists == 3) { + var text = ["Main", "Mask", "Display"]; + dom.create("h2", {innerHTML: text[i]}, opDiv); + } + + var whichOpSpan = dom.create("h3", {innerHTML: "Combining operation", style: {display: "none"}}, opDiv); + + + thisB.opListDivs[i] = dom.create("div", {id: thisB.track.name + "_OpList" + i}, opDiv); + + var leftRightSpan = dom.create("h3", {innerHTML: "Left or right?", style: {display: "none"}}, opDiv); + thisB.whichArgDivs[i] = dom.create("div", {id: thisB.track.name + "_whichArg" + i}, opDiv); + + var opButtons = thisB._generateSuffixRadioButtons(this.value, opList, store, thisB.opListDivs[i], i); + var leftRightButtons = thisB._maybeRenderWhichArgDiv(this.value, store, thisB.whichArgDivs[i], i); + + if(leftRightButtons.length && !thisB.whichOpArg) { + leftRightButtons[0].set('checked', 'checked'); + } + if(opButtons.length) { + opButtons[0].set('checked', 'checked'); + } + + whichOpSpan.style.display = opButtons.length ? "" : "none"; + leftRightSpan.style.display = leftRightButtons.length ? "" : "none"; + } + } + }); + } + + if( maskOps[0] ) + this.maskOpButtons[0].set('checked', 'checked'); + + if( maskOps.length <= 1 ) { + if ( !maskOps.length || maskOps[0] == "0000") { + maskOpListDiv.style.display = 'none'; + } + this.maskOpButtons[0].set('disabled', 'disabled'); + } + + var actionBar = this._createActionBar(); + + nodesToAdd.push(actionBar); + + return nodesToAdd; +}, + +_createActionBar: function (addingEnabled) { + if(addingEnabled === undefined) + addingEnabled = true; + var actionBar = dom.create("div", { className: "dijitDialogPaneActionBar"}); + new Button({ + iconClass: 'dijitIconDelete', + label: "Cancel", + onClick: dojo.hitch(this, function() { + this.shouldCombine = false; + this.dialog.hide(); + }) + }).placeAt(actionBar); + + var btnCombine = new Button({ + label: "Combine tracks", + onClick: dojo.hitch(this, function() { + this.shouldCombine = true; + this.dialog.hide(); + }) + }); + + btnCombine.placeAt(actionBar); + + if(!addingEnabled) + btnCombine.set("disabled", "disabled"); + return actionBar; +}, + +_generateSuffixRadioButtons: function(prefix, stringlist, store, parent, offset) { + offset = offset || 0; + while(parent.firstChild) { + if(dijit.byId(parent.firstChild.id)) dijit.byId(parent.firstChild.id).destroy(); + dom.destroy(parent.firstChild); + } + var buttons = []; + + var thisB = this; + var allowedOps = this._generateSuffixList(prefix, stringlist, offset); + for(var i in allowedOps) { + var opButton = this._renderRadioButton(parent, allowedOps[i], this.inWords[allowedOps[i]]); + buttons.push(opButton); + opButton.on("change", function(isSelected) { + if(isSelected) { + thisB.opValue[offset] = this.value; + var operation = thisB._getOperation(); + thisB.previewTree = thisB._createPreviewTree(operation, store); + thisB.formulaPreview.innerHTML = thisB._generateTreeFormula(thisB.previewTree); + } + }); + } + return buttons; +}, + +_getOperation: function() { + var retString = this.maskOpValue; + for(var i = 0; i < this.opListDivs.length; i++) { + retString = retString + this.opValue[i] + this.whichArg[i]; + } + return retString; +}, + +//Type checking necessary? +_generateSuffixList: function(prefix, stringlist, offset) { + if(offset === undefined) offset = 0; + return this._makeUnique(stringlist.filter(function(value) { + return value.indexOf(prefix) != -1; + }).map(function(item) { + return item.substring(prefix.length + offset, prefix.length + offset + 1); + })); +}, + +_maybeRenderWhichArgDiv: function(prefix, store, parent, offset) { + offset = offset || 0; + while(parent.firstChild) { + if(dijit.byId(parent.firstChild.id)) { + dijit.byId(parent.firstChild.id).destroy(); + } + dom.destroy(parent.firstChild); + } + var leftRightButtons = []; + var thisB = this; + + var whichArgChange = function(isSelected, value) { + if(isSelected) { + thisB.whichArg[offset] = value === undefined ? this.value : value; + var operation = thisB._getOperation(); + thisB.previewTree = thisB._createPreviewTree(operation, store); + thisB.formulaPreview.innerHTML = thisB._generateTreeFormula(thisB.previewTree); + } + }; + + if(prefix == "0020") + whichArgChange(true, "L"); + else if (prefix == "0002") + whichArgChange(true, "R"); + else if (prefix == "1111" && offset == 0) + whichArgChange(true, "?"); + else { + var rbLeft = this._renderRadioButton(parent, "L", "left"); + var rbRight = this._renderRadioButton(parent, "R", "right"); + leftRightButtons.push(rbLeft); + leftRightButtons.push(rbRight); + rbLeft.on("change", whichArgChange); + rbRight.on("change", whichArgChange); + } + + return leftRightButtons; +}, + +_makeUnique: function(stringArray) { + var unique = {}; + return stringArray.filter(function(value) { + if(!unique[value]) { + unique[value] = true; + return true; + } + return false; + }); +}, + +_createPreviewTree: function (opString, store ) { + // Recursive cloning would probably be safer, but this seems to be working okay + var newOpTree = store.opTree ? store.opTree.clone() : new TreeNode({Value: store}); + if(newOpTree) { + newOpTree.recursivelyCall(function(node) { + node.highlighted = true; + }); + } + var superior = new TreeNode(this.opTree); + var firstChars = opString.substring(0, 2); + var inferior = newOpTree; + if(firstChars == "01") { + superior = newOpTree; + inferior = this.opTree; + } + return this._applyTreeTransform(opString.substring(2), superior, inferior); +}, + +_applyTreeTransform: function (opString, superior, inferior) { + var retTree = superior; + var firstChars = opString.substring(0, 2); + var childToUse; + var opTree1 = superior; + var opTree2 = inferior; + switch(firstChars) { + case "10": + opTree1 = superior.leftChild; + childToUse = "leftChild"; + opTree2 = inferior; + break; + case "01": + opTree1 = superior.rightChild; + childToUse = "rightChild"; + opTree2 = inferior; + break; + case "11": + retTree = new TreeNode({Value: opString.substring(2,3)}); + retTree["leftChild"] = this._transformTree(opString.substring(4), superior.leftChild, inferior.leftChild); + opString = opString.substring(4); + childToUse = "rightChild"; + opTree1 = superior.rightChild; + opTree2 = inferior.rightChild; + break; + case "20": + this.newDisplayType = this.oldType; + break; + case "02": + this.newDisplayType = this.currType; + break; + } + var opNode= this._transformTree(opString.substring(2), opTree1, opTree2); + if(childToUse == undefined) + return opNode; + + retTree[childToUse] = opNode; + return retTree; +}, + +_transformTree: function(opString, opTree1, opTree2) { + + var op = opString.substring (0, 1); + var opNode = new TreeNode({Value: op}); + if(opString.substring(1,2) == "L") { + opNode.add(opTree2); + opNode.add(opTree1); + } else { + opNode.add(opTree1); + opNode.add(opTree2); + } + + return opNode; +}, + +// This mess constructs a complete list of all operations that can be performed +_allAllowedOperations: function(store) { + var allowedList = []; + var candidate = ""; + var allowedOps; + candidate = candidate + (this.oldType == "mask" ? "1" : "0"); + candidate = candidate + (this.currType == "mask" ? "1" : "0"); + if (candidate == "00") { + if(this.oldType == this.currType) { + var candidate2 = candidate + "00"; + allowedOps = this.trackClasses[this.currType].allowedOps; + for(var i in allowedOps) { + allowedList.push(candidate2 + allowedOps[i]); + } + } + allowedOps = this.trackClasses["mask"].allowedOps; + if(this.currType == "set") { + var candidate2 = candidate + "20"; + for(var i in allowedOps) allowedList.push(candidate2 + allowedOps[i]); + } + if(this.oldType == "set") { + var candidate2 = candidate + "02"; + for(var i in allowedOps) allowedList.push(candidate2 + allowedOps[i]); + } + } else if (candidate == "10") { + if(this.currType == "set") { + allowedOps = this.trackClasses[this.currType].allowedOps; + var candidate2 = candidate + "10"; + for(var i in allowedOps) { + allowedList.push(candidate2 + allowedOps[i]); + } + } + if(this.currType == this.displayType) { + var candidate2 = candidate + "01"; + allowedOps = this.trackClasses[this.currType].allowedOps; + for(var i in allowedOps) { + allowedList.push(candidate2 + allowedOps[i]); + } + } + } else if (candidate == "01") { + if(this.oldType == "set") { + allowedOps = this.trackClasses[this.oldType].allowedOps; + var candidate2 = candidate + "10"; + for(var i in allowedOps) { + allowedList.push(candidate2 + allowedOps[i]); + } + } + var displayType = this.supportedBy[store.stores.display.config.type]; + if(this.oldType == displayType) { + candidate = candidate + "01"; + var allowedOps = this.trackClasses[displayType].allowedOps; + for(var i in allowedOps) { + allowedList.push(candidate + allowedOps[i]); + } + } + } else if (candidate == "11") { // Fix the logic of the tree manipulation to work with out the last L's and R's + candidate = candidate + "11"; + allowedOps = this.trackClasses["set"].allowedOps; + for(var i in allowedOps) { + var displayType = this.supportedBy[store.stores.display.config.type]; + var oldType = this.displayType; + if(displayType == oldType) { + var allowedOps2 = this.trackClasses[displayType].allowedOps; + for(var j in allowedOps2) { + var allowedMaskOps = this.trackClasses["mask"].allowedOps; + for(var k in allowedMaskOps) { + allowedList.push(candidate + allowedMaskOps[k] + allowedOps[i] + allowedOps2[j]); + } + } + } + } + } + + return allowedList; +}, + +_renderRadioButton: function(parent, value, label) { + var id = parent.id + "_rb_" + value; + if(dijit.byId(id)) { + dom.destroy(dijit.byId(id).domNode); + dijit.byId(id).destroy(); + } + + + label = label || value; + var radioButton = new RadioButton({name: parent.id + "_rb", id: id, value: value}); + parent.appendChild(radioButton.domNode); + var radioButtonLabel = dom.create("label", {"for": radioButton.id, innerHTML: label}, parent); + parent.appendChild(dom.create("br")); + return radioButton; +}, + +run: function( callback, cancelCallback, errorCallback) { + this.dialog.show(); + var thisB = this; + this.dialog.on("Hide", function() { + if(thisB.previewTree) { + thisB.previewTree.recursivelyCall(function(node) { + if(node.highlighted) + delete node.highlighted; + }); + } + if(thisB.shouldCombine) + callback(thisB.previewTree, thisB.newStore, thisB.newDisplayType); + else cancelCallback(); + }); +}, + +_generateTreeFormula: function(tree) { + if(!tree || tree === undefined){ + return 'NULL'; + } + if(tree.isLeaf()){ + return '' + (tree.get().name ? (this.storeToKey[tree.get().name] ? this.storeToKey[tree.get().name] : tree.get().name) + : tree.get()) + ''; + } + return '(' + this._generateTreeFormula(tree.left()) +' '+ tree.get() +" " + this._generateTreeFormula(tree.right()) +")"; +}, + +destroyRecursive: function() { + this.dialog.destroyRecursive(); +} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/DraggableAlignments.js b/www/JBrowse/View/Track/DraggableAlignments.js new file mode 100644 index 00000000..ab6e1c3f --- /dev/null +++ b/www/JBrowse/View/Track/DraggableAlignments.js @@ -0,0 +1,53 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/View/Track/Alignments', + 'WebApollo/View/Track/DraggableHTMLFeatures', + 'JBrowse/Util', + ], + function( + declare, + AlignmentsTrack, + DraggableTrack, + Util + ) { + +return declare([ DraggableTrack, AlignmentsTrack ], { + + constructor: function( args ) { + // forcing store to create subfeatures, unless config.subfeatures explicitly set to false + // default is set to true in _defaultConfig() + this.store.createSubfeatures = this.config.subfeatures; + }, + + _defaultConfig: function() { + var thisConfig = Util.deepUpdate( +// return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + layoutPitchY: 2, + subfeatures: true, + style: { + className: "bam-read", + renderClassName: null, + arrowheadClass: "arrowhead", + centerChildrenVertically: false, + showSubfeatures: true, + showMismatches: false, + showLabels: false, + subfeatureClasses: { + M: "cigarM", + D: "cigarD", + N: "cigarN", + E: "cigarEQ", /* "=" converted to "E" in BAM/LazyFeature subfeature construction */ + X: "cigarX", + I: "cigarI" + } + } + } + ); + return thisConfig; + } + +} ); + +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/DraggableHTMLFeatures.js b/www/JBrowse/View/Track/DraggableHTMLFeatures.js new file mode 100644 index 00000000..02840fe8 --- /dev/null +++ b/www/JBrowse/View/Track/DraggableHTMLFeatures.js @@ -0,0 +1,929 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Track/HTMLFeatures', + 'JBrowse/FeatureSelectionManager', + 'jquery', + 'jqueryui/draggable', + 'JBrowse/Util', + 'JBrowse/Model/SimpleFeature', + 'JBrowse/SequenceOntologyUtils' + ], + function (declare, array, HTMLFeatureTrack, FeatureSelectionManager, $, draggable, Util, SimpleFeature, SeqOnto) { + +/* Subclass of FeatureTrack that allows features to be selected, + and dragged and dropped into the annotation track to create annotations. + + WARNING: + for selection to work for features that cross block boundaries, z-index of feature style MUST be set, and must be > 0 + otherwise what happens is: + feature div inherits z-order from parent, so same z-order as block + so feature div pixels may extend into next block, but next block draws ON TOP OF IT (assuming next block added + to parent after current block). So events over part of feature div that isn't within it's parent block will never + reach feature div but instead be triggered on next block + This issue will be more obvious if blocks have background color set since then not only will selection not work but + part of feature div that extends into next block won't even be visible, since next block background will render over it + */ + +var debugFrame = false; + +//var DraggableFeatureTrack = declare( HTMLFeatureTrack, +var draggableTrack = declare( HTMLFeatureTrack, + +{ + // so is dragging + dragging: false, + + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + events: { + // need to map click to a null-op, to override default JBrowse click behavior for click on features + // (JBrowse default is feature detail popup) + click: function(event) { + // not quite a null-op, also need to suprress propagation of click recursively up through parent divs, + // in order to stop default JBrowse behavior for click on tracks (which is to recenter view at click point) + event.stopPropagation(); + } + // WebApollo can't set up mousedown --> onFeatureMouseDown() in config.events, + // because dojo.on used by JBrowse config-based event setup doesn't play nice with + // JQuery event retriggering via _mousedown() for feature drag bootstrapping + // also, JBrowse only sets these events for features, and WebApollo needs them to trigger for subfeatures as well + // , mousedown: dojo.hitch( this, 'onFeatureMouseDown' ), + // , dblclick: dojo.hitch( this, 'onFeatureDoubleClick' ) + } + } + ); + }, + + constructor: function( args ) { + this.gview = this.browser.view; + + // DraggableFeatureTracks all share the same FeatureSelectionManager if + // want subclasses to have different selection manager, call + // this.setSelectionManager in subclass (after calling parent + // constructor). + this.setSelectionManager(this.browser.featSelectionManager); + + // CSS class for selected features + // override if want subclass to have different CSS class for selected features + this.selectionClass = "selected-feature"; + + this.last_whitespace_mousedown_loc = null; + this.last_whitespace_mouseup_time = new Date(); // dummy timestamp + this.prev_selection = null; + + this.feature_context_menu = null; + this.edge_matching_enabled = true; + }, + + + loadSuccess: function(trackInfo) { + /* if subclass indicates it has custom context menu, do not initialize default feature context menu */ + if (! this.has_custom_context_menu) { + this.initFeatureContextMenu(); + this.initFeatureDialog(); + } + this.inherited( arguments ); + }, + + setSelectionManager: function(selman) { + if (this.selectionManager) { + this.selectionManager.removeListener(this); + } + this.selectionManager = selman; + // FeatureSelectionManager listeners must implement + // selectionAdded() and selectionRemoved() response methods + this.selectionManager.addListener(this); + return selman; + }, + +/** + * only called once, during track setup ??? + * + * doublclick in track whitespace is used by JBrowse for zoom + * but WebApollo/JBrowse uses single click in whitespace to clear selection + * + * so this sets up mousedown/mouseup/doubleclick + * kludge to restore selection after a double click to whatever selection was before + * initiation of doubleclick (first mousedown/mouseup) + * + */ + setViewInfo: function(genomeView, numBlocks, + trackDiv, labelDiv, + widthPct, widthPx, scale) { + this.inherited( arguments ); + + var $div = $(this.div); + var track = this; + + // this.scale = scale; // scale is in pixels per base + + // setting up mousedown and mouseup handlers to enable click-in-whitespace to clear selection + // (without conflicting with JBrowse drag-in-whitespace to scroll) + $div.bind('mousedown', function(event) { + var target = event.target; + if (! (target.feature || target.subfeature)) { + track.last_whitespace_mousedown_loc = [ event.pageX, event.pageY ]; + } + } ); + $div.bind('mouseup', function (event) { + var target = event.target; + if (! (target.feature || target.subfeature)) { // event not on feature, so must be on whitespace + var xup = event.pageX; + var yup = event.pageY; + // if click in whitespace without dragging (no movement between mouse down and mouse up, + // and no shift modifier, + // then deselect all + var eventModifier = event.shiftKey || event.altKey || event.metaKey || event.ctrlKey; + if (track.last_whitespace_mousedown_loc && + xup === track.last_whitespace_mousedown_loc[0] && + yup === track.last_whitespace_mousedown_loc[1] && + (! eventModifier )) { + var timestamp = new Date(); + var prev_timestamp = track.last_whitespace_mouseup_time; + track.last_whitespace_mouseup_time = timestamp; + // if less than half a second, probably a doubleclick (or triple or more click...) + var probably_doubleclick = ((timestamp.getTime() - prev_timestamp.getTime()) < 500); + if (probably_doubleclick) { + // don't record selection state, want to keep prev_selection set + // to selection prior to first mouseup of doubleclick + } + else { + track.prev_selection = track.selectionManager.getSelection(); + } + track.selectionManager.clearAllSelection(); + } + else { + track.prev_selection = null; + } + } + // regardless of what element it's over, mouseup clears out tracking of mouse down + track.last_whitespace_mousedown_loc = null; + }); + + // kludge to restore selection after a double click to + // whatever selection was before initiation of doubleclick + // (first mousedown/mouseup) + $div.bind('dblclick', function(event) { + var target = event.target; + // because of dblclick bound to features, will only bubble up to here on whitespace, + // but doing feature check just to make sure + if (! (target.feature || target.subfeature)) { + if (track.prev_selection) { + var plength = track.prev_selection.length; + // restore selection + for (var i = 0; i bs ) { return 1; } + else if ( as < bs ) { return -1; } + else { return 0; /* shouldn't fall through to here */ } + }, + + + /** + * if feature has translated region (CDS, wholeCDS, start_codon, ???), + * reworks feature's subfeatures for more annotation-editing-friendly selection + * + * Assumes: + * if translated, will either have + * CDS-ish term for each coding segment + * wholeCDS from start of translation to end of translation (so already pre-processed) + * mutually exclusive (either have CDS, or wholeCDS, but not both) + * if wholeCDS present, then pre-processed (no UTRs) + * if any exon-ish types present, then _all_ exons are present with exon-ish types + */ + _processTranslation: function( feature ) { + var track = this; + + var feat_type = feature.get('type'); + + // most very dense genomic feature tracks do not have CDS. Trying to minimize overhead for that case -- + // keep list of types that NEVER have CDS children (match, alignment, repeat, etc.) + // (WARNING in this case not sorting, but sorting (currently) only needed for features with CDS (for reading frame calcs)) + if (SeqOnto.neverHasCDS[feat_type]) { + feature.normalized = true; + return; + } + var subfeats = feature.get('subfeatures'); + + // var cds = subfeats.filter( function(feat) { return feat.get('type') === 'CDS'; } ); + var cds = subfeats.filter( function(feat) { + return SeqOnto.cdsTerms[feat.get('type')]; + } ); + var wholeCDS = subfeats.filter( function(feat) { return feat.get('type') === 'wholeCDS'; } ); + + // most very dense genomic feature tracks do not have CDS. Trying to minimize overhead for that case -- + // if no CDS, no wholeCDS, consider normalized + // (WARNING in this case not sorting, but sorting (currently) only needed for features with CDS (for reading frame calcs)) + // + if (cds.length === 0 && wholeCDS.length === 0) { + feature.normalized = true; + return; + } + + var newsubs; + // wholeCDS is specific to WebApollo, if seen can assume no CDS, and UTR/exon already normalized + if (wholeCDS.length > 0) { + // extract wholecds from subfeats, then sort subfeats + feature.wholeCDS = wholeCDS[0]; + newsubs = subfeats.filter( function(feat) { return feat.get('type') !== 'wholeCDS'; } ); + } + + // if has a CDS, remove CDS from subfeats and sort exons + else if (cds.length > 0) { + cds.sort(this._subfeatSorter); + var cdsmin = cds[0].get('start'); + var cdsmax = cds[cds.length-1].get('end'); + feature.wholeCDS = new SimpleFeature({ parent: feature, + data: { start: cdsmin, end: cdsmax, type: 'wholeCDS', + strand: feature.get('strand') } + } ); + var hasExons = false; + for (var i=0; i 0, guaranteed to have at least one CDS + var exonCount = 0; + var prevStart, prevEnd; + // scan through sorted subfeats, joining abutting UTR/CDS regions + for (var i=0; i displayStart) && (subStart < displayEnd)); + var render = subDiv && (subEnd > displayStart) && (subStart < displayEnd); + + // look for UTR and CDS subfeature class mapping from trackData + // if can't find, then default to parent feature class + "-UTR" or "-CDS" + if( render ) { // subfeatureClases defaults set in this._defaultConfig + UTRclass = this.config.style.subfeatureClasses["UTR"]; + CDSclass = this.config.style.subfeatureClasses["CDS"]; + } + + // if ((subEnd <= displayStart) || (subStart >= displayEnd)) { return undefined; } + + var segDiv; + // console.log("render sub frame"); + // whole exon is untranslated (falls outside wholeCDS range, or no CDS info found) + if( (cdsMin === undefined && cdsMax === undefined) || + (cdsMax <= subStart || cdsMin >= subEnd)) { + if( render ) { + segDiv = document.createElement("div"); + // not worrying about appending "plus-"/"minus-" based on strand yet + dojo.addClass(segDiv, "subfeature"); + dojo.addClass(segDiv, UTRclass); + if (Util.is_ie6) segDiv.appendChild(document.createComment()); + segDiv.style.cssText = + "left: " + (100 * ((subStart - subStart) / subLength)) + "%;" + + "width: " + (100 * ((subEnd - subStart) / subLength)) + "%;"; + subDiv.appendChild(segDiv); + } + } + + /* + Frame is calculated as (3 - ((length-frame) mod 3)) mod 3. + (length-frame) is the length of the previous feature starting at the first whole codon (and thus the frame subtracted out). + (length-frame) mod 3 is the number of bases on the 3' end beyond the last whole codon of the previous feature. + 3-((length-frame) mod 3) is the number of bases left in the codon after removing those that are represented at the 3' end of the feature. + (3-((length-frame) mod 3)) mod 3 changes a 3 to a 0, since three bases makes a whole codon, and 1 and 2 are left unchanged. + */ + // whole exon is translated + else if (cdsMin <= subStart && cdsMax >= subEnd) { + var overhang = priorCdsLength % 3; // number of bases overhanging from previous CDS + var relFrame = (3 - (priorCdsLength % 3)) % 3; + var absFrame, cdsFrame, initFrame; + if (reverse) { + initFrame = (cdsMax - 1) % 3; + absFrame = (subEnd - 1) % 3; + cdsFrame = (3 + absFrame - relFrame) % 3; + } + else { + initFrame = cdsMin % 3; + absFrame = (subStart % 3); + cdsFrame = (absFrame + relFrame) % 3; + } + if (debugFrame) { + console.log("whole exon: " + subStart + " -- ", subEnd, " initFrame: ", initFrame, + ", overhang: " + overhang + ", relFrame: ", relFrame, ", absFrame: ", absFrame, + ", cdsFrame: " + cdsFrame); + } + + if (render) { + segDiv = document.createElement("div"); + // not worrying about appending "plus-"/"minus-" based on strand yet + dojo.addClass(segDiv, "subfeature"); + dojo.addClass(segDiv, CDSclass); + if (Util.is_ie6) segDiv.appendChild(document.createComment()); + segDiv.style.cssText = + "left: " + (100 * ((subStart - subStart) / subLength)) + "%;" + + "width: " + (100 * ((subEnd - subStart) / subLength)) + "%;"; + if (this.config.style.colorCdsFrame || this.browser.colorCdsByFrame) { + dojo.addClass(segDiv, "cds-frame" + cdsFrame); + } + subDiv.appendChild(segDiv); + } + priorCdsLength += subLength; + } + // partial translation of exon + else { + // calculate 5'UTR, CDS segment, 3'UTR + var cdsSegStart = Math.max(cdsMin, subStart); + var cdsSegEnd = Math.min(cdsMax, subEnd); + var overhang = priorCdsLength % 3; // number of bases overhanging + var absFrame, cdsFrame, initFrame; + if (priorCdsLength > 0) { + var relFrame = (3 - (priorCdsLength % 3)) % 3; + if (reverse) { + // cdsFrame = ((subEnd-1) + ((3 - (priorCdsLength % 3)) % 3)) % 3; } + initFrame = (cdsMax - 1) % 3; + absFrame = (subEnd - 1) % 3; + cdsFrame = (3 + absFrame - relFrame) % 3; + } + else { + // cdsFrame = (subStart + ((3 - (priorCdsLength % 3)) % 3)) % 3; + initFrame = cdsMin % 3; + absFrame = (subStart % 3); + cdsFrame = (absFrame + relFrame) % 3; + } + if (debugFrame) { console.log("partial exon: " + subStart + ", initFrame: " + (cdsMin % 3) + + ", overhang: " + overhang + ", relFrame: " + relFrame + ", subFrame: " + (subStart % 3) + + ", cdsFrame: " + cdsFrame); } + } + else { // actually shouldn't need this? -- if priorCdsLength = 0, then above conditional collapses down to same calc... + if (reverse) { + cdsFrame = (cdsMax-1) % 3; // console.log("rendering reverse frame"); + } + else { + cdsFrame = cdsMin % 3; + } + } + + var utrStart; + var utrEnd; + // make left UTR (if needed) + if (cdsMin > subStart) { + utrStart = subStart; + utrEnd = cdsSegStart; + if (render) { + segDiv = document.createElement("div"); + // not worrying about appending "plus-"/"minus-" based on strand yet + dojo.addClass(segDiv, "subfeature"); + dojo.addClass(segDiv, UTRclass); + if (Util.is_ie6) segDiv.appendChild(document.createComment()); + segDiv.style.cssText = + "left: " + (100 * ((utrStart - subStart) / subLength)) + "%;" + + "width: " + (100 * ((utrEnd - utrStart) / subLength)) + "%;"; + subDiv.appendChild(segDiv); + } + } + if (render) { + // make CDS segment + segDiv = document.createElement("div"); + // not worrying about appending "plus-"/"minus-" based on strand yet + dojo.addClass(segDiv, "subfeature"); + dojo.addClass(segDiv, CDSclass); + if (Util.is_ie6) segDiv.appendChild(document.createComment()); + segDiv.style.cssText = + "left: " + (100 * ((cdsSegStart - subStart) / subLength)) + "%;" + + "width: " + (100 * ((cdsSegEnd - cdsSegStart) / subLength)) + "%;"; + if (this.config.style.colorCdsFrame || this.browser.colorCdsByFrame) { + dojo.addClass(segDiv, "cds-frame" + cdsFrame); + } + subDiv.appendChild(segDiv); + } + priorCdsLength += (cdsSegEnd - cdsSegStart); + + // make right UTR (if needed) + if (cdsMax < subEnd) { + utrStart = cdsSegEnd; + utrEnd = subEnd; + if (render) { + segDiv = document.createElement("div"); + // not worrying about appending "plus-"/"minus-" based on strand yet + dojo.addClass(segDiv, "subfeature"); + dojo.addClass(segDiv, UTRclass); + if (Util.is_ie6) segDiv.appendChild(document.createComment()); + segDiv.style.cssText = + "left: " + (100 * ((utrStart - subStart) / subLength)) + "%;" + + "width: " + (100 * ((utrEnd - utrStart) / subLength)) + "%;"; + subDiv.appendChild(segDiv); + } + } + } + return priorCdsLength; + }, + + + /* + * selection occurs on mouse down + * mouse-down on unselected feature -- deselect all & select feature + * mouse-down on selected feature -- no change to selection (but may start drag?) + * mouse-down on "empty" area -- deselect all + * (WARNING: this is preferred behavior, but conflicts with dblclick for zoom -- zoom would also deselect) + * therefore have mouse-click on empty area deselect all (no conflict with dblclick) + * shift-mouse-down on unselected feature -- add feature to selection + * shift-mouse-down on selected feature -- remove feature from selection + * shift-mouse-down on "empty" area -- no change to selection + * + * "this" should be a featdiv or subfeatdiv + */ + onFeatureMouseDown: function(event) { + // event.stopPropagation(); + + this.handleFeatureSelection(event); + this.handleFeatureDragSetup(event); + }, + + handleFeatureSelection: function( event ) { + var ftrack = this; + var selman = ftrack.selectionManager; + var featdiv = (event.currentTarget || event.srcElement); + var feat = featdiv.feature || featdiv.subfeature; + + if( selman.unselectableTypes[feat.get('type')] ) { + return; + } + + var already_selected = selman.isSelected( { feature: feat, track: ftrack } ); + var parent_selected = false; + var parent = feat.parent(); + if (parent) { + parent_selected = selman.isSelected( { feature: parent, track: ftrack } ); + } + // if parent is selected, allow propagation of event up to parent, + // in order to ensure parent draggable setup and triggering + // otherwise stop propagation + if (! parent_selected) { + event.stopPropagation(); + } + if (event.shiftKey) { + if (already_selected) { // if shift-mouse-down and this already selected, deselect this + selman.removeFromSelection( { feature: feat, track: this }); + } + else if (parent_selected) { + // if shift-mouse-down and parent selected, do nothing -- + // event will get propagated up to parent, where parent will get deselected... + // selman.removeFromSelection(parent); + } + else { // if shift-mouse-down and neither this or parent selected, select this + // children are auto-deselected by selection manager when parent is selected + selman.addToSelection({ feature: feat, track: this }, true); + } + } + else if (event.altKey) { + } + else if (event.ctrlKey) { + } + else if (event.metaKey) { + } + else { // no shift modifier + if (already_selected) { // if this selected, do nothing (this remains selected) + } + else { + if (parent_selected) { + // if this not selected but parent selected, do nothing (parent remains selected) + // event will propagate up (since parent_selected), so draggable check + // will be done in bubbled parent event + } + else { // if this not selected and parent not selected, select this + selman.clearSelection(); + selman.addToSelection({ track: this, feature: feat}); + } + } + } + }, + + /* + * WARNING: Assumes one level (featdiv has feature) or two-level (featdiv + * has feature, subdivs have subfeature) feature hierarchy. + */ + handleFeatureDragSetup: function(event) { + var ftrack = this; + var featdiv = (event.currentTarget || event.srcElement); + var feat = featdiv.feature || featdiv.subfeature; + var selected = this.selectionManager.isSelected({ feature: feat, track: ftrack }); + + var valid_drop; + /** + * ideally would only make $.draggable call once for each selected div + * but having problems with draggability disappearing from selected divs + * that $.draggable was already called on + * therefore whenever mousedown on a previously selected div also want to + * check that draggability and redo if missing + */ + if (selected) { + var $featdiv = $(featdiv); + if (! $featdiv.hasClass("ui-draggable")) { + $featdiv.draggable({ // draggable() adds "ui-draggable" class to div + zIndex: 200, + helper: 'clone', + opacity: 0.5, + axis: 'y', + revert: function (valid) { + valid_drop = !!valid; + return !valid; + }, + stop: function (event, ui) { + if (valid_drop) { + ftrack.selectionManager.clearAllSelection(); + } + } + }); + $featdiv.data("ui-draggable")._mouseDown(event); + } + } + }, + + /* given a feature or subfeature, return block that rendered it */ + getBlock: function( featdiv ) { + var fdiv = featdiv; + while (fdiv.feature || fdiv.subfeature) { + if (fdiv.parentNode.block) { return fdiv.parentNode.block; } + fdiv = fdiv.parentNode; + } + return null; // should never get here... + }, + + getEquivalentBlock: function ( block ) { + var startBase = block.startBase; + var endBase = block.endBase; + for (var i=this.firstAttached; i<=this.lastAttached; i++) { + var testBlock = this.blocks[i]; + if (testBlock.startBase == startBase && testBlock.endBase == endBase) { + return testBlock; + } + } + return null; + }, + + onFeatureDoubleClick: function( event ) { + var ftrack = this; + var selman = ftrack.selectionManager; + // prevent event bubbling up to genome view and triggering zoom + event.stopPropagation(); + var featdiv = (event.currentTarget || event.srcElement); + + // only take action on double-click for subfeatures + // (but stop propagation for both features and subfeatures) + // GAH TODO: make this work for feature hierarchies > 2 levels deep + var subfeat = featdiv.subfeature; + // if (subfeat && (! unselectableTypes[subfeat.get('type')])) { // only allow double-click parent selection for selectable features + if( subfeat && selman.isSelected({ feature: subfeat, track: ftrack }) ) { // only allow double-click of child for parent selection if child is already selected + var parent = subfeat.parent(); + // select parent feature + // children (including subfeat double-clicked one) are auto-deselected in FeatureSelectionManager if parent is selected + if( parent ) { selman.addToSelection({ feature: parent, track: ftrack }); } + } + }, + + + /** + * returns first feature or subfeature div (including itself) + * found when crawling towards root from branch in + * feature/subfeature/descendants div hierachy + */ + getLowestFeatureDiv: function(elem) { + while (!elem.feature && !elem.subfeature) { + elem = elem.parentNode; + if (elem === document) {return null;} + } + return elem; + }, + + + /** + * Near as I can tell, track.showRange is called every time the + * appearance of the track changes in a way that would cause + * feature divs to be added or deleted (or moved? -- not sure, + * feature moves may also happen elsewhere?) So overriding + * showRange here to try and map selected features to selected + * divs and make sure the divs have selection style set + */ + showRange: function( first, last, startBase, bpPerBlock, scale, + containerStart, containerEnd ) { + this.inherited( arguments ); + + // console.log("called DraggableFeatureTrack.showRange(), block range: " + + // this.firstAttached + "--" + this.lastAttached + ", " + (this.lastAttached - this.firstAttached)); + // redo selection styles for divs in case any divs for selected features were changed/added/deleted + var srecs = this.selectionManager.getSelection(); + for (var sin in srecs) { + // only look for selected features in this track -- + // otherwise will be redoing (sfeats.length * tracks.length) times instead of sfeats.length times, + // because showRange is getting called for each track + var srec = srecs[sin]; + if (srec.track === this) { + // some or all feature divs are usually recreated in a showRange call + // therefore calling track.selectionAdded() to retrigger setting of selected-feature CSS style, etc. on new feat divs + this.selectionAdded(srec); + } + } + }, + + /* bypassing HTMLFeatures floating of arrows to keep them in view, too buggy for now */ + updateFeatureArrowPositions: function( coords ) { + return; + }, + + /** + * for the input mouse event, returns genome position under mouse IN 0-BASED INTERBASE COORDINATES + * WARNING: + * returns genome coord in 0-based interbase (which is how internal data structure represent coords), + * instead of 1-based interbase (which is how UI displays coordinates) + * if need display coordinates, use getUiGenomeCoord() directly instead + * + * otherwise same capability and assumptions as getUiGenomeCoord(): + * event can be on GenomeView.elem or any descendant DOM elements (track, block, feature divs, etc.) + * assumes: + * event is a mouse event (plain Javascript event or JQuery event) + * elem is a DOM element OR JQuery wrapped set (in which case result is based on first elem in result set) + * elem is displayed (see JQuery.offset() docs) + * no border/margin/padding set on the doc element (see JQuery.offset() docs) + * if in IE<9, either page is not scrollable (in the HTML page sense) OR event is JQuery event + * (currently JBrowse index.html page is not scrollable (JBrowse internal scrolling is NOT same as HTML page scrolling)) + * + */ + getGenomeCoord: function(mouseEvent) { + return Math.floor(this.gview.absXtoBp(mouseEvent.pageX)); + // return this.getUiGenomeCoord(mouseEvent) - 1; + } + +}); + +return draggableTrack; +}); + + /* + Copyright (c) 2010-2011 Berkeley Bioinformatics Open-source Projects & Lawrence Berkeley National Labs + + This package and its accompanying libraries are free software; you can + redistribute it and/or modify it under the terms of the LGPL (either + version 2.1, or at your option, any later version) or the Artistic + License 2.0. Refer to LICENSE for the full license text. +*/ diff --git a/www/JBrowse/View/Track/DraggableResultFeatures.js b/www/JBrowse/View/Track/DraggableResultFeatures.js new file mode 100644 index 00000000..99dd4a10 --- /dev/null +++ b/www/JBrowse/View/Track/DraggableResultFeatures.js @@ -0,0 +1,48 @@ +define( [ + 'dojo/_base/declare', + 'WebApollo/View/Track/DraggableHTMLFeatures', + ], + function( declare, DraggableFeatureTrack) { + + var DraggableResultFeatures = declare( DraggableFeatureTrack, { + constructor: function(args) { }, + + makeTrackMenu: function() { + var track = this; + this.inherited(arguments); + var trackMenu = this.trackMenu; + var annotTrack; + for (var i = 0; i < this.genomeView.tracks.length; ++i) { + if (this.genomeView.tracks[i].isWebApolloAnnotTrack) { + annotTrack = this.genomeView.tracks[i]; + break; + } + } + if (trackMenu && annotTrack) { + var mitems = this.trackMenu.getChildren(); + for (var mindex=0; mindex < mitems.length; mindex++) { + if (mitems[mindex].type == "dijit/MenuSeparator") { break; } + } + trackMenu.addChild(new dijit.MenuItem({ + label: "Promote all to annotations", + iconClass: 'dijitIconEdit', + onClick: function() { + if (confirm("Are you sure you want to promote all annotations?")) { + var featuresToAdd = new Array(); + track.store.getFeatures({start: track.store.refSeq.start, end: track.store.refSeq.end}, function(feature) { + var afeat = JSONUtils.createApolloFeature(feature, "transcript"); + featuresToAdd.push(afeat); + }); + var postData = '{ "track": "' + annotTrack.getUniqueTrackName() + '", "features": ' + JSON.stringify(featuresToAdd) + ', "operation": "add_transcript" }'; + annotTrack.executeUpdateOperation(postData); + } + } + }), mindex); + } + + } + }); + + return DraggableResultFeatures; + +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/EditTrack.js b/www/JBrowse/View/Track/EditTrack.js new file mode 100644 index 00000000..95e82067 --- /dev/null +++ b/www/JBrowse/View/Track/EditTrack.js @@ -0,0 +1,999 @@ +define([ + 'dojo/_base/declare', + 'jquery', + 'jqueryui/droppable', + 'jqueryui/resizable', + 'dijit/Menu', + 'dijit/MenuItem', + 'dijit/MenuSeparator', + 'dijit/PopupMenuItem', + 'dijit/form/Button', + 'dijit/form/DropDownButton', + 'dijit/DropDownMenu', + + 'dijit/Dialog', + 'dojox/grid/DataGrid', + 'dojo/data/ItemFileWriteStore', + 'JBrowse/View/Track/DraggableHTMLFeatures', + 'JBrowse/FeatureSelectionManager', + 'JBrowse/JSONUtils', + 'JBrowse/BioFeatureUtils', + 'JBrowse/SequenceSearch', + 'JBrowse/Model/SimpleFeature', + 'JBrowse/Util', + 'JBrowse/View/GranularRectLayout', + ], + function(declare, + $, + droppable, + resizable, + dijitMenu, + dijitMenuItem, + dijitMenuSeparator, + dijitPopupMenuItem, + dijitButton, + dijitDropDownButton, + dijitDropDownMenu, + dijitDialog, + dojoxDataGrid, + dojoItemFileWriteStore, + DraggableFeatureTrack, + FeatureSelectionManager, + JSONUtils, + BioFeatureUtils, + SequenceSearch, + SimpleFeature, + Util, + Layout) { + +var creation_count = 0; +var contextMenuItems; +var context_path = ".."; + +var EditTrack = declare(DraggableFeatureTrack, +{ + constructor: function( args ) { + this.is_edit_track = true; + this.has_custom_context_menu = true; + this.exportAdapters = []; + + this.selectionManager = this.setSelectionManager(this.browser.annotSelectionManager); + + /** + * only show residues overlay if "pointer-events" CSS property is supported + * (otherwise will interfere with passing of events to features beneath the overlay) + */ + this.useResiduesOverlay = 'pointerEvents' in document.body.style; + this.FADEIN_RESIDUES = false; + this.selectionClass = "selected-annotation"; + + + this.gview.browser.subscribe("/jbrowse/v1/n/navigate", dojo.hitch(this, function(currRegion) { + if (currRegion.ref != this.refSeq.name) { + if (this.listener && this.listener.fired == -1 ) { + this.listener.cancel(); + } + } + })); + }, + + _defaultConfig: function() { + var thisConfig = this.inherited(arguments); + thisConfig.noExport = true; // turn off default "Save track data" " + thisConfig.style.centerChildrenVertically = false; + return thisConfig; + }, + + setViewInfo: function(genomeView, numBlocks, trackDiv, labelDiv, widthPct, widthPx, scale) { + this.inherited( arguments ); + var track = this; + this.makeTrackDroppable(); + this.hide(); + this.show(); + }, + + renderSubfeature: function( feature, featDiv, subfeature, + displayStart, displayEnd, block) { + var subdiv = this.inherited( arguments ); + + /** + * setting up annotation resizing via pulling of left/right edges but + * if subfeature is not selectable, do not bind mouse down + */ + if (subdiv && subdiv != null && (! this.selectionManager.unselectableTypes[subfeature.get('type')]) ) { + $(subdiv).bind("mousedown", dojo.hitch(this, 'onAnnotMouseDown')); + } + return subdiv; + }, + + /** + * handles mouse down on an annotation subfeature + * to make the annotation resizable by pulling the left/right edges + */ + onAnnotMouseDown: function(event) { + var track = this; + event = event || window.event; + var elem = (event.currentTarget || event.srcElement); + var featdiv = track.getLowestFeatureDiv(elem); + + if (featdiv && (featdiv != null)) { + if (dojo.hasClass(featdiv, "ui-resizable")) { + } + else { + var scale = track.gview.bpToPx(1); + + // if zoomed int to showing sequence residues, then make edge-dragging snap to interbase pixels + var gridvals; + var charSize = track.browser.getSequenceCharacterSize(); + if (scale === charSize.width) { gridvals = [track.gview.charWidth, 1]; } + else { gridvals = false; } + + $(featdiv).resizable({ + handles: "e, w", + helper: "ui-resizable-helper", + autohide: false, + grid: gridvals, + + stop: function(event, ui) { + var gview = track.gview; + var oldPos = ui.originalPosition; + var newPos = ui.position; + var oldSize = ui.originalSize; + var newSize = ui.size; + var leftDeltaPixels = newPos.left - oldPos.left; + var leftDeltaBases = Math.round(gview.pxToBp(leftDeltaPixels)); + var oldRightEdge = oldPos.left + oldSize.width; + var newRightEdge = newPos.left + newSize.width; + var rightDeltaPixels = newRightEdge - oldRightEdge; + var rightDeltaBases = Math.round(gview.pxToBp(rightDeltaPixels)); + var subfeat = ui.originalElement[0].subfeature; + var parent = subfeat.parent(); + var subfeatures = parent.get('subfeatures'); + var subfeatid; + + for (var i in subfeatures) { + if (subfeatures[i].id() == subfeat.id()) { + subfeatid = i; + } + }; + track.resizeExon(parent, subfeatid, leftDeltaBases, rightDeltaBases); + //subfeat[1] = subfeat[1] + leftDeltaBases; + //subfeat[2] = subfeat[2] + leftDeltaBases; + //console.log(track.store.features); + //console.log(parent); + //var new_feature = track.newTranscript(parent); + ////var new_feature = track.create_annotation(parent); + //track.store.replace(new_feature); + //console.log(track.store.features); + //track.changed(); + } + }); + } + } + event.stopPropagation(); + }, + + /** + * feature click no-op (to override FeatureTrack.onFeatureClick, which conflicts with mouse-down selection + */ + onFeatureClick: function(event) { + event = event || window.event; + var elem = (event.currentTarget || event.srcElement); + var featdiv = this.getLowestFeatureDiv( elem ); + if (featdiv && (featdiv != null)) { + } + }, + + makeTrackDroppable: function() { + var target_track = this; + var target_trackdiv = target_track.div; + + $(target_trackdiv).droppable( { + accept: ".selected-feature", + drop: function(event, ui) { + var dropped_feats = target_track.browser.featSelectionManager.getSelection(); + for (var i in dropped_feats) { + target_track.addTranscript(dropped_feats[i].feature); + } + } + }); + }, + + addTranscript: function (transcript) { + var new_transcript = this.newTranscript(transcript); + this.store.insert(new_transcript); + this.changed(); + }, + + resizeExon: function (transcript, index, leftDelta, rightDelta) { + var new_transcript = this.newTranscript(transcript); + var subfeatures = new_transcript.get('subfeatures'); + var exon = subfeatures[index]; + + var fmin = exon.get('start'); + var fmax = exon.get('end'); + fmin = fmin + leftDelta; + fmax = fmax + rightDelta; + exon.set('start', fmin); + exon.set('end', fmax); + + if (new_transcript.get('start') > fmin){ + new_transcript.set('start', fmin); + } + if (new_transcript.get('end') < fmax) { + new_transcript.set('end', fmax); + } + + this.store.deleteFeatureById(transcript.id()); + this.store.insert(new_transcript); + this.changed(); + + var featdiv = this.getFeatDiv(exon); + $(featdiv).trigger('mousedown'); + }, + + newTranscript: function(from) { + var feature = new SimpleFeature({ + data: { + name: from.get('name'), + ref: from.get('seq_id'), + start: from.get('start'), + end: from.get('end'), + strand: from.get('strand') + } + }); + + var from_subfeatures = from.get('subfeatures'); + + var subfeatures = new Array(); + for (var i = 0; i < from_subfeatures.length; ++i) { + var from_subfeature = from_subfeatures[i]; + var subfeature = new SimpleFeature({ + data: { + start: from_subfeature.get('start'), + end: from_subfeature.get('end'), + strand: from_subfeature.get('strand'), + type: from_subfeature.get('type') + }, + parent: feature + }); + subfeatures.push(subfeature); + } + feature.set('subfeatures', subfeatures); + //console.log(feature); + return feature; + }, + + /* feature_records ==> { feature: the_feature, track: track_feature_is_from } */ + addToAnnotation: function(annot, feature_records) { + var new_transcript = this.newTranscript(annot); + var from_feature = feature_records[0].feature; + var feature = new SimpleFeature({ + data: { + start: from_feature.get('start'), + end: from_feature.get('end'), + strand: from_feature.get('strand'), + type: from_feature.get('type') + }, + parent: new_transcript + }); + var subfeatures = new_transcript.get('subfeatures'); + subfeatures.push(feature); + new_transcript.set('subfeatures', subfeatures); + this.store.deleteFeatureById(annot.id()); + this.store.insert(new_transcript); + this.changed(); + }, + + duplicateSelectedFeatures: function() { + var selected = this.selectionManager.getSelection(); + var selfeats = this.selectionManager.getSelectedFeatures(); + this.selectionManager.clearSelection(); + this.duplicateAnnotations(selfeats); + }, + + duplicateAnnotations: function(feats) { + var track = this; + var featuresToAdd = new Array(); + var subfeaturesToAdd = new Array(); + var parentFeature; + for( var i in feats ) { + var feat = feats[i]; + var is_subfeature = !! feat.parent() ; // !! is shorthand for returning true if value is defined and non-null + if (is_subfeature) { + subfeaturesToAdd.push(feat); + } + else { + featuresToAdd.push( JSONUtils.createApolloFeature( feat, "transcript") ); + } + } + if (subfeaturesToAdd.length > 0) { + var feature = new SimpleFeature(); + var subfeatures = new Array(); + feature.set( 'subfeatures', subfeatures ); + var fmin = undefined; + var fmax = undefined; + var strand = undefined; + for (var i = 0; i < subfeaturesToAdd.length; ++i) { + var subfeature = subfeaturesToAdd[i]; + if (fmin === undefined || subfeature.get('start') < fmin) { + fmin = subfeature.get('start'); + } + if (fmax === undefined || subfeature.get('end') > fmax) { + fmax = subfeature.get('end'); + } + if (strand === undefined) { + strand = subfeature.get('strand'); + } + subfeatures.push(subfeature); + } + feature.set('start', fmin ); + feature.set('end', fmax ); + feature.set('strand', strand ); + featuresToAdd.push( JSONUtils.createApolloFeature( feature, "transcript") ); + } + var postData = '{ "track": "' + track.getUniqueTrackName() + '", "features": ' + JSON.stringify(featuresToAdd) + ', "operation": "add_transcript" }'; + track.executeUpdateOperation(postData); + }, + + /** + * If there are multiple EditTracks, each has a separate FeatureSelectionManager + * (contrasted with DraggableFeatureTracks, which all share the same selection and selection manager + */ + deleteSelectedFeatures: function() { + var selected = this.selectionManager.getSelection(); + this.selectionManager.clearSelection(); + this.deleteAnnotations(selected); + }, + + deleteAnnotations: function(records) { + var track = this; + var features = '"features": ['; + var uniqueNames = []; + for (var i in records) { + var record = records[i]; + var selfeat = record.feature; + var seltrack = record.track; + var uniqueName = selfeat.id(); + // just checking to ensure that all features in selection are from this track -- + // if not, then don't try and delete them + if (seltrack === track) { + var trackdiv = track.div; + var trackName = track.getUniqueTrackName(); + + if (i > 0) { + features += ','; + } + features += ' { "uniquename": "' + uniqueName + '" } '; + uniqueNames.push(uniqueName); + } + } + features += ']'; + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "delete_feature" }'; + track.executeUpdateOperation(postData); + }, + + mergeSelectedFeatures: function() { + var selected = this.selectionManager.getSelection(); + this.selectionManager.clearSelection(); + this.mergeAnnotations(selected); + }, + + mergeAnnotations: function(selection) { + var track = this; + var annots = []; + for (var i=0; i rightAnnot[track.fields["end"]]) { + rightAnnot = annot; + } + } + } + */ + + var features; + var operation; + // merge exons + if (leftAnnot.parent() && rightAnnot.parent() && leftAnnot.parent() == rightAnnot.parent()) { + features = '"features": [ { "uniquename": "' + leftAnnot.id() + '" }, { "uniquename": "' + rightAnnot.id() + '" } ]'; + operation = "merge_exons"; + } + // merge transcripts + else { + var leftTranscriptId = leftAnnot.parent() ? leftAnnot.parent().id() : leftAnnot.id(); + var rightTranscriptId = rightAnnot.parent() ? rightAnnot.parent().id() : rightAnnot.id(); + features = '"features": [ { "uniquename": "' + leftTranscriptId + '" }, { "uniquename": "' + rightTranscriptId + '" } ]'; + operation = "merge_transcripts"; + } + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + splitSelectedFeatures: function(event) { + // var selected = this.selectionManager.getSelection(); + var selected = this.selectionManager.getSelectedFeatures(); + this.selectionManager.clearSelection(); + this.splitAnnotations(selected, event); + }, + + splitAnnotations: function(annots, event) { + // can only split on max two elements + if( annots.length > 2 ) { + return; + } + var track = this; + var sortedAnnots = track.sortAnnotationsByLocation(annots); + var leftAnnot = sortedAnnots[0]; + var rightAnnot = sortedAnnots[sortedAnnots.length - 1]; + var trackName = track.getUniqueTrackName(); + + /* + for (var i in annots) { + var annot = annots[i]; + // just checking to ensure that all features in selection are from this track -- + // if not, then don't try and delete them + if (annot.track === track) { + var trackName = track.getUniqueTrackName(); + if (leftAnnot == null || annot[track.fields["start"]] < leftAnnot[track.fields["start"]]) { + leftAnnot = annot; + } + if (rightAnnot == null || annot[track.fields["end"]] > rightAnnot[track.fields["end"]]) { + rightAnnot = annot; + } + } + } + */ + var features; + var operation; + // split exon + if (leftAnnot == rightAnnot) { + var coordinate = this.getGenomeCoord(event); + features = '"features": [ { "uniquename": "' + leftAnnot.id() + '", "location": { "fmax": ' + coordinate + ', "fmin": ' + (coordinate + 1) + ' } } ]'; + operation = "split_exon"; + } + // split transcript + else if (leftAnnot.parent() == rightAnnot.parent()) { + features = '"features": [ { "uniquename": "' + leftAnnot.id() + '" }, { "uniquename": "' + rightAnnot.id() + '" } ]'; + operation = "split_transcript"; + } + else { + return; + } + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + makeIntron: function(event) { + var selected = this.selectionManager.getSelection(); + this.selectionManager.clearSelection(); + this.makeIntronInExon(selected, event); + }, + + makeIntronInExon: function(records, event) { + if (records.length > 1) { + return; + } + var track = this; + var annot = records[0].feature; + var coordinate = this.getGenomeCoord(event); + var features = '"features": [ { "uniquename": "' + annot.id() + '", "location": { "fmin": ' + coordinate + ' } } ]'; + var operation = "make_intron"; + var trackName = track.getUniqueTrackName(); + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + setTranslationStart: function(event) { + // var selected = this.selectionManager.getSelection(); + var selfeats = this.selectionManager.getSelectedFeatures(); + this.selectionManager.clearSelection(); + this.setTranslationStartInCDS(selfeats, event); + }, + + setTranslationStartInCDS: function(annots, event) { + if (annots.length > 1) { + return; + } + var track = this; + var annot = annots[0]; + // var coordinate = this.gview.getGenomeCoord(event); +// var coordinate = Math.floor(this.gview.absXtoBp(event.pageX)); + var coordinate = this.getGenomeCoord(event); + console.log("called setTranslationStartInCDS to: " + coordinate); + + var uid = annot.parent() ? annot.parent().id() : annot.id(); + var features = '"features": [ { "uniquename": "' + uid + '", "location": { "fmin": ' + coordinate + ' } } ]'; + var operation = "set_translation_start"; + var trackName = track.getUniqueTrackName(); + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + flipStrand: function() { + var selected = this.selectionManager.getSelection(); + this.flipStrandForSelectedFeatures(selected); + }, + + flipStrandForSelectedFeatures: function(records) { + var track = this; + var uniqueNames = new Object(); + for (var i in records) { + var record = records[i]; + var selfeat = record.feature; + var seltrack = record.track; + var topfeat = EditTrack.getTopLevelAnnotation(selfeat); + var uniqueName = topfeat.id(); + // just checking to ensure that all features in selection are from this track + if (seltrack === track) { + uniqueNames[uniqueName] = 1; + } + } + var features = '"features": ['; + var i = 0; + for (var uniqueName in uniqueNames) { + var trackdiv = track.div; + var trackName = track.getUniqueTrackName(); + + if (i > 0) { + features += ','; + } + features += ' { "uniquename": "' + uniqueName + '" } '; + ++i; + } + features += ']'; + var operation = "flip_strand"; + var trackName = track.getUniqueTrackName(); + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + setLongestORF: function() { + var selected = this.selectionManager.getSelection(); + this.selectionManager.clearSelection(); + this.setLongestORFForSelectedFeatures(selected); + }, + + setLongestORFForSelectedFeatures: function(selection) { + var track = this; + var features = '"features": ['; + for (var i in selection) { + var annot = EditTrack.getTopLevelAnnotation(selection[i].feature); + var atrack = selection[i].track; + var uniqueName = annot.id(); + // just checking to ensure that all features in selection are from this track + if (atrack === track) { + var trackdiv = track.div; + var trackName = track.getUniqueTrackName(); + + if (i > 0) { + features += ','; + } + features += ' { "uniquename": "' + uniqueName + '" } '; + } + } + features += ']'; + var operation = "set_longest_orf"; + var trackName = track.getUniqueTrackName(); + var postData = '{ "track": "' + trackName + '", ' + features + ', "operation": "' + operation + '" }'; + track.executeUpdateOperation(postData); + }, + + getSequence: function() { + var selected = this.selectionManager.getSelection(); + this.getSequenceForSelectedFeatures(selected); + }, + + getSequenceForSelectedFeatures: function(records) { + }, + + searchSequence: function() { + var track = this; + var starts = new Object(); + var browser = track.gview.browser; + for (i in browser.allRefs) { + var refSeq = browser.allRefs[i]; + starts[refSeq.name] = refSeq.start; + } + var search = new SequenceSearch(context_path); + search.setRedirectCallback(function(id, fmin, fmax) { + var loc = id + ":" + fmin + "-" + fmax; + if (id == track.refSeq.name) { + track.gview.browser.navigateTo(loc); + track.popupDialog.hide(); + } + else { + var url = window.location.toString().replace(/loc=.+/, "loc=" + loc); + window.location.replace(url); + } + }); + search.setErrorCallback(function(response) { + track.handleError(response); + }); + var content = search.searchSequence(track.getUniqueTrackName(), track.refSeq.name, starts); + if (content) { + this.openDialog("Search sequence", content); + } + }, + + zoomToBaseLevel: function(event) { + var coordinate = this.getGenomeCoord(event); + this.gview.zoomToBaseLevel(event, coordinate); + }, + + zoomBackOut: function(event) { + this.gview.zoomBackOut(event); + }, + + handleError: function(response) { + console.log("ERROR: "); + console.log(response); // in Firebug, allows retrieval of stack trace, jump to code, etc. + console.log(response.stack); + var error = eval('(' + response.responseText + ')'); + // var error = response.error ? response : eval('(' + response.responseText + ')'); + if (error && error.error) { + alert(error.error); + return false; + } + }, + + handleConfirm: function(response) { + return confirm(response); + }, + + getUniqueTrackName: function() { + return this.name + "-" + this.refSeq.name; + }, + + openDialog: function(title, data) { + this.popupDialog.set("title", title); + this.popupDialog.set("content", data); + this.popupDialog.show(); + this.popupDialog.placeAt("GenomeBrowser", "first"); + }, + + updateMenu: function() { + this.updateSetTranslationStartMenuItem(); + this.updateMergeMenuItem(); + this.updateSplitMenuItem(); + this.updateMakeIntronMenuItem(); + this.updateFlipStrandMenuItem(); + this.updateEditCommentsMenuItem(); + this.updateEditDbxrefsMenuItem(); + this.updateUndoMenuItem(); + this.updateRedoMenuItem(); + this.updateZoomToBaseLevelMenuItem(); + this.updateDuplicateMenuItem(); + }, + + updateSetTranslationStartMenuItem: function() { + var menuItem = this.getMenuItem("set_translation_start"); + var selected = this.selectionManager.getSelection(); + if (selected.length > 1) { + menuItem.set("disabled", true); + return; + } + menuItem.set("disabled", false); + var selectedFeat = selected[0].feature; + if (selectedFeat.parent()) { + selectedFeat = selectedFeat.parent(); + } + if (selectedFeat.get('manuallySetTranslationStart')) { + menuItem.set("label", "Unset translation start"); + } + else { + menuItem.set("label", "Set translation start"); + } + }, + + updateMergeMenuItem: function() { + var menuItem = this.getMenuItem("merge"); + var selected = this.selectionManager.getSelection(); + if (selected.length < 2) { + menuItem.set("disabled", true); + // menuItem.domNode.style.display = "none"; // direct method for hiding menuitem + // $(menuItem.domNode).hide(); // probably better method for hiding menuitem + return; + } + else { + // menuItem.domNode.style.display = ""; // direct method for unhiding menuitem + // $(menuItem.domNode).show(); // probably better method for unhiding menuitem + } + var strand = selected[0].feature.get('strand'); + for (var i = 1; i < selected.length; ++i) { + if (selected[i].feature.get('strand') != strand) { + menuItem.set("disabled", true); + return; + } + } + menuItem.set("disabled", false); + }, + + updateSplitMenuItem: function() { + var menuItem = this.getMenuItem("split"); + var selected = this.selectionManager.getSelection(); + if (selected.length > 2) { + menuItem.set("disabled", true); + return; + } + var parent = selected[0].feature.parent(); + for (var i = 1; i < selected.length; ++i) { + if (selected[i].feature.parent() != parent) { + menuItem.set("disabled", true); + return; + } + } + menuItem.set("disabled", false); + }, + + updateMakeIntronMenuItem: function() { + var menuItem = this.getMenuItem("make_intron"); + var selected = this.selectionManager.getSelection(); + if( selected.length > 1) { + menuItem.set("disabled", true); + return; + } + menuItem.set("disabled", false); + }, + + updateFlipStrandMenuItem: function() { + var menuItem = this.getMenuItem("flip_strand"); + }, + + updateEditCommentsMenuItem: function() { + var menuItem = this.getMenuItem("edit_comments"); + var selected = this.selectionManager.getSelection(); + var parent = EditTrack.getTopLevelAnnotation(selected[0].feature); + for (var i = 1; i < selected.length; ++i) { + if (EditTrack.getTopLevelAnnotation(selected[i].feature) != parent) { + menuItem.set("disabled", true); + return; + } + } + menuItem.set("disabled", false); + }, + + updateEditDbxrefsMenuItem: function() { + var menuItem = this.getMenuItem("edit_dbxrefs"); + var selected = this.selectionManager.getSelection(); + var parent = EditTrack.getTopLevelAnnotation(selected[0].feature); + for (var i = 1; i < selected.length; ++i) { + if (EditTrack.getTopLevelAnnotation(selected[i].feature) != parent) { + menuItem.set("disabled", true); + return; + } + } + menuItem.set("disabled", false); + }, + + updateUndoMenuItem: function() { + var menuItem = this.getMenuItem("undo"); + var selected = this.selectionManager.getSelection(); + if (selected.length > 1) { + menuItem.set("disabled", true); + return; + } + menuItem.set("disabled", false); + }, + + updateRedoMenuItem: function() { + var menuItem = this.getMenuItem("redo"); + var selected = this.selectionManager.getSelection(); + if (selected.length > 1) { + menuItem.set("disabled", true); + return; + } + menuItem.set("disabled", false); + }, + + + updateHistoryMenuItem: function() { + var menuItem = this.getMenuItem("history"); + var selected = this.selectionManager.getSelection(); + if (selected.length > 1) { + menuItem.set("disabled", true); + return; + } + menuItem.set("disabled", false); + }, + + updateZoomToBaseLevelMenuItem: function() { + var menuItem = this.getMenuItem("zoom_to_base_level"); + if( !this.gview.isZoomedToBase() ) { + menuItem.set("label", "Zoom to base level"); + } + else { + menuItem.set("label", "Zoom back out"); + } + }, + + updateDuplicateMenuItem: function() { + var menuItem = this.getMenuItem("duplicate"); + var selected = this.selectionManager.getSelection(); + var parent = EditTrack.getTopLevelAnnotation(selected[0].feature); + for (var i = 1; i < selected.length; ++i) { + if (EditTrack.getTopLevelAnnotation(selected[i].feature) != parent) { + menuItem.set("disabled", true); + return; + } + } + menuItem.set("disabled", false); + }, + + sortAnnotationsByLocation: function(annots) { + var track = this; + return annots.sort(function(annot1, annot2) { + var start1 = annot1.get("start"); + var end1 = annot1.get("end"); + var start2 = annot2.get("start"); + var end2 = annot2.get('end'); + + if (start1 != start2) { return start1 - start2; } + else if (end1 != end2) { return end1 - end2; } + else { return 0; } + }); + }, + + /** + * handles adding overlay of sequence residues to "row" of selected feature + * (also handled in similar manner in fillBlock()); + * WARNING: + * this _requires_ browser support for pointer-events CSS property, + * (currently supported by Firefox 3.6+, Chrome 4.0+, Safari 4.0+) + * (Exploring possible workarounds for IE, for example see: + * http://www.vinylfox.com/forwarding-mouse-events-through-layers/ + * http://stackoverflow.com/questions/3680429/click-through-a-div-to-underlying-elements + * [ see section on CSS conditional statement workaround for IE ] + * ) + * and must set "pointer-events: none" in CSS rule for div.annot-sequence + * otherwise, since sequence overlay is rendered on top of selected features + * (and is a sibling of feature divs), events intended for feature divs will + * get caught by overlay and not make it to the feature divs + */ + selectionAdded: function( rec, smanager) { + var feat = rec.feature; + this.inherited( arguments ); + + var track = this; + + // switched to only have most recent selected annot have residues overlay if zoomed to base level, + // rather than all selected annots + // therefore want to revove all prior residues overlay divs + if (rec.track === track) { + // remove sequence text nodes + $("div.annot-sequence", track.div).remove(); + } + + // want to get child of block, since want position relative to block + // so get top-level feature div (assumes top level feature is always rendered...) + var topfeat = EditTrack.getTopLevelAnnotation(feat); + var featdiv = track.getFeatDiv(topfeat); + if (featdiv) { + var strand = topfeat.get('strand'); + var selectionYPosition = $(featdiv).position().top; + var scale = track.gview.bpToPx(1); + var charSize = track.browser.getSequenceCharacterSize(); + if (scale === charSize.width && track.useResiduesOverlay) { + var seqTrack = this.getSequenceTrack(); + for (var bindex = this.firstAttached; bindex <= this.lastAttached; bindex++) { + var block = this.blocks[bindex]; + // seqTrack.getRange(block.startBase, block.endBase, + // seqTrack.sequenceStore.getRange(this.refSeq, block.startBase, block.endBase, + seqTrack.sequenceStore.getFeatures({ ref: this.refSeq.name, start: block.startBase, end: block.endBase }, + function(feat) { + var start = feat.get('start'); + var end = feat.get('end'); + var seq = feat.get('seq'); + + // var ypos = $(topfeat).position().top; + // +2 hardwired adjustment to center (should be calc'd based on feature div dims? + var ypos = selectionYPosition + 2; + // checking to see if residues for this "row" of the block are already present + // ( either from another selection in same row, or previous rendering + // of same selection [which often happens when scrolling] ) + // trying to avoid duplication both for efficiency and because re-rendering of text can + // be slighly off from previous rendering, leading to bold / blurry text when overlaid + + var $seqdivs = $("div.annot-sequence", block); + var sindex = $seqdivs.length; + var add_residues = true; + if ($seqdivs && sindex > 0) { + for (var i=0; i 0 ) { + // if selected annotations, then hide residues overlay + // (in case zoomed in to base pair resolution and the residues overlay is being displayed) + $(".annot-sequence", this.div).css('display', 'none'); + } + }, + + executeUpdateOperation: function(postData, loadCallback) { + } +}); + +EditTrack.getTopLevelAnnotation = function(annotation) { + while( annotation.parent() ) { + annotation = annotation.parent(); + } + return annotation; +}; + +return EditTrack; +}); + +/* + Copyright (c) 2010-2011 Berkeley Bioinformatics Open Projects (BBOP) + + This package and its accompanying libraries are free software; you can + redistribute it and/or modify it under the terms of the LGPL (either + version 2.1, or at your option, any later version) or the Artistic + License 2.0. Refer to LICENSE for the full license text. + +*/ diff --git a/www/JBrowse/View/Track/ExportMixin.js b/www/JBrowse/View/Track/ExportMixin.js new file mode 100644 index 00000000..ca164688 --- /dev/null +++ b/www/JBrowse/View/Track/ExportMixin.js @@ -0,0 +1,333 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'dojo/aspect', + 'dojo/on', + 'JBrowse/has', + 'dojo/window', + 'dojo/dom-construct', + 'JBrowse/Util', + 'dijit/form/TextBox', + 'dijit/form/Button', + 'dijit/form/RadioButton', + 'dijit/Dialog', + 'FileSaver/FileSaver' + ], + function( + declare, + array, + lang, + aspect, + on, + has, + dojoWindow, + dom, + Util, + dijitTextBox, + dijitButton, + dijitRadioButton, + dijitDialog, + saveAs + ) { + +/** + * Mixin for a track that can export its data. + * @lends JBrowse.View.Track.ExportMixin + */ +return declare( null, { + + _canSaveFiles: function() { + return has('save-generated-files') && ! this.config.noExportFiles; + }, + + _canExport: function() { + if( this.config.noExport ) + return false; + + var highlightedRegion = this.browser.getHighlight(); + var visibleRegion = this.browser.view.visibleRegion(); + var wholeRefSeqRegion = { ref: this.refSeq.name, start: this.refSeq.start, end: this.refSeq.end }; + var canExportVisibleRegion = this._canExportRegion( visibleRegion ); + var canExportWholeRef = this._canExportRegion( wholeRefSeqRegion ); + return highlightedRegion && this._canExportRegion( highlightedRegion ) + || this._canExportRegion( visibleRegion ) + || this._canExportRegion( wholeRefSeqRegion ); + }, + + _possibleExportRegions: function() { + var regions = [ + // the visible region + (function() { + var r = dojo.clone( this.browser.view.visibleRegion() ); + r.description = 'Visible region'; + r.name = 'visible'; + return r; + }.call(this)), + // whole reference sequence + { ref: this.refSeq.name, start: this.refSeq.start, end: this.refSeq.end, description: 'Whole reference sequence', name: 'wholeref' } + ]; + + var highlightedRegion = this.browser.getHighlight(); + if( highlightedRegion ) { + regions.unshift( lang.mixin( lang.clone( highlightedRegion ), { description: "Highlighted region", name: "highlight" } ) ); + } + + return regions; + }, + + _exportDialogContent: function() { + // note that the `this` for this content function is not the track, it's the menu-rendering context + var possibleRegions = this.track._possibleExportRegions(); + + // for each region, calculate its length and determine whether we can export it + array.forEach( possibleRegions, function( region ) { + region.length = Math.round( region.end - region.start + 1 ); + region.canExport = this._canExportRegion( region ); + },this.track); + + var setFilenameValue = dojo.hitch(this.track, function() { + var region = this._readRadio(form.elements.region); + var format = nameToExtension[this._readRadio(form.elements.format)]; + form.elements.filename.value = ((this.key || this.label) + "-" + region).replace(/[^ .a-zA-Z0-9_-]/g,'-') + "." + format; + }); + + var form = dom.create('form', { onSubmit: function() { return false; } }); + var regionFieldset = dom.create('fieldset', {className: "region"}, form ); + dom.create('legend', {innerHTML: "Region to save"}, regionFieldset); + + var checked = 0; + array.forEach( possibleRegions, function(r) { + var locstring = Util.assembleLocString(r); + var regionButton = new dijitRadioButton( + { name: "region", id: "region_"+r.name, + value: locstring, checked: r.canExport && !(checked++) ? "checked" : "" + }); + regionFieldset.appendChild(regionButton.domNode); + var regionButtonLabel = dom.create("label", {"for": regionButton.id, innerHTML: r.description+' - ' + + locstring+' ('+Util.humanReadableNumber(r.length)+(r.canExport ? 'b' : 'b, too large')+')'}, regionFieldset); + if(!r.canExport) { + regionButton.domNode.disabled = "disabled"; + regionButtonLabel.className = "ghosted"; + } + + on(regionButton, "click", setFilenameValue); + + dom.create('br',{},regionFieldset); + }); + + + var formatFieldset = dom.create("fieldset", {className: "format"}, form); + dom.create("legend", {innerHTML: "Format"}, formatFieldset); + + checked = 0; + var nameToExtension = {}; + array.forEach( this.track._exportFormats(), function(fmt) { + if( ! fmt.name ) { + fmt = { name: fmt, label: fmt }; + } + if( ! fmt.fileExt) { + fmt.fileExt = fmt.name || fmt; + } + nameToExtension[fmt.name] = fmt.fileExt; + var formatButton = new dijitRadioButton({ name: "format", id: "format"+fmt.name, value: fmt.name, checked: checked++?"":"checked"}); + formatFieldset.appendChild(formatButton.domNode); + var formatButtonLabel = dom.create("label", {"for": formatButton.id, innerHTML: fmt.label}, formatFieldset); + + on(formatButton, "click", setFilenameValue); + dom.create( "br", {}, formatFieldset ); + },this); + + + var filenameFieldset = dom.create("fieldset", {className: "filename"}, form); + dom.create("legend", {innerHTML: "Filename"}, filenameFieldset); + dom.create("input", {type: "text", name: "filename", style: {width: "100%"}}, filenameFieldset); + + setFilenameValue(); + + var actionBar = dom.create( 'div', { + className: 'dijitDialogPaneActionBar' + }); + + // note that the `this` for this content function is not the track, it's the menu-rendering context + var dialog = this.dialog; + + new dijitButton({ iconClass: 'dijitIconDelete', onClick: dojo.hitch(dialog,'hide'), label: 'Cancel' }) + .placeAt( actionBar ); + var viewButton = new dijitButton({ iconClass: 'dijitIconTask', + label: 'View', + disabled: ! array.some(possibleRegions,function(r) { return r.canExport; }), + onClick: lang.partial( this.track._exportViewButtonClicked, this.track, form, dialog ) + }) + .placeAt( actionBar ); + + // don't show a download button if we for some reason can't save files + if( this.track._canSaveFiles() ) { + + var dlButton = new dijitButton({ iconClass: 'dijitIconSave', + label: 'Save', + disabled: ! array.some(possibleRegions,function(r) { return r.canExport; }), + onClick: dojo.hitch( this.track, function() { + var format = this._readRadio( form.elements.format ); + var region = this._readRadio( form.elements.region ); + var filename = form.elements.filename.value.replace(/[^ .a-zA-Z0-9_-]/g,'-'); + dlButton.set('disabled',true); + dlButton.set('iconClass','jbrowseIconBusy'); + this.exportRegion( region, format, dojo.hitch( this, function( output ) { + dialog.hide(); + this._fileDownload({ format: format, data: output, filename: filename }); + })); + })}) + .placeAt( actionBar ); + } + + return [ form, actionBar ]; + }, + + // run when the 'View' button is clicked in the export dialog + _exportViewButtonClicked: function( track, form, dialog ) { + var viewButton = this; + viewButton.set('disabled',true); + viewButton.set('iconClass','jbrowseIconBusy'); + + var region = track._readRadio( form.elements.region ); + var format = track._readRadio( form.elements.format ); + var filename = form.elements.filename.value.replace(/[^ .a-zA-Z0-9_-]/g,'-'); + track.exportRegion( region, format, function(output) { + dialog.hide(); + var text = dom.create('textarea', { + rows: Math.round( dojoWindow.getBox().h / 12 * 0.5 ), + wrap: 'off', + cols: 80, + style: "maxWidth: 90em; overflow: scroll; overflow-y: scroll; overflow-x: scroll; overflow:-moz-scrollbars-vertical;", + readonly: true + }); + text.value = output; + var actionBar = dom.create( 'div', { + className: 'dijitDialogPaneActionBar' + }); + var exportView = new dijitDialog({ + className: 'export-view-dialog', + title: format + ' export - '+ region+' ('+Util.humanReadableNumber(output.length)+'bytes)', + content: [ text, actionBar ] + }); + new dijitButton({ iconClass: 'dijitIconDelete', + label: 'Close', onClick: dojo.hitch( exportView, 'hide' ) + }) + .placeAt(actionBar); + + // only show a button if the browser can save files + if( track._canSaveFiles() ) { + var saveDiv = dom.create( "div", { className: "save" }, actionBar ); + + var saveButton = new dijitButton( + { + iconClass: 'dijitIconSave', + label: 'Save', + onClick: function() { + var filename = fileNameText.get('value').replace(/[^ .a-zA-Z0-9_-]/g,'-'); + exportView.hide(); + track._fileDownload({ format: format, data: output, filename: filename }); + } + }).placeAt(saveDiv); + var fileNameText = new dijitTextBox({ + value: filename, + style: "width: 24em" + }).placeAt( saveDiv ); + } + + aspect.after( exportView, 'hide', function() { + // manually unhook and free the (possibly huge) text area + text.parentNode.removeChild( text ); + text = null; + setTimeout( function() { + exportView.destroyRecursive(); + }, 500 ); + }); + exportView.show(); + }); + }, + + _fileDownload: function( args ) { + saveAs(new Blob([args.data], {type: args.format ? 'application/x-'+args.format.toLowerCase() : 'text/plain'}), args.filename); + // We will need to check whether this breaks the WebApollo plugin. + }, + + // cross-platform function for (portably) reading the value of a radio control. sigh. *rolls eyes* + _readRadio: function( r ) { + if( r.length ) { + for( var i = 0; iAttributes' + }, + container ); + array.forEach( additionalTags.sort(), function(t) { + this.renderDetailField( container, t, f.get(t) ); + }, this ); + } + }, + + _renderUnderlyingReferenceSequence: function( track, f, featDiv, container ) { + + // render the sequence underlying this feature if possible + var field_container = dojo.create('div', { className: 'field_container feature_sequence' }, container ); + dojo.create( 'h2', { className: 'field feature_sequence', innerHTML: 'Region sequence', title: 'reference sequence underlying this '+(f.get('type') || 'feature') }, field_container ); + var valueContainerID = 'feature_sequence'+this._uniqID(); + var valueContainer = dojo.create( + 'div', { + id: valueContainerID, + innerHTML: '
Loading...
', + className: 'value feature_sequence' + }, field_container); + var maxSize = this.config.maxFeatureSizeForUnderlyingRefSeq; + if( maxSize < (f.get('end') - f.get('start')) ) { + valueContainer.innerHTML = 'Not displaying underlying reference sequence, feature is longer than maximum of '+Util.humanReadableNumber(maxSize)+'bp'; + } else { + track.browser.getStore('refseqs', dojo.hitch(this,function( refSeqStore ) { + valueContainer = dojo.byId(valueContainerID) || valueContainer; + if( refSeqStore ) { + refSeqStore.getFeatures( + { ref: this.refSeq.name, start: f.get('start'), end: f.get('end')}, + // feature callback + dojo.hitch( this, function( feature ) { + var seq = feature.get('seq'); + valueContainer = dojo.byId(valueContainerID) || valueContainer; + valueContainer.innerHTML = ''; + // the HTML is rewritten by the dojo dialog + // parser, but this callback may be called either + // before or after that happens. if the fetch by + // ID fails, we have come back before the parse. + var textArea = new FASTAView({ width: 62, htmlMaxRows: 10 }) + .renderHTML( + { ref: this.refSeq.name, + start: f.get('start'), + end: f.get('end'), + strand: f.get('strand'), + type: f.get('type') + }, + f.get('strand') == -1 ? Util.revcom(seq) : seq, + valueContainer + ); + }), + // end callback + function() {}, + // error callback + dojo.hitch( this, function() { + valueContainer = dojo.byId(valueContainerID) || valueContainer; + valueContainer.innerHTML = 'reference sequence not available'; + }) + ); + } else { + valueContainer.innerHTML = 'reference sequence not available'; + } + })); + } + }, + + _uniqID: function() { + this._idCounter = this._idCounter || 0; + return this._idCounter++; + }, + + _subfeaturesDetail: function( track, subfeatures, container ) { + var field_container = dojo.create('div', { className: 'field_container subfeatures' }, container ); + dojo.create( 'h2', { className: 'field subfeatures', innerHTML: 'Subfeatures' }, field_container ); + var subfeaturesContainer = dojo.create( 'div', { className: 'value subfeatures' }, field_container ); + array.forEach( subfeatures || [], function( subfeature ) { + this.defaultFeatureDetail( + track, + subfeature, + null, + dojo.create('div', { + className: 'detail feature-detail subfeature-detail feature-detail-'+track.name+' subfeature-detail-'+track.name, + innerHTML: '' + }, subfeaturesContainer ) + ); + },this); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/FixedImage.js b/www/JBrowse/View/Track/FixedImage.js new file mode 100644 index 00000000..69ebfbc4 --- /dev/null +++ b/www/JBrowse/View/Track/FixedImage.js @@ -0,0 +1,145 @@ +define( + [ + 'dojo/_base/declare', + 'JBrowse/View/Track/BlockBased' + ], + function( declare, BlockBased ) { + +return declare( BlockBased, + /** + * @lends JBrowse.View.Track.FixedImage.prototype + */ +{ + + /** + * A track that displays tiled images (PNGs, or other images) at fixed + * intervals along the reference sequence. + * @constructs + * @extends JBrowse.View.Track.BlockBased + */ + constructor: function( args ) { + this.trackPadding = args.trackPadding || 0; + }, + + handleImageError: function(ev) { + var img = ev.currentTarget || ev.srcElement; + img.style.display = "none"; + dojo.stopEvent(ev); + }, + + /** + * @private + */ + makeImageLoadHandler: function( img, blockIndex, blockWidth, composeCallback ) { + var handler = dojo.hitch( this, function() { + this.imageHeight = img.height; + img.style.height = img.height + "px"; + img.style.width = (100 * (img.baseWidth / blockWidth)) + "%"; + this.heightUpdate( img.height, blockIndex ); + if( composeCallback ) + composeCallback(); + return true; + }); + + if( ! dojo.isIE ) + return handler; + else + // in IE, have to delay calling it for a (arbitrary) 1/4 + // second because the image's height is not always + // available when the onload event fires. >:-{ + return function() { + window.setTimeout(handler,250); + }; + + }, + + fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + var finishCallback = args.finishCallback || function() {}; + + var blockWidth = rightBase - leftBase; + + this.store.getImages( + { scale: scale, start: leftBase, end: rightBase }, + dojo.hitch(this, function(images) { + dojo.forEach( images, function(im) { + im.className = 'image-track'; + if (!(im.parentNode && im.parentNode.parentNode)) { + im.style.position = "absolute"; + im.style.left = (100 * ((im.startBase - leftBase) / blockWidth)) + "%"; + switch (this.config.align) { + case "top": + im.style.top = "0px"; + break; + case "bottom": + default: + im.style.bottom = this.trackPadding + "px"; + break; + } + block.domNode.appendChild(im); + } + + // make an onload handler for when the image is fetched that + // will update the height and width of the track + var loadhandler = this.makeImageLoadHandler( im, blockIndex, blockWidth ); + if( im.complete ) + // just call the handler ourselves if the image is already loaded + loadhandler(); + else + // otherwise schedule it + im.onload = loadhandler; + + }, this); + finishCallback(); + }), + dojo.hitch( this, function( error ) { + if( error.status == 404 ) { + // do nothing + } else { + this.fillBlockError( blockIndex, block, error ); + } + finishCallback(); + }) + ); + }, + + startZoom: function(destScale, destStart, destEnd) { + if (this.empty) return; + }, + + endZoom: function(destScale, destBlockBases) { + this.clear(); + }, + + clear: function() { + this.inherited( arguments ); + }, + + transfer: function(sourceBlock, destBlock, scale, + containerStart, containerEnd) { + if (!(sourceBlock && destBlock)) return; + + var children = sourceBlock.domNode.childNodes; + var destLeft = destBlock.startBase; + var destRight = destBlock.endBase; + var im; + for (var i = 0; i < children.length; i++) { + im = children[i]; + if ("startBase" in im) { + //if sourceBlock contains an image that overlaps destBlock, + if ((im.startBase < destRight) + && ((im.startBase + im.baseWidth) > destLeft)) { + //move image from sourceBlock to destBlock + im.style.left = (100 * ((im.startBase - destLeft) / (destRight - destLeft))) + "%"; + destBlock.domNode.appendChild(im); + } + } + } + } +}); + +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/FixedImage/Wiggle.js b/www/JBrowse/View/Track/FixedImage/Wiggle.js new file mode 100644 index 00000000..e64ef6c4 --- /dev/null +++ b/www/JBrowse/View/Track/FixedImage/Wiggle.js @@ -0,0 +1,52 @@ +define([ + 'dojo/_base/declare', + 'JBrowse/View/Track/FixedImage', + 'JBrowse/View/Track/YScaleMixin' + ], + function( declare, FixedImage, YScaleMixin ) { + +var Wiggle = declare( [ FixedImage, YScaleMixin ], + /** + * @lends JBrowse.View.Track.FixedImage.Wiggle.prototype + */ +{ + + /** + * Tiled-image track subclass that displays images calculated from + * wiggle data. Has a scale bar in addition to the images. + * @class + * @constructor + */ + constructor: function() { + }, + + updateStaticElements: function( coords ) { + this.inherited( arguments ); + this.updateYScaleFromViewDimensions( coords ); + }, + + makeImageLoadHandler: function( img, blockIndex, blockWidth, composeCallback ) { + return this.inherited( arguments, + [ img, + blockIndex, + blockWidth, + dojo.hitch(this, function() { + this.makeWiggleYScale(); + if( composeCallback ) + composeCallback(); + }) + ] + ); + }, + + makeWiggleYScale: function() { + var thisB = this; + this.store.getGlobalStats( function( stats ) { + if( ! thisB.yscale ) + thisB.makeYScale({ min: stats.scoreMin, max: stats.scoreMax }); + }); + } +}); + +return Wiggle; +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/GridLines.js b/www/JBrowse/View/Track/GridLines.js new file mode 100644 index 00000000..bc50fd3a --- /dev/null +++ b/www/JBrowse/View/Track/GridLines.js @@ -0,0 +1,68 @@ +define([ + 'dojo/_base/declare', + 'dojo/dom-construct', + 'JBrowse/View/Track/BlockBased' + ], + function( declare, dom, BlockBased ) { +return dojo.declare( BlockBased, + /** + * @lends JBrowse.View.Track.GridLines.prototype + */ +{ + + /** + * This track draws vertical gridlines, which are divs with height + * 100%, absolutely positioned at the very top of all the tracks. + * @constructs + * @extends JBrowse.View.Track.BlockBased + */ + constructor: function( args ) { + this.loaded = true; + this.name = 'gridlines'; + }, + + // this track has no track label or track menu, stub them out + makeTrackLabel: function() {}, + makeTrackMenu: function() {}, + + fillBlock: function( args ) { + this.renderGridlines( args.block, args.leftBase, args.rightBase ); + + var highlight = this.browser.getHighlight(); + if( highlight && highlight.ref == this.refSeq.name ) + this.renderRegionHighlight( args, highlight ); + + args.finishCallback(); + this.heightUpdate(100, args.blockIndex); + }, + + renderGridlines: function(block,leftBase,rightBase) { + + var base_span = rightBase-leftBase; + var minor_count = + !( base_span % 20 ) ? 20 : + !( base_span % 10 ) ? 10 : + !( base_span % 5 ) ? 5 : + !( base_span % 2 ) ? 2 : + 0; + var major_count = base_span == 20 ? 2 : base_span > 0 ? 1 : 0; + + var new_gridline = function( glclass, position ) { + var gridline = document.createElement("div"); + gridline.style.cssText = "left: " + position + "%; width: 0px"; + gridline.className = "gridline "+glclass; + return gridline; + }; + + for( var i=0; i= bpPerBin) { + //console.log("bpPerBin: " + bpPerBin + ", histStats bases: " + this.histStats[i].bases + ", mean/max: " + (this.histStats[i].mean / this.histStats[i].max)); + logScale = ((stats[i].mean / stats[i].max) < .01); + pxPerCount = 100 / (logScale ? + Math.log(stats[i].max) : + stats[i].max); + statEntry = stats[i]; + break; + } + } + + return { + bpPerBin: bpPerBin, + pxPerCount: pxPerCount, + logScale: logScale, + stats: statEntry + }; + }, + + fillHist: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var stripeWidth = args.stripeWidth; + + var track = this; + this.store.getRegionFeatureDensities( + { ref: this.refSeq.name, + start: args.leftBase, + end: args.rightBase, + bpPerBin: Math.abs( rightBase - leftBase )/this.numBins + }, + function( histData ) { + var hist = histData.bins; + var maxBin = 0; + for (var bin = 0; bin < track.numBins; bin++) { + if (typeof hist[bin] == 'number' && isFinite(hist[bin])) { + maxBin = Math.max(maxBin, hist[bin]); + } + } + + var dims = track._histDimensions( Math.abs( rightBase - leftBase ), histData.stats ); + + var binDiv; + for (bin = 0; bin < track.numBins; bin++) { + if (!(typeof hist[bin] == 'number' && isFinite(hist[bin]))) + continue; + binDiv = document.createElement("div"); + binDiv.className = "hist feature-hist "+track.config.style.className + "-hist"; + binDiv.style.cssText = + "left: " + ((bin / track.numBins) * 100) + "%; " + + "height: " + + ( dims.pxPerCount * ( dims.logScale ? Math.log(hist[bin]) : hist[bin]) ) + + "px;" + + "bottom: " + track.trackPadding + "px;" + + "width: " + ((100 / track.numBins) - (100 / stripeWidth)) + "%;" + + (track.config.style.histCss ? + track.config.style.histCss : ""); + binDiv.setAttribute('value',hist[bin]); + if (Util.is_ie6) binDiv.appendChild(document.createComment()); + block.domNode.appendChild(binDiv); + } + + track.heightUpdate( dims.pxPerCount * ( dims.logScale ? Math.log(maxBin) : maxBin ), + blockIndex ); + track.makeHistogramYScale( Math.abs(rightBase-leftBase), histData ); + }); + + args.finishCallback(); + }, + + endZoom: function(destScale, destBlockBases) { + this.clear(); + }, + + updateStaticElements: function( coords ) { + this.inherited( arguments ); + this.updateYScaleFromViewDimensions( coords ); + this.updateFeatureLabelPositions( coords ); + this.updateFeatureArrowPositions( coords ); + }, + + updateFeatureArrowPositions: function( coords ) { + if( ! 'x' in coords ) + return; + + var viewmin = this.browser.view.minVisible(); + var viewmax = this.browser.view.maxVisible(); + + var blocks = this.blocks; + + for( var blockIndex = 0; blockIndex < blocks.length; blockIndex++ ) { + var block = blocks[blockIndex]; + if( ! block ) + continue; + var childNodes = block.domNode.childNodes; + for( var i = 0; i viewmin ) { + var minusArrowClass = 'minus-'+this.config.style.arrowheadClass; + featDivChildren = featDiv.childNodes; + for( var j = 0; j= 0 ) { + arrowhead.style.left = + ( fmin < viewmin ? block.bpToX( viewmin ) - block.bpToX( fmin ) + : -this.minusArrowWidth + ) + 'px'; + }; + } + } + // plus strand + else if( strand > 0 && fmin < viewmax ) { + var plusArrowClass = 'plus-'+this.config.style.arrowheadClass; + featDivChildren = featDiv.childNodes; + for( var j = 0; j= 0 ) { + arrowhead.style.right = + ( fmax > viewmax ? block.bpToX( fmax ) - block.bpToX( viewmax ) + : -this.plusArrowWidth + ) + 'px'; + } + } + } + } + } + }, + + updateFeatureLabelPositions: function( coords ) { + if( ! 'x' in coords ) + return; + + array.forEach( this.blocks, function( block, blockIndex ) { + + + // calculate the view left coord relative to the + // block left coord in units of pct of the block + // width + if( ! block || ! this.label ) + return; + var viewLeft = 100 * ( (this.label.offsetLeft+this.label.offsetWidth) - block.domNode.offsetLeft ) / block.domNode.offsetWidth + 2; + + // if the view start is unknown, or is to the + // left of this block, we don't have to worry + // about adjusting the feature labels + if( ! viewLeft ) + return; + + var blockWidth = block.endBase - block.startBase; + + array.forEach( block.domNode.childNodes, function( featDiv ) { + if( ! featDiv.label ) return; + var labelDiv = featDiv.label; + var feature = featDiv.feature; + + // get the feature start and end in terms of block width pct + var minLeft = parseInt( feature.get('start') ); + minLeft = 100 * (minLeft - block.startBase) / blockWidth; + var maxLeft = parseInt( feature.get('end') ); + maxLeft = 100 * ( (maxLeft - block.startBase) / blockWidth + - labelDiv.offsetWidth / block.domNode.offsetWidth + ); + + // move our label div to the view start if the start is between the feature start and end + labelDiv.style.left = Math.max( minLeft, Math.min( viewLeft, maxLeft ) ) + '%'; + + },this); + },this); + }, + + fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + var containerStart = args.containerStart; + var containerEnd = args.containerEnd; + + var region = { ref: this.refSeq.name, start: leftBase, end: rightBase }; + + this.store.getGlobalStats( + dojo.hitch( this, function( stats ) { + + var density = stats.featureDensity; + var histScale = this.config.style.histScale || density * this.config.style._defaultHistScale; + var featureScale = this.config.style.featureScale || density / this.config.maxFeatureScreenDensity; // (feat/bp) / ( feat/px ) = px/bp ) + + // only update the label once for each block size + var blockBases = Math.abs( leftBase-rightBase ); + if( this._updatedLabelForBlockSize != blockBases ){ + if ( this.store.getRegionFeatureDensities && scale < histScale ) { + this.setLabel(this.key + ' per ' + Util.addCommas( Math.round( blockBases / this.numBins)) + ' bp'); + } else { + this.setLabel(this.key); + } + this._updatedLabelForBlockSize = blockBases; + } + + // console.log(this.name+" scale: %d, density: %d, histScale: %d, screenDensity: %d", scale, stats.featureDensity, this.config.style.histScale, stats.featureDensity / scale ); + + // if we our store offers density histograms, and we are zoomed out far enough, draw them + if( this.store.getRegionFeatureDensities && scale < histScale ) { + this.fillHist( args ); + } + // if we have no histograms, check the predicted density of + // features on the screen, and display a message if it's + // bigger than maxFeatureScreenDensity + else if( scale < featureScale ) { + this.fillTooManyFeaturesMessage( + blockIndex, + block, + scale + ); + args.finishCallback(); + } + else { + // if we have transitioned to viewing features, delete the + // y-scale used for the histograms + this._removeYScale(); + this.fillFeatures( dojo.mixin( {stats: stats}, args ) ); + } + }), + dojo.hitch( this, 'fillBlockError', blockIndex, block ) + ); + }, + + /** + * Creates a Y-axis scale for the feature histogram. Must be run after + * the histogram bars are drawn, because it sometimes must use the + * track height to calculate the max value if there are no explicit + * histogram stats. + * @param {Number} blockSizeBp the size of the blocks in base pairs. + * Necessary for calculating histogram stats. + */ + makeHistogramYScale: function( blockSizeBp, histData ) { + var dims = this._histDimensions( blockSizeBp, histData.stats ); + if( dims.logScale ) { + console.error("Log histogram scale axis labels not yet implemented."); + return; + } + var maxval = this.height/dims.pxPerCount; + maxval = dims.logScale ? log(maxval) : maxval; + + // if we have a scale, and it has the same characteristics + // (including pixel height), don't redraw it. + if( this.yscale && this.yscale_params + && this.yscale_params.maxval == maxval + && this.yscale_params.height == this.height + && this.yscale_params.blockbp == blockSizeBp + ) { + return; + } else { + this._removeYScale(); + this.makeYScale({ min: 0, max: maxval }); + this.yscale_params = { + height: this.height, + blockbp: blockSizeBp, + maxval: maxval + }; + } + }, + + /** + * Delete the Y-axis scale if present. + * @private + */ + _removeYScale: function() { + if( !this.yscale ) { + query( '.ruler', this.div ).orphan(); + return; + } + this.yscale.parentNode.removeChild( this.yscale ); + delete this.yscale_params; + delete this.yscale; + }, + + destroy: function() { + this._clearLayout(); + this.inherited(arguments); + }, + + cleanupBlock: function(block) { + if( block ) { + // discard the layout for this range + if ( this.layout ) + this.layout.discardRange( block.startBase, block.endBase ); + + if( block.featureNodes ) + for( var name in block.featureNodes ) { + var featDiv = block.featureNodes[name]; + array.forEach( + 'track,feature,callbackArgs,_labelScale,_descriptionScale'.split(','), + function(a) { Util.removeAttribute( featDiv, a ); } + ); + if( 'label' in featDiv ) { + array.forEach( + 'track,feature,callbackArgs'.split(','), + function(a) { Util.removeAttribute( featDiv.label, a ); } + ); + Util.removeAttribute( featDiv, 'label' ); + } + } + } + + this.inherited( arguments ); + }, + + /** + * Called when sourceBlock gets deleted. Any child features of + * sourceBlock that extend onto destBlock should get moved onto + * destBlock. + */ + transfer: function(sourceBlock, destBlock, scale, containerStart, containerEnd) { + + if (!(sourceBlock && destBlock)) return; + + var destLeft = destBlock.startBase; + var destRight = destBlock.endBase; + var blockWidth = destRight - destLeft; + var sourceSlot; + + var overlaps = (sourceBlock.startBase < destBlock.startBase) + ? sourceBlock.rightOverlaps + : sourceBlock.leftOverlaps; + overlaps = overlaps || []; + + for (var i = 0; i < overlaps.length; i++) { + //if the feature overlaps destBlock, + //move to destBlock & re-position + sourceSlot = sourceBlock.featureNodes[ overlaps[i] ]; + if ( sourceSlot && sourceSlot.label && sourceSlot.label.parentNode ) { + sourceSlot.label.parentNode.removeChild(sourceSlot.label); + } + if (sourceSlot && sourceSlot.feature) { + if ( sourceSlot.layoutEnd > destLeft + && sourceSlot.feature.get('start') < destRight ) { + + sourceSlot.parentNode.removeChild(sourceSlot); + + delete sourceBlock.featureNodes[ overlaps[i] ]; + + /* feature render, adding to block, centering refactored into addFeatureToBlock() */ + var featDiv = this.addFeatureToBlock( sourceSlot.feature, overlaps[i], + destBlock, scale, sourceSlot._labelScale, sourceSlot._descriptionScale, + containerStart, containerEnd ); + // if there are boolean coverage divs, modify feature accordingly. + if ( sourceSlot.booleanCovs ) { + this._maskTransfer( featDiv, sourceSlot, containerStart, containerEnd ); + } + } + } + } + }, + + /** + * Called by "tranfer" when sourceBlock gets deleted. Ensures that any child features of + * sourceBlock that extend onto destBlock will remain masked when moved onto + * destBlock. + */ + _maskTransfer: function( featDiv, sourceSlot, containerStart, containerEnd ) { + var subfeatures = []; + // remove subfeatures + while ( featDiv.firstChild ) { + subfeatures.push( featDiv.firstChild ); + featDiv.removeChild( featDiv.firstChild ); + } + var s = featDiv.featureEdges.s; + var e = featDiv.featureEdges.e; + for ( var key in sourceSlot.booleanCovs ) { + if ( sourceSlot.booleanCovs.hasOwnProperty(key) ) { + // dynamically resize the coverage divs. + var start = sourceSlot.booleanCovs[key].span.s; + var end = sourceSlot.booleanCovs[key].span.e; + if ( end < containerStart || start > containerEnd) + continue; + // note: we should also remove it from booleanCovs at some point. + sourceSlot.booleanCovs[key].style.left = 100*(start-s)/(e-s)+'%'; + sourceSlot.booleanCovs[key].style.width = 100*(end-start)/(e-s)+'%'; + featDiv.appendChild( sourceSlot.booleanCovs[key] ); + } + } + // add the processed subfeatures, if in frame. + dojo.query( '.basicSubfeature', sourceSlot ).forEach( + function(node, idx, arr) { + var start = node.subfeatureEdges.s; + var end = node.subfeatureEdges.e; + if ( end < containerStart || start > containerEnd ) + return; + node.style.left = 100*(start-s)/(e-s)+'%'; + node.style.width = 100*(end-start)/(e-s)+'%'; + featDiv.appendChild(node); + } + ); + if ( this.config.style.arrowheadClass ) { + // add arrowheads + var a = this.config.style.arrowheadClass; + dojo.query( '.minus-'+a+', .plus-'+a, sourceSlot ).forEach( + function(node, idx, arr) { + featDiv.appendChild(node); + } + ) + } + featDiv.className = 'basic'; + featDiv.oldClassName = sourceSlot.oldClassName; + featDiv.booleanCovs = sourceSlot.booleanCovs; + }, + + /** + * arguments: + * @param args.block div to be filled with info + * @param args.leftBlock div to the left of the block to be filled + * @param args.rightBlock div to the right of the block to be filled + * @param args.leftBase starting base of the block + * @param args.rightBase ending base of the block + * @param args.scale pixels per base at the current zoom level + * @param args.containerStart don't make HTML elements extend further left than this + * @param args.containerEnd don't make HTML elements extend further right than this. 0-based. + */ + fillFeatures: function(args) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + var stats = args.stats; + var containerStart = args.containerStart; + var containerEnd = args.containerEnd; + var finishCallback = args.finishCallback; + + this.scale = scale; + + block.featureNodes = {}; + + //determine the glyph height, arrowhead width, label text dimensions, etc. + if( !this.haveMeasurements ) { + this.measureStyles(); + this.haveMeasurements = true; + } + + var labelScale = this.config.style.labelScale || stats.featureDensity * this.config.style._defaultLabelScale; + var descriptionScale = this.config.style.descriptionScale || stats.featureDensity * this.config.style._defaultDescriptionScale; + + var curTrack = this; + + var featCallback = dojo.hitch(this,function( feature ) { + var uniqueId = feature.id(); + if( ! this._featureIsRendered( uniqueId ) ) { + /* feature render, adding to block, centering refactored into addFeatureToBlock() */ + // var filter = this.browser.view.featureFilter; + if( this.filterFeature( feature ) ) { + this.addFeatureToBlock( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ); + } + } + }); + + this.store.getFeatures( { ref: this.refSeq.name, + start: leftBase, + end: rightBase + }, + featCallback, + function ( args ) { + curTrack.heightUpdate(curTrack._getLayout(scale).getTotalHeight(), + blockIndex); + if ( args && args.maskingSpans ) { + //note: spans have to be inverted + var invSpan = []; + invSpan[0] = { start: leftBase }; + var i = 0; + for ( var span in args.maskingSpans) { + if (args.maskingSpans.hasOwnProperty(span)) { + span = args.maskingSpans[span]; + invSpan[i].end = span.start; + i++; + invSpan[i] = { start: span.end }; + } + } + invSpan[i].end = rightBase; + if (invSpan[i].end <= invSpan[i].start) { + invSpan.splice(i,1); } + if (invSpan[0].end <= invSpan[0].start) { + invSpan.splice(0,1); } + curTrack.maskBySpans( invSpan, args.maskingSpans ); + } + finishCallback(); + }, + function( error ) { + console.error( error, error.stack ); + curTrack.fillBlockError( blockIndex, block, error ); + finishCallback(); + } + ); + }, + + /** + * Creates feature div, adds to block, and centers subfeatures. + * Overridable by subclasses that need more control over the substructure. + */ + addFeatureToBlock: function( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ) { + var featDiv = this.renderFeature( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ); + if( ! featDiv ) + return null; + + block.domNode.appendChild( featDiv ); + if( this.config.style.centerChildrenVertically ) + this._centerChildrenVertically( featDiv ); + return featDiv; + }, + + + fillBlockTimeout: function( blockIndex, block ) { + this.inherited( arguments ); + block.featureNodes = {}; + }, + + + /** + * Returns true if a feature is visible and rendered someplace in the blocks of this track. + * @private + */ + _featureIsRendered: function( uniqueId ) { + var blocks = this.blocks; + for( var i=0; i= end ) { + isAdded = true; + break; + } + var u = union (start, end, divStart, divEnd ); + if ( u ) { + var coverageNode = makeDiv( u.s, u.e, parentDiv, masked, voidClass ); + var tempIndex = parentDiv.booleanCovs.indexOf(parentDiv.childNodes[key]); + parentDiv.removeChild(parentDiv.childNodes[key]); + parentDiv.booleanCovs.splice(tempIndex, 1); + parentDiv.appendChild(coverageNode); + parentDiv.booleanCovs.push(coverageNode); + isAdded = true; + addDiv( u.s, u.e, parentDiv, masked, voidClass, true ); + break; + } + } + } + if ( !isAdded ) { + var coverageNode = makeDiv( start, end, parentDiv, masked, voidClass ); + parentDiv.appendChild(coverageNode); + parentDiv.booleanCovs.push(coverageNode); + } + }; + + var addOverlaps = function ( s, e, feat, spans, invSpans, voidClass ) { + if ( !feat.booleanCovs ) { + feat.booleanCovs = []; + } + // add opaque divs + for ( var index in invSpans ) { + if ( invSpans.hasOwnProperty(index) ) { + var ov = overlaps( s, e, invSpans[index].start, invSpans[index].end ); + if ( ov ) { + addDiv( ov.s, ov.e, feat, false, voidClass ); + } + } + } + // add masked divs + for ( var index in spans ) { + if ( spans.hasOwnProperty(index) ) { + var ov = overlaps( s, e, spans[index].start, spans[index].end ); + if ( ov ) { + addDiv( ov.s, ov.e, feat, true, voidClass ); + } + } + } + + feat.oldClassName = feat.className == voidClass + ? feat.oldClassName + : feat.className; + feat.className = voidClass; + }; + + for ( var key in block.featureNodes ) { + if (block.featureNodes.hasOwnProperty(key)) { + var feat = block.featureNodes[key]; + if ( !feat.feature ) { + // If there is no feature property, than it is a subfeature + var s = feat.subfeatureEdges.s; + var e = feat.subfeatureEdges.e; + addOverlaps( s, e, feat, spans, invSpans, 'basicSubfeature' ); + continue; + } + var s = feat.feature.get('start'); + var e = feat.feature.get('end'); + addOverlaps( s, e, feat, spans, invSpans, 'basic' ); + } + } + } + } + }, + + measureStyles: function() { + //determine dimensions of labels (height, per-character width) + var heightTest = document.createElement("div"); + heightTest.className = "feature-label"; + heightTest.style.height = "auto"; + heightTest.style.visibility = "hidden"; + heightTest.appendChild(document.createTextNode("1234567890")); + document.body.appendChild(heightTest); + this.labelHeight = heightTest.clientHeight; + this.labelWidth = heightTest.clientWidth / 10; + document.body.removeChild(heightTest); + + //measure the height of glyphs + var glyphBox; + heightTest = document.createElement("div"); + //cover all the bases: stranded or not, phase or not + heightTest.className = + "feature " + this.config.style.className + + " plus-" + this.config.style.className + + " plus-" + this.config.style.className + "1"; + if (this.config.style.featureCss) + heightTest.style.cssText = this.config.style.featureCss; + heightTest.style.visibility = "hidden"; + if (Util.is_ie6) heightTest.appendChild(document.createComment("foo")); + document.body.appendChild(heightTest); + glyphBox = domGeom.getMarginBox(heightTest); + this.glyphHeight = Math.round(glyphBox.h); + this.padding = this.defaultPadding + glyphBox.w; + document.body.removeChild(heightTest); + + //determine the width of the arrowhead, if any + if (this.config.style.arrowheadClass) { + var ah = document.createElement("div"); + ah.className = "plus-" + this.config.style.arrowheadClass; + if (Util.is_ie6) ah.appendChild(document.createComment("foo")); + document.body.appendChild(ah); + glyphBox = domGeom.position(ah); + this.plusArrowWidth = glyphBox.w; + this.plusArrowHeight = glyphBox.h; + ah.className = "minus-" + this.config.style.arrowheadClass; + glyphBox = domGeom.position(ah); + this.minusArrowWidth = glyphBox.w; + this.minusArrowHeight = glyphBox.h; + document.body.removeChild(ah); + } + }, + + hideAll: function() { + this._clearLayout(); + return this.inherited(arguments); + }, + + getFeatDiv: function( feature ) { + var id = this.getId( feature ); + if( ! id ) + return null; + + for( var i = 0; i < this.blocks.length; i++ ) { + var b = this.blocks[i]; + if( b && b.featureNodes ) { + var f = b.featureNodes[id]; + if( f ) + return f; + } + } + + return null; + }, + + getId: function( f ) { + return f.id(); + }, + + renderFeature: function( feature, uniqueId, block, scale, labelScale, descriptionScale, containerStart, containerEnd ) { + //featureStart and featureEnd indicate how far left or right + //the feature extends in bp space, including labels + //and arrowheads if applicable + + var featureEnd = feature.get('end'); + var featureStart = feature.get('start'); + if( typeof featureEnd == 'string' ) + featureEnd = parseInt(featureEnd); + if( typeof featureStart == 'string' ) + featureStart = parseInt(featureStart); + // layoutStart: start genome coord (at current scale) of horizontal space need to render feature, + // including decorations (arrowhead, label, etc) and padding + var layoutStart = featureStart; + // layoutEnd: end genome coord (at current scale) of horizontal space need to render feature, + // including decorations (arrowhead, label, etc) and padding + var layoutEnd = featureEnd; + + // JBrowse now draws arrowheads within feature genome coord bounds + // For WebApollo we're keeping arrow outside of feature genome coord bounds, + // because otherwise arrow can obscure edge-matching, CDS/UTR transitions, small inton/exons, etc. + // Would like to implement arrowhead change in WebApollo plugin, but would need to refactor HTMLFeature more to allow for that + if (this.config.style.arrowheadClass) { + switch (feature.get('strand')) { + case 1: + case '+': + layoutEnd += (this.plusArrowWidth / scale); break; + case -1: + case '-': + layoutStart -= (this.minusArrowWidth / scale); break; + } + } + + var levelHeight = this.glyphHeight + this.glyphHeightPad; + + // if the label extends beyond the feature, use the + // label end position as the end position for layout + var name = this.getFeatureLabel( feature ); + var description = scale > descriptionScale && this.getFeatureDescription(feature); + if( description && description.length > this.config.style.maxDescriptionLength ) + description = description.substr(0, this.config.style.maxDescriptionLength+1 ).replace(/(\s+\S+|\s*)$/,'')+String.fromCharCode(8230); + + // add the label div (which includes the description) to the + // calculated height of the feature if it will be displayed + if( this.showLabels && scale >= labelScale && name ) { + layoutEnd = Math.max(layoutEnd, layoutStart + (''+name).length * this.labelWidth / scale ); + levelHeight += this.labelHeight + this.labelPad; + } + if( this.showLabels && description ) { + layoutEnd = Math.max( layoutEnd, layoutStart + (''+description).length * this.labelWidth / scale ); + levelHeight += this.labelHeight + this.labelPad; + } + + layoutEnd += Math.max(1, this.padding / scale); + + var top = this._getLayout( scale ) + .addRect( uniqueId, + layoutStart, + layoutEnd, + levelHeight); + + if( top === null ) { + // could not lay out, would exceed our configured maxHeight + // mark the block as exceeding the max height + this.markBlockHeightOverflow( block ); + return null; + } + + var featDiv = this.config.hooks.create(this, feature ); + this._connectFeatDivHandlers( featDiv ); + // NOTE ANY DATA SET ON THE FEATDIV DOM NODE NEEDS TO BE + // MANUALLY DELETED IN THE cleanupBlock METHOD BELOW + featDiv.track = this; + featDiv.feature = feature; + featDiv.layoutEnd = layoutEnd; + + // border values used in positioning boolean subfeatures, if any. + featDiv.featureEdges = { s : Math.max( featDiv.feature.get('start'), containerStart ), + e : Math.min( featDiv.feature.get('end') , containerEnd ) }; + + // (callbackArgs are the args that will be passed to callbacks + // in this feature's context menu or left-click handlers) + featDiv.callbackArgs = [ this, featDiv.feature, featDiv ]; + + // save the label scale and description scale in the featDiv + // so that we can use them later + featDiv._labelScale = labelScale; + featDiv._descriptionScale = descriptionScale; + + + block.featureNodes[uniqueId] = featDiv; + + // record whether this feature protrudes beyond the left and/or right side of the block + if( layoutStart < block.startBase ) { + if( ! block.leftOverlaps ) block.leftOverlaps = []; + block.leftOverlaps.push( uniqueId ); + } + if( layoutEnd > block.endBase ) { + if( ! block.rightOverlaps ) block.rightOverlaps = []; + block.rightOverlaps.push( uniqueId ); + } + + dojo.addClass(featDiv, "feature"); + var className = this.config.style.className; + if (className == "{type}") { className = feature.get('type'); } + var strand = feature.get('strand'); + switch (strand) { + case 1: + case '+': + dojo.addClass(featDiv, "plus-" + className); break; + case -1: + case '-': + dojo.addClass(featDiv, "minus-" + className); break; + default: + dojo.addClass(featDiv, className); + } + var phase = feature.get('phase'); + if ((phase !== null) && (phase !== undefined)) +// featDiv.className = featDiv.className + " " + featDiv.className + "_phase" + phase; + dojo.addClass(featDiv, className + "_phase" + phase); + + // check if this feature is highlighted + var highlighted = this.isFeatureHighlighted( feature, name ); + + // add 'highlighted' to the feature's class if its name + // matches the objectName of the global highlight and it's + // within the highlighted region + if( highlighted ) + dojo.addClass( featDiv, 'highlighted' ); + + // Since some browsers don't deal well with the situation where + // the feature goes way, way offscreen, we truncate the feature + // to exist betwen containerStart and containerEnd. + // To make sure the truncated end of the feature never gets shown, + // we'll destroy and re-create the feature (with updated truncated + // boundaries) in the transfer method. + var displayStart = Math.max( featureStart, containerStart ); + var displayEnd = Math.min( featureEnd, containerEnd ); + var blockWidth = block.endBase - block.startBase; + var featwidth = Math.max( this.minFeatWidth, (100 * ((displayEnd - displayStart) / blockWidth))); + featDiv.style.cssText = + "left:" + (100 * (displayStart - block.startBase) / blockWidth) + "%;" + + "top:" + top + "px;" + + " width:" + featwidth + "%;" + + (this.config.style.featureCss ? this.config.style.featureCss : ""); + + if ( this.config.style.arrowheadClass ) { + var ah = document.createElement("div"); + var featwidth_px = featwidth/100*blockWidth*scale; + + switch (strand) { + case 1: + case '+': + ah.className = "plus-" + this.config.style.arrowheadClass; + ah.style.cssText = "right: "+(-this.plusArrowWidth) + "px"; + featDiv.appendChild(ah); + break; + case -1: + case '-': + ah.className = "minus-" + this.config.style.arrowheadClass; + ah.style.cssText = "left: " + (-this.minusArrowWidth) + "px"; + featDiv.appendChild(ah); + break; + } + } + if ( ( name || description ) && this.showLabels && scale >= labelScale ) { + var labelDiv = dojo.create( 'div', { + className: "feature-label" + ( highlighted ? ' highlighted' : '' ), + innerHTML: ( name ? '
'+name+'
' : '' ) + +( description ? '
'+description+'
' : '' ), + style: { + top: (top + this.glyphHeight + 2) + "px", + left: (100 * (layoutStart - block.startBase) / blockWidth)+'%' + } + }, block.domNode ); + + this._connectFeatDivHandlers( labelDiv ); + + featDiv.label = labelDiv; + + // NOTE: ANY DATA ADDED TO THE labelDiv MUST HAVE A + // CORRESPONDING DELETE STATMENT IN cleanupBlock BELOW + labelDiv.feature = feature; + labelDiv.track = this; + // (callbackArgs are the args that will be passed to callbacks + // in this feature's context menu or left-click handlers) + labelDiv.callbackArgs = [ this, featDiv.feature, featDiv ]; + } + + if( featwidth > this.config.style.minSubfeatureWidth ) { + this.handleSubFeatures(feature, featDiv, displayStart, displayEnd, block); + } + + if ( typeof this.config.hooks.modify == 'function' ) { + this.config.hooks.modify(this, feature, featDiv); + } + + return featDiv; + }, + + handleSubFeatures: function( feature, featDiv, + displayStart, displayEnd, block ) { + var subfeatures = feature.get('subfeatures'); + if( subfeatures ) { + for (var i = 0; i < subfeatures.length; i++) { + this.renderSubfeature( feature, featDiv, + subfeatures[i], + displayStart, displayEnd, block ); + } + } + }, + + /** + * Get the height of a div. Caches div heights based on + * classname. + */ + _getHeight: function( theDiv ) { + if (this.config.disableHeightCache) { + return theDiv.offsetHeight || 0; + } + else { + var c = this.heightCache[ theDiv.className ]; + if( c ) + return c; + c = theDiv.offsetHeight || 0; + this.heightCache[ theDiv.className ] = c; + return c; + } + }, + + /** + * Vertically centers all the child elements of a feature div. + * @private + */ + _centerChildrenVertically: function( /**HTMLElement*/ featDiv ) { + if( featDiv.childNodes.length > 0 ) { + var parentHeight = this._getHeight(featDiv); + for( var i = 0; i< featDiv.childNodes.length; i++ ) { + var child = featDiv.childNodes[i]; + // only operate on child nodes that can be styled, + // i.e. HTML elements instead of text nodes or whatnot + if( child.style ) { + // cache the height of elements, for speed. + var h = this._getHeight(child); + dojo.style( child, { marginTop: '0', top: ((parentHeight-h)/2) + 'px' }); + // recursively center any descendants + if (child.childNodes.length > 0) { + this._centerChildrenVertically( child ); + } + } + } + } + }, + + /** + * Connect our configured event handlers to a given html element, + * usually a feature div or label div. + */ + _connectFeatDivHandlers: function( /** HTMLElement */ div ) { + for( var event in this.eventHandlers ) { + this.own( on( div, event, this.eventHandlers[event] ) ); + } + // if our click handler has a label, set that as a tooltip + if( this.eventHandlers.click && this.eventHandlers.click.label ) + div.setAttribute( 'title', this.eventHandlers.click.label ); + }, + + renderSubfeature: function( feature, featDiv, subfeature, displayStart, displayEnd, block ) { + var subStart = subfeature.get('start'); + var subEnd = subfeature.get('end'); + var featLength = displayEnd - displayStart; + var type = subfeature.get('type'); + var className; + if( this.config.style.subfeatureClasses ) { + className = this.config.style.subfeatureClasses[type]; + // if no class mapping specified for type, default to subfeature.get('type') + if (className === undefined) { className = type; } + // if subfeatureClasses specifies that subfeature type explicitly maps to null className + // then don't render the feature + else if (className === null) { + return null; + } + } + else { + // if no config.style.subfeatureClasses to specify subfeature class mapping, default to subfeature.get('type') + className = type; + } + + // a className of 'hidden' causes things to not even be rendered + if( className == 'hidden' ) + return null; + + var subDiv = document.createElement("div"); + // used by boolean tracks to do positiocning + subDiv.subfeatureEdges = { s: subStart, e: subEnd }; + + dojo.addClass(subDiv, "subfeature"); + // check for className to avoid adding "null", "plus-null", "minus-null" + if (className) { + switch ( subfeature.get('strand') ) { + case 1: + case '+': + dojo.addClass(subDiv, "plus-" + className); break; + case -1: + case '-': + dojo.addClass(subDiv, "minus-" + className); break; + default: + dojo.addClass(subDiv, className); + } + } + + // if the feature has been truncated to where it doesn't cover + // this subfeature anymore, just skip this subfeature + if ( subEnd <= displayStart || subStart >= displayEnd ) + return null; + + if (Util.is_ie6) subDiv.appendChild(document.createComment()); + + subDiv.style.cssText = "left: " + (100 * ((subStart - displayStart) / featLength)) + "%;" + + "width: " + (100 * ((subEnd - subStart) / featLength)) + "%;"; + featDiv.appendChild(subDiv); + + block.featureNodes[ subfeature.id() ] = subDiv; + + return subDiv; + }, + + _getLayout: function( scale ) { + + //determine the glyph height, arrowhead width, label text dimensions, etc. + if (!this.haveMeasurements) { + this.measureStyles(); + this.haveMeasurements = true; + } + + // create the layout if we need to, and we can + if( ( ! this.layout || this.layout.pitchX != 4/scale ) && scale ) + this.layout = new Layout({ + pitchX: 4/scale, + pitchY: this.config.layoutPitchY || (this.glyphHeight + this.glyphHeightPad), + maxHeight: this.getConf('maxHeight') + }); + + + return this.layout; + }, + _clearLayout: function() { + delete this.layout; + }, + + clear: function() { + delete this.layout; + this.inherited( arguments ); + }, + + /** + * indicates a change to this track has happened that may require a re-layout + * clearing layout here, and relying on superclass BlockBased.changed() call and + * standard _changedCallback function passed in track constructor to trigger relayout + */ + changed: function() { + this._clearLayout(); + this.inherited(arguments); + }, + + _exportFormats: function() { + return [ {name: 'GFF3', label: 'GFF3', fileExt: 'gff3'}, {name: 'BED', label: 'BED', fileExt: 'bed'}, { name: 'SequinTable', label: 'Sequin Table', fileExt: 'sqn' } ]; + }, + +}); + +return HTMLFeatures; +}); + +/* + +Copyright (c) 2007-2010 The Evolutionary Software Foundation + +Created by Mitchell Skinner + +This package and its accompanying libraries are free software; you can +redistribute it and/or modify it under the terms of the LGPL (either +version 2.1, or at your option, any later version) or the Artistic +License 2.0. Refer to LICENSE for the full license text. + +*/ diff --git a/www/JBrowse/View/Track/HTMLVariants.js b/www/JBrowse/View/Track/HTMLVariants.js new file mode 100644 index 00000000..0ce0a6bf --- /dev/null +++ b/www/JBrowse/View/Track/HTMLVariants.js @@ -0,0 +1,19 @@ +/** + * Just an HTMLFeatures track that uses the VariantDetailsMixin to + * provide a variant-specific feature detail dialog. + */ + +define( [ + 'dojo/_base/declare', + 'JBrowse/View/Track/HTMLFeatures', + 'JBrowse/View/Track/VariantDetailMixin' + ], + + function( + declare, + HTMLFeatures, + VariantDetailsMixin + ) { +return declare( [ HTMLFeatures, VariantDetailsMixin ], { +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/LocationScale.js b/www/JBrowse/View/Track/LocationScale.js new file mode 100644 index 00000000..cb3c2f46 --- /dev/null +++ b/www/JBrowse/View/Track/LocationScale.js @@ -0,0 +1,60 @@ +define([ + 'dojo/_base/declare', + 'dojo/dom-construct', + 'JBrowse/View/Track/BlockBased', + 'JBrowse/Util'], + function( + declare, + dom, + BlockBased, + Util + ) { +return declare(BlockBased, + /** + * @lends JBrowse.View.Track.LocationScale.prototype + */ +{ + + /** + * This track is for (e.g.) position and sequence information that should + * always stay visible at the top of the view. + * @constructs + */ + + constructor: function( args ) {//name, labelClass, posHeight) { + this.loaded = true; + this.labelClass = args.labelClass; + this.posHeight = args.posHeight; + this.height = Math.round( args.posHeight * 1.2 ); + }, + + // this track has no track label or track menu, stub them out + makeTrackLabel: function() {}, + makeTrackMenu: function() {}, + + fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + + var posLabel = document.createElement("div"); + var numtext = Util.addCommas( leftBase+1 ); + posLabel.className = this.labelClass; + + // give the position label a negative left offset in ex's to + // more-or-less center it over the left boundary of the block + posLabel.style.left = "-" + Number(numtext.length)/1.7 + "ex"; + + posLabel.appendChild( document.createTextNode( numtext ) ); + block.domNode.appendChild(posLabel); + + var highlight = this.browser.getHighlight(); + if( highlight && highlight.ref == this.refSeq.name ) + this.renderRegionHighlight( args, highlight ); + + this.heightUpdate( Math.round( this.posHeight*1.2 ), blockIndex); + args.finishCallback(); + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/SNPCoverage.js b/www/JBrowse/View/Track/SNPCoverage.js new file mode 100644 index 00000000..2eed6b08 --- /dev/null +++ b/www/JBrowse/View/Track/SNPCoverage.js @@ -0,0 +1,234 @@ +define( ['dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/View/Track/Wiggle/XYPlot', + 'JBrowse/Util', + 'JBrowse/View/Track/_AlignmentsMixin', + 'JBrowse/Store/SeqFeature/SNPCoverage' + ], + function( declare, array, WiggleXY, Util, AlignmentsMixin, SNPCoverageStore ) { + +var dojof = Util.dojof; + +return declare( [WiggleXY, AlignmentsMixin], +{ + constructor: function() { + // force conf variables that are meaningless for this kind of track, and maybe harmful + delete this.config.bicolor_pivot; + delete this.config.scale; + delete this.config.align; + + this.store = new SNPCoverageStore({ store: this.store, browser: this.browser }); + }, + + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + autoscale: 'local', + min_score: 0 + } + ); + }, + + /* + * Draw a set of features on the canvas. + * @private + */ + _drawFeatures: function( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ) { + var thisB = this; + var context = canvas.getContext('2d'); + var canvasHeight = canvas.height; + var toY = dojo.hitch( this, function( val ) { + return canvasHeight * ( 1-dataScale.normalize.call(this, val) ); + }); + var originY = toY( dataScale.origin ); + + // a canvas element below the histogram that will contain indicators of likely SNPs + var snpCanvasHeight = 20; + var snpCanvas = dojo.create('canvas', + {height: snpCanvasHeight, + width: canvas.width, + style: { + cursor: 'default', + width: "100%", + height: snpCanvasHeight + "px" + }, + innerHTML: 'Your web browser cannot display this type of track.', + className: 'SNP-indicator-track' + }, block.domNode); + var snpContext = snpCanvas.getContext('2d'); + + var negColor = this.config.style.neg_color; + var clipColor = this.config.style.clip_marker_color; + var bgColor = this.config.style.bg_color; + var disableClipMarkers = this.config.disable_clip_markers; + + var drawRectangle = function(ID, yPos, height, fRect) { + if( yPos <= canvasHeight ) { // if the rectangle is visible at all + context.fillStyle = thisB.colorForBase(ID); + if( yPos <= originY ) { + // bar goes upward + context.fillRect( fRect.l, yPos, fRect.w, height); + if( !disableClipMarkers && yPos < 0 ) { // draw clip marker if necessary + context.fillStyle = clipColor || negColor; + context.fillRect( fRect.l, 0, fRect.w, 2 ); + } + } + else { + // bar goes downward + context.fillRect( fRect.l, originY, fRect.w, height ); + if( !disableClipMarkers && yPos >= canvasHeight ) { // draw clip marker if necessary + context.fillStyle = clipColor || thisB.colorForBase(ID); + context.fillRect( fRect.l, canvasHeight-3, fRect.w, 2 ); + } + } + } + }; + + // Note: 'reference' is done first to ensure the grey part of the graph is on top + dojo.forEach( features, function(f,i) { + var fRect = featureRects[i]; + var score = f.get('score'); + + // draw the background color if we are configured to do so + if( bgColor ) { + context.fillStyle = bgColor; + context.fillRect( fRect.l, 0, fRect.w, canvasHeight ); + } + + drawRectangle( 'reference', toY( score.total() ), originY-toY( score.get('reference'))+1, fRect); + }); + + dojo.forEach( features, function(f,i) { + var fRect = featureRects[i]; + var score = f.get('score'); + var totalHeight = score.total(); + + // draw indicators of SNPs if base coverage is greater than 50% of total coverage + score.forEach( function( count, category ) { + if ( category != 'reference' && count > 0.5*totalHeight ) { + snpContext.beginPath(); + snpContext.arc( fRect.l + 0.5*fRect.w, + 0.40*snpCanvas.height, + 0.20*snpCanvas.height, + 1.75 * Math.PI, + 1.25 * Math.PI, + false); + snpContext.lineTo(fRect.l + 0.5*fRect.w, 0); + snpContext.closePath(); + snpContext.fillStyle = thisB.colorForBase(category); + snpContext.fill(); + snpContext.lineWidth = 1; + snpContext.strokeStyle = 'black'; + snpContext.stroke(); + } + }); + + totalHeight -= score.get('reference'); + + score.forEach( function( count, category ) { + if ( category != 'reference' ) { + drawRectangle( category, toY(totalHeight), originY-toY( count )+1, fRect); + totalHeight -= count; + } + }); + }, this ); + }, + + // Overwrites the method from WiggleBase + _draw: function( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale, pixels, spans ) { + // Note: pixels currently has no meaning, as the function that generates it is not yet defined for this track + this._preDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ); + this._drawFeatures( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ); + if ( spans ) { + this._maskBySpans( scale, leftBase, canvas, spans ); + } + this._postDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ); + }, + + /* If it's a boolean track, mask accordingly */ + _maskBySpans: function( scale, leftBase, canvas, spans ) { + var context = canvas.getContext('2d'); + var canvasHeight = canvas.height; + var booleanAlpha = this.config.style.masked_transparancy || 0.17; + this.config.style.masked_transparancy = booleanAlpha; + + // make a temporary canvas to store image data + var tempCan = dojo.create( 'canvas', {height: canvasHeight, width: canvas.width} ); + var ctx2 = tempCan.getContext('2d'); + + for ( var index in spans ) { + if (spans.hasOwnProperty(index)) { + var w = Math.round(( spans[index].end - spans[index].start ) * scale ); + var l = Math.round(( spans[index].start - leftBase ) * scale ); + if (l+w >= canvas.width) + w = canvas.width-l; // correct possible rounding errors + if (w==0) + continue; // skip if there's no width. + ctx2.drawImage(canvas, l, 0, w, canvasHeight, l, 0, w, canvasHeight); + context.globalAlpha = booleanAlpha; + // clear masked region and redraw at lower opacity. + context.clearRect(l, 0, w, canvasHeight); + context.drawImage(tempCan, l, 0, w, canvasHeight, l, 0, w, canvasHeight); + context.globalAlpha = 1; + } + } + }, + + /* + * The following method is required to override the equivalent method in "WiggleBase.js" + * It displays more complete data. + */ + _showPixelValue: function( scoreDisplay, score ) { + if( ! score || ! score.score ) + return false; + score = score.score; + + function fmtNum( num ) { + return parseFloat( num ).toPrecision(6).replace(/0+$/,'').replace(/\.$/,''); + } + + if( score.snpsCounted ) { + var total = score.total(); + var scoreSummary = ''; + function pctString( count ) { + return Math.round(count/total*100)+'%'; + } + scoreSummary += + ''; + + score.forEach( function( count, category ) { + if( category == 'reference' ) return; + + // if this count has more nested categories, do counts of those + var subdistribution = ''; + if( count.forEach ) { + subdistribution = []; + count.forEach( function( count, category ) { + subdistribution.push( fmtNum(count) + ' '+category ); + }); + subdistribution = subdistribution.join(', '); + if( subdistribution ) + subdistribution = '('+subdistribution+')'; + } + + category = { '*': 'del' }[category] || category; + scoreSummary += ''; + }); + scoreSummary += ''; + scoreDisplay.innerHTML = scoreSummary+'
' + + (score.refBase ? score.refBase+'*' : 'Ref') + + '' + + fmtNum( score.get('reference') ) + + '' + + pctString( score.get('reference') ) + + '
'+category + '' + fmtNum(count) + '' + +pctString(count)+''+subdistribution + '
Total'+fmtNum(total)+'  
'; + return true; + } else { + scoreDisplay.innerHTML = '
Total'+fmtNum(score)+'
'; + return true; + } + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Sequence.js b/www/JBrowse/View/Track/Sequence.js new file mode 100644 index 00000000..08b0c651 --- /dev/null +++ b/www/JBrowse/View/Track/Sequence.js @@ -0,0 +1,178 @@ +define( [ + 'dojo/_base/declare', + 'JBrowse/View/Track/BlockBased', + 'JBrowse/View/Track/ExportMixin', + 'JBrowse/Util' + ], + function( declare, BlockBased, ExportMixin, Util ) { + +return declare( [BlockBased, ExportMixin], + /** + * @lends JBrowse.View.Track.Sequence.prototype + */ +{ + /** + * Track to display the underlying reference sequence, when zoomed in + * far enough. + * + * @constructs + * @extends JBrowse.View.Track.BlockBased + */ + constructor: function( args ) {}, + + _defaultConfig: function() { + return { + maxExportSpan: 500000, + showReverseStrand: true + }; + }, + _exportFormats: function() { + return [{name: 'FASTA', label: 'FASTA', fileExt: 'fasta'}]; + }, + + endZoom: function(destScale, destBlockBases) { + this.clear(); + }, + + setViewInfo:function(genomeView, heightUpdate, numBlocks, + trackDiv, + widthPct, widthPx, scale) { + this.inherited( arguments ); + this.show(); + }, + + nbsp: String.fromCharCode(160), + + fillBlock:function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + + var charSize = this.getCharacterMeasurements(); + + // if we are zoomed in far enough to draw bases, then draw them + if ( scale >= 1 ) { + this.store.getFeatures( + { + ref: this.refSeq.name, + seqChunkSize: this.refSeq.seqChunkSize, + start: leftBase, + end: rightBase + }, + dojo.hitch( this, '_fillSequenceBlock', block, scale ), + function() {} + ); + this.heightUpdate( charSize.h*2, blockIndex ); + } + // otherwise, just draw a sort of line (possibly dotted) that + // suggests there are bases there if you zoom in far enough + else { + var borderWidth = Math.max(1,Math.round(4*scale/charSize.w)); + var blur = dojo.create( 'div', { + className: 'sequence_blur', + style: { borderStyle: 'solid', borderTopWidth: borderWidth+'px', borderBottomWidth: borderWidth+'px' } + }, block.domNode ); + this.heightUpdate( blur.offsetHeight+2*blur.offsetTop, blockIndex ); + } + + args.finishCallback(); + }, + + _fillSequenceBlock: function( block, scale, feature ) { + var seq = feature.get('seq'); + var start = feature.get('start'); + var end = feature.get('end'); + + // fill with leading blanks if the + // sequence does not extend all the way + // pad with blanks if the sequence does not extend all the way + // across our range + if( start < this.refSeq.start ) + while( seq.length < (end-start) ) { + //nbsp is an " " entity + seq = this.nbsp+seq; + } + else if( end > this.refSeq.end ) + while( seq.length < (end-start) ) { + //nbsp is an " " entity + seq += this.nbsp; + } + + + // make a div to contain the sequences + var seqNode = document.createElement("div"); + seqNode.className = "sequence"; + seqNode.style.width = "100%"; + block.domNode.appendChild(seqNode); + + // add a div for the forward strand + seqNode.appendChild( this._renderSeqDiv( start, end, seq, scale )); + + // and one for the reverse strand + if( this.config.showReverseStrand ) { + var comp = this._renderSeqDiv( start, end, Util.complement(seq), scale ); + comp.className = 'revcom'; + seqNode.appendChild( comp ); + } + }, + + /** + * Given the start and end coordinates, and the sequence bases, + * makes a div containing the sequence. + * @private + */ + _renderSeqDiv: function ( start, end, seq, scale ) { + + var charSize = this.getCharacterMeasurements(); + + var container = document.createElement('div'); + var charWidth = 100/(end-start)+"%"; + var drawChars = scale >= charSize.w; + var bigTiles = scale > charSize.w + 4; // whether to add .big styles to the base tiles + for( var i=0; ih and w, + * in pixels, of the characters being used for sequences + */ + getCharacterMeasurements: function() { + if( !this._measurements ) + this._measurements = this._measureSequenceCharacterSize( this.div ); + return this._measurements; + }, + + /** + * Conducts a test with DOM elements to measure sequence text width + * and height. + */ + _measureSequenceCharacterSize: function( containerElement ) { + var widthTest = document.createElement("div"); + widthTest.className = "sequence"; + widthTest.style.visibility = "hidden"; + var widthText = "12345678901234567890123456789012345678901234567890"; + widthTest.appendChild(document.createTextNode(widthText)); + containerElement.appendChild(widthTest); + var result = { + w: widthTest.clientWidth / widthText.length, + h: widthTest.clientHeight + }; + containerElement.removeChild(widthTest); + return result; + } + +}); +}); diff --git a/www/JBrowse/View/Track/SequenceTrack.js b/www/JBrowse/View/Track/SequenceTrack.js new file mode 100644 index 00000000..b55ac0cb --- /dev/null +++ b/www/JBrowse/View/Track/SequenceTrack.js @@ -0,0 +1,1329 @@ +define( [ + 'dojo/_base/declare', + 'JBrowse/Store/Sequence/StaticChunked', + 'JBrowse/Store/SeqFeature/ScratchPad', + 'JBrowse/View/Track/DraggableHTMLFeatures', + 'JBrowse/JSONUtils', + 'JBrowse/Permission', + 'dojox/widget/Standby' + ], +function( declare, StaticChunked, ScratchPad, DraggableFeatureTrack, JSONUtils, Permission, Standby ) { + + var SequenceTrack = declare( "SequenceTrack", DraggableFeatureTrack, + +{ + +/** + * Track to display the underlying reference sequence, when zoomed in + * far enough. + * @class + * @constructor + */ + constructor: function( args ) { + this.isWebApolloSequenceTrack = true; + var track = this; + + /** + * DraggableFeatureTrack now has its own context menu for divs, + * and adding this flag provides a quick way to short-circuit it's + * initialization + */ + this.has_custom_context_menu = true; + // this.use_standard_context_menu = false; + this.show_reverse_strand = true; + this.show_protein_translation = true; + this.context_path = ".."; + this.verbose_server_notification = false; + + this.residues_context_menu = new dijit.Menu({}); // placeholder till setAnnotTrack() triggers real menu init + this.annot_context_menu = new dijit.Menu({}); // placeholder till setAnnotTrack() triggers real menu init + + this.residuesMouseDown = function(event) { + track.onResiduesMouseDown(event); + }; + +// this.charSize = this.webapollo.getSequenceCharacterSize(); + // this.charWidth = this.charSize.charWidth; + // this.seqHeight = this.charSize.seqHeight; + + // splitting seqHeight into residuesHeight and translationHeight, so future iteration may be possible + // for DNA residues and protein translation to be different styles + // this.dnaHeight = this.seqHeight; + // this.proteinHeight = this.seqHeight; + + // this.refSeq = refSeq; already assigned in BlockBased superclass + + if (this.store.name == 'refseqs') { + this.sequenceStore = this.store; + var annotStoreConfig = dojo.clone(this.config); + annotStoreConfig.browser = this.browser; + annotStoreConfig.refSeq = this.refSeq; + var annotStore = new ScratchPad(annotStoreConfig); + this.store = annotStore; + annotStoreConfig.name = this.store.name; + this.browser._storeCache[this.store.name] = { + refCount: 1, + store: this.store + }; + } + else { + var seqStoreConfig = dojo.clone(this.config); + seqStoreConfig.storeClass = "JBrowse/Store/Sequence/StaticChunked"; + seqStoreConfig.type = "JBrowse/Store/Sequence/StaticChunked"; + // old style, using residuesUrlTemplate + if (this.config.residuesUrlTemplate) { + seqStoreConfig.urlTemplate = this.config.residuesUrlTemplate; + } + + var inner_config = dojo.clone(seqStoreConfig); + // need a seqStoreConfig.config, + // since in StaticChunked constructor seqStoreConfig.baseUrl is ignored, + // and seqStoreConfig.config.baseUrl is used instead (as of JBrowse 1.9.8+) + seqStoreConfig.config = inner_config; + // must add browser and refseq _after_ cloning, otherwise get Browser errors + seqStoreConfig.browser = this.browser; + seqStoreConfig.refSeq = this.refSeq; + + this.sequenceStore = new StaticChunked(seqStoreConfig); + this.browser._storeCache[ 'refseqs'] = { + refCount: 1, + store: this.sequenceStore + }; + } + + this.trackPadding = 10; + this.SHOW_IF_FEATURES = true; + this.ALWAYS_SHOW = false; + // this.setLoaded(); + // this.initContextMenu(); + + /* + var atrack = this.getAnnotTrack(); + if (atrack) { + this.setAnnotTrack(atrack); + } + */ + + this.translationTable = {}; + + var initAnnotTrack = dojo.hitch(this, function() { + var atrack = this.getAnnotTrack(); + if (atrack && this.div) { + this.setAnnotTrack(atrack); + } + else { + window.setTimeout(initAnnotTrack, 100); + } + }); + initAnnotTrack(); + + }, + +// annotSelectionManager is class variable (shared by all AnnotTrack instances) +// SequenceTrack.seqSelectionManager = new FeatureSelectionManager(); + +// setting up selection exclusiveOr -- +// if selection is made in annot track, any selection in other tracks is deselected, and vice versa, +// regardless of multi-select mode etc. +// SequenceTrack.seqSelectionManager.addMutualExclusion(DraggableFeatureTrack.selectionManager); +// SequenceTrack.seqSelectionManager.addMutualExclusion(AnnotTrack.annotSelectionManager); +//DraggableFeatureTrack.selectionManager.addMutualExclusion(SequenceTrack.seqSelectionManager); + +// loadSuccess: function(trackInfo) { } // loadSuccess no longer called by track initialization/loading + _defaultConfig: function() { + var thisConfig = this.inherited(arguments); + // nulling out menuTemplate to suppress default JBrowse feature contextual menu + thisConfig.menuTemplate = null; + thisConfig.maxFeatureScreenDensity = 100000; // set rediculously high, ensures will never show "zoomed too far out" placeholder + thisConfig.style.renderClassName = null; + thisConfig.style.arrowheadClass = null; + thisConfig.style.centerChildrenVertically = false; + thisConfig.ignoreFeatureFilter = true; + thisConfig.style.showLabels = false; + thisConfig.pinned = true; + return thisConfig; + }, + + /** removing "Pin to top" menuitem, so SequenceTrack is always pinned + * (very hacky since depends on label property of menuitem config) + */ + _trackMenuOptions: function() { + var options = this.inherited( arguments ); + options = this.webapollo.removeItemWithLabel(options, "Pin to top"); + return options; + }, + + loadTranslationTable: function() { + var track = this; + return dojo.xhrPost( { + postData: '{ "track": "' + track.annotTrack.getUniqueTrackName() + '", "operation": "get_translation_table" }', + url: track.context_path + "/AnnotationEditorService", + handleAs: "json", + //timeout: 5 * 1000, // Time in milliseconds + // The LOAD function will be called on a successful response. + load: function(response, ioArgs) { // + track.translationTable = {}; + var ttable = response.translation_table; + for (var codon in ttable) { + // looping through codon table, make sure not hitting generic properties... + if (ttable.hasOwnProperty(codon)) { + var aa = ttable[codon]; + // console.log("Codon: ", codon, ", aa: ", aa); + var nucs = []; + for (var i=0; i<3; i++) { + var nuc = codon.charAt(i); + nucs[i] = []; + nucs[i][0] = nuc.toUpperCase(); + nucs[i][1] = nuc.toLowerCase(); + } + for (var i=0; i<2; i++) { + var n0 = nucs[0][i]; + for (var j=0; j<2; j++) { + var n1 = nucs[1][j]; + for (var k=0; k<2; k++) { + var n2 = nucs[2][k]; + var triplet = n0 + n1 + n2; + track.translationTable[triplet] = aa; + // console.log("triplet: ", triplet, ", aa: ", aa ); + } + } + } + } + } + track.changed(); + }, + // The ERROR function will be called in an error case. + error: function(response, ioArgs) { // + return response; // + } + }); + }, + + /** + * called by AnnotTrack to initiate sequence alterations load + */ + loadSequenceAlterations: function() { + var track = this; + + /** + * now do XHR to WebApollo AnnotationEditorService for "get_sequence_alterations" + */ + return dojo.xhrPost( { + postData: '{ "track": "' + track.annotTrack.getUniqueTrackName() + '", "operation": "get_sequence_alterations" }', + url: track.context_path + "/AnnotationEditorService", + handleAs: "json", + //timeout: 5 * 1000, // Time in milliseconds + // The LOAD function will be called on a successful response. + load: function(response, ioArgs) { // + var responseFeatures = response.features; + for (var i = 0; i < responseFeatures.length; i++) { + var jfeat = JSONUtils.createJBrowseSequenceAlteration(responseFeatures[i]); + track.store.insert(jfeat); + } + track.featureCount = track.storedFeatureCount(); + if (track.ALWAYS_SHOW || (track.SHOW_IF_FEATURES && track.featureCount > 0)) { + track.show(); + } + else { + track.hide(); + } + // track.hideAll(); shouldn't need to call hideAll() before changed() anymore + track.changed(); + }, + // The ERROR function will be called in an error case. + error: function(response, ioArgs) { // + + return response; // + } + }); + }, + + startZoom: function(destScale, destStart, destEnd) { + // would prefer to only try and hide dna residues on zoom if previous scale was at base pair resolution + // (otherwise there are no residues to hide), but by time startZoom is called, pxPerBp is already set to destScale, + // so would require keeping prevScale var around, or passing in prevScale as additional parameter to startZoom() + // so for now just always trying to hide residues on a zoom, whether they're present or not + + // if (prevScale == this.charWidth) { + + $(".dna-residues", this.div).css('display', 'none'); + $(".block-seq-container", this.div).css('height', '20px'); + // } + this.heightUpdate(20); + this.gview.trackHeightUpdate(this.name, Math.max(this.labelHeight, 20)); + }, + + endZoom: function(destScale, destBlockBases) { +// var charSize = this.getCharacterMeasurements(); + var charSize = this.webapollo.getSequenceCharacterSize(); + + if( ( destScale == charSize.width ) || + this.ALWAYS_SHOW || (this.SHOW_IF_FEATURES && this.featureCount > 0)) { + this.show(); + } + else { + this.hide(); + } + this.clear(); + // this.prevScale = destScale; + }, + + /* + * SequenceTrack.prototype.showRange = function(first, last, startBase, bpPerBlock, scale, + containerStart, containerEnd) { + console.log("called SequenceTrack.showRange():"); + console.log({ first: first, last: last, startBase: startBase, bpPerBloc: bpPerBlock, scale: scale, + containerStart: containerStart, containerEnd: containerEnd }); + DraggableFeatureTrack.prototype.showRange.apply(this, arguments); + }; + */ + + setViewInfo: function( genomeView, numBlocks, + trackDiv, labelDiv, + widthPct, widthPx, scale ) { + + this.inherited( arguments ); + +// var charSize = this.getCharacterMeasurements(); + var charSize = this.webapollo.getSequenceCharacterSize(); + if ( (scale == charSize.width ) || + this.ALWAYS_SHOW || (this.SHOW_IF_FEATURES && this.featureCount > 0) ) { + this.show(); + } else { + this.hide(); + this.heightUpdate(0); + } + this.setLabel( this.key ); + }, + + startStandby: function() { + if (this.standby == null) { + this.standby = new Standby({target: this.div, color: "transparent"}); + document.body.appendChild(this.standby.domNode); + this.standby.startup(); + this.standby.show(); + } + }, + + stopStandby: function() { + if (this.standby != null) { + this.standby.hide(); + } + }, + + /** + * GAH + * not entirely sure, but I think this strategy of calling getRange() only works as long as + * seq chunk sizes are a multiple of block sizes + * or in other words for a given block there is only one chunk that overlaps it + * (otherwise in the callback would need to fiddle with horizontal position of seqNode within the block) ??? + */ + fillBlock: function( args ) { + var blockIndex = args.blockIndex; + var block = args.block; + var leftBase = args.leftBase; + var rightBase = args.rightBase; + var scale = args.scale; + var containerStart = args.containerStart; + var containerEnd = args.containerEnd; + + var verbose = false; + // test block for diagnostics + // var verbose = (leftBase === 245524); + + var fillArgs = arguments; + var track = this; + + var finishCallback = args.finishCallback; + args.finishCallback = function() { + finishCallback(); + track.stopStandby(); + }; + +// var charSize = this.getCharacterMeasurements(); + var charSize = this.webapollo.getSequenceCharacterSize(); + if ((scale == charSize.width) || + this.ALWAYS_SHOW || (this.SHOW_IF_FEATURES && this.featureCount > 0) ) { + this.show(); + } else { + this.hide(); + this.heightUpdate(0); + } + var blockHeight = 0; + + if (this.shown) { + // make a div to contain the sequences + var seqNode = document.createElement("div"); + seqNode.className = "wa-sequence"; + // seq_block_container style sets width = 100%, so seqNode fills the block width + // regardless of whether holding residue divs or not + $(seqNode).addClass("block-seq-container"); + block.domNode.appendChild(seqNode); + + var slength = rightBase - leftBase; + + // just always add two base pairs to front and end, + // to make sure can do three-frame translation across for every base position in (leftBase..rightBase), + // both forward (need tw pairs on end) and reverse (need 2 extra bases at start) + var leftExtended = leftBase - 2; + var rightExtended = rightBase + 2; + + var dnaHeight = charSize.height; + var proteinHeight = charSize.height; + + if ( scale == charSize.width ) { + // this.sequenceStore.getRange( this.refSeq, leftBase, rightBase, + // this.sequenceStore.getRange( this.refSeq, leftBase, endBase, + // this.store.getFeatures( + this.sequenceStore.getFeatures( + { ref: this.refSeq.name, start: leftExtended, end: rightExtended }, + function( feat ) { + var start = feat.get('start'); + var end = feat.get('end'); + var seq = feat.get('seq'); + + // fill with leading blanks if the + // sequence does not extend all the way + // across our range + for( ; start < 0; start++ ) { + seq = SequenceTrack.nbsp + seq; //nbsp is an " " entity + } + + var blockStart = start + 2; + var blockEnd = end - 2; + var blockResidues = seq.substring(2, seq.length-2); + var blockLength = blockResidues.length; + var extendedStart = start; + var extendedEnd = end; + var extendedStartResidues = seq.substring(0, seq.length-2); + var extendedEndResidues = seq.substring(2); + + if (verbose) { + console.log("seq: " + seq + ", length: " + seq.length); + console.log("blockResidues: " + blockResidues + ", length: " + blockResidues.length); + console.log("extendedStartResidues: " + extendedStartResidues + ", length: " + extendedStartResidues.length); + console.log("extendedEndResidues: " + extendedEndResidues + ", length: " + extendedEndResidues.length); + } + + if (track.show_protein_translation) { + var framedivs = []; + for (var i=0; i<3; i++) { + // var tstart = start + i; + var tstart = blockStart + i; + var frame = tstart % 3; + if (verbose) { console.log(" forward translating: offset = " + i + ", frame = " + frame); } + var transProtein = track.renderTranslation( extendedEndResidues, i, blockLength); + // if coloring CDS in feature tracks by frame, use same "cds-frame" styling, + // otherwise use more muted "frame" styling + if (track.webapollo.colorCdsByFrame) { + $(transProtein).addClass("cds-frame" + frame); + } + else { + $(transProtein).addClass("frame" + frame); + } + framedivs[frame] = transProtein; + } + for (var i=2; i>=0; i--) { + var transProtein = framedivs[i]; + seqNode.appendChild(transProtein); + $(transProtein).bind("mousedown", track.residuesMouseDown); + blockHeight += proteinHeight; + } + } + + /* + var dnaContainer = document.createElement("div"); + $(dnaContainer).addClass("dna-container"); + seqNode.appendChild(dnaContainer); + */ + + // add a div for the forward strand + var forwardDNA = track.renderResidues( blockResidues ); + $(forwardDNA).addClass("forward-strand"); + seqNode.appendChild( forwardDNA ); + + + /* could force highlighting on mouseenter in additona to mousemove, + but mousemove seems to always be fired anyway when there's a mouseenter + $(forwardDNA).bind("mouseenter", function(event) { + track.removeTextHighlight(element); + } ); + */ + + + // dnaContainer.appendChild(forwardDNA); + track.residues_context_menu.bindDomNode(forwardDNA); + $(forwardDNA).bind("mousedown", track.residuesMouseDown); + blockHeight += dnaHeight; + + if (track.show_reverse_strand) { + // and one for the reverse strand + // var reverseDNA = track.renderResidues( start, end, track.complement(seq) ); + var reverseDNA = track.renderResidues( track.complement(blockResidues) ); + $(reverseDNA).addClass("reverse-strand"); + seqNode.appendChild( reverseDNA ); + // dnaContainer.appendChild(reverseDNA); + track.residues_context_menu.bindDomNode(reverseDNA); + $(reverseDNA).bind("mousedown", track.residuesMouseDown); + blockHeight += dnaHeight; + } + + // set up highlighting of base pair underneath mouse + $(forwardDNA).bind("mouseleave", function(event) { + track.removeTextHighlight(forwardDNA); + if (reverseDNA) { track.removeTextHighlight(reverseDNA); } + track.last_dna_coord = undefined; + } ); + $(forwardDNA).bind("mousemove", function(event) { + var gcoord = track.getGenomeCoord(event); + if ((!track.last_dna_coord) || (gcoord !== track.last_dna_coord)) { + var blockCoord = gcoord - leftBase; + track.last_dna_coord = gcoord; + track.setTextHighlight(forwardDNA, blockCoord, blockCoord, "dna-highlighted"); + if (!track.freezeHighlightedBases) { + track.lastHighlightedForwardDNA = forwardDNA; + } + if (reverseDNA) { + track.setTextHighlight(reverseDNA, blockCoord, blockCoord, "dna-highlighted"); + if (!track.freezeHighlightedBases) { + track.lastHighlightedReverseDNA = reverseDNA; + } + } + } + } ); + if (reverseDNA) { + $(reverseDNA).bind("mouseleave", function(event) { + track.removeTextHighlight(forwardDNA); + track.removeTextHighlight(reverseDNA); + track.last_dna_coord = undefined; + } ); + $(reverseDNA).bind("mousemove", function(event) { + var gcoord = track.getGenomeCoord(event); + if ((!track.last_dna_coord) || (gcoord !== track.last_dna_coord)) { + var blockCoord = gcoord - leftBase; + track.last_dna_coord = gcoord; + track.setTextHighlight(forwardDNA, blockCoord, blockCoord, "dna-highlighted"); + track.setTextHighlight(reverseDNA, blockCoord, blockCoord, "dna-highlighted"); + if (!track.freezeHighlightedBases) { + track.lastHighlightedForwardDNA = forwardDNA; + track.lastHighlightedReverseDNA = reverseDNA; + } + } + } ); + } + + if (track.show_protein_translation && track.show_reverse_strand) { + var extendedReverseComp = track.reverseComplement(extendedStartResidues); + if (verbose) { console.log("extendedReverseComp: " + extendedReverseComp); } + var framedivs = []; + for (var i=0; i<3; i++) { + var tstart = blockStart + i; + // var frame = tstart % 3; + var frame = (track.refSeq.length - blockEnd + i) % 3; + // frame = (frame + (3 - (track.refSeq.length % 3))) % 3; + frame = (Math.abs(frame - 2) + (track.refSeq.length % 3)) % 3; + var transProtein = track.renderTranslation( extendedStartResidues, i, blockLength, true); + if (track.webapollo.colorCdsByFrame) { + $(transProtein).addClass("cds-frame" + frame); + } + else { + $(transProtein).addClass("frame" + frame); + } + framedivs[frame] = transProtein; + } + // for (var i=2; i>=0; i--) { + for (var i=0; i<3; i++) { + var transProtein = framedivs[i]; + seqNode.appendChild(transProtein); + $(transProtein).bind("mousedown", track.residuesMouseDown); + blockHeight += proteinHeight; + } + } +// DraggableFeatureTrack.prototype.fillBlock.apply(track, fillArgs); +// dojo.hitch ??? + track.inherited("fillBlock", fillArgs); + blockHeight += 5; // a little extra padding below (track.trackPadding used for top padding) + // this.blockHeights[blockIndex] = blockHeight; // shouldn't be necessary, done in track.heightUpdate(); + track.heightUpdate(blockHeight, blockIndex); + }, + function() {} + ); + } + else { + blockHeight = 20; // default dna track height if not zoomed to base level + seqNode.style.height = "20px"; + + // DraggableFeatureTrack.prototype.fillBlock.apply(track, arguments); + track.inherited("fillBlock", arguments); + // this.inherited("fillBlock", arguments); + + // this.blockHeights[blockIndex] = blockHeight; // shouldn't be necessary, done in track.heightUpdate(); + track.heightUpdate(blockHeight, blockIndex); + } + } else { + this.heightUpdate(0, blockIndex); + } + }, + + // heightUpdate: function(height, blockIndex) { + // // console.log("SequenceTrack.heightUpdate: height = " + height + ", bindex = " + blockIndex); + // DraggableFeatureTrack.prototype.heightUpdate.call(this, height, blockIndex); + // }; + + addFeatureToBlock: function( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ) { + var featDiv = + this.renderFeature(feature, uniqueId, block, scale, labelScale, descriptionScale, containerStart, containerEnd); + $(featDiv).addClass("sequence-alteration"); +// var charSize = this.getCharacterMeasurements(); + var charSize = this.webapollo.getSequenceCharacterSize(); + + var seqNode = $("div.wa-sequence", block.domNode).get(0); + featDiv.style.top = "0px"; + var ftype = feature.get("type"); + if (ftype) { + if (ftype == "deletion") { + + } + else if (ftype == "insertion") { + if ( scale == charSize.width ) { + var container = document.createElement("div"); + var residues = feature.get("residues"); + $(container).addClass("dna-residues"); + container.appendChild( document.createTextNode( residues ) ); + container.style.position = "absolute"; + container.style.top = "-16px"; + container.style.border = "2px solid #00CC00"; + container.style.backgroundColor = "#AAFFAA"; + featDiv.appendChild(container); + } + else { + // + } + } + else if ((ftype == "substitution")) { + if ( scale == charSize.width ) { + var container = document.createElement("div"); + var residues = feature.get("residues"); + $(container).addClass("dna-residues"); + container.appendChild( document.createTextNode( residues ) ); + container.style.position = "absolute"; + container.style.top = "-16px"; + container.style.border = "1px solid black"; + container.style.backgroundColor = "#FFF506"; + featDiv.appendChild(container); + } + else { + + } + } + } + seqNode.appendChild(featDiv); + return featDiv; + }, + + /** + * overriding renderFeature to add event handling right-click context menu + */ + renderFeature: function( feature, uniqueId, block, scale, labelScale, descriptionScale, + containerStart, containerEnd ) { + // var track = this; + // var featDiv = DraggableFeatureTrack.prototype.renderFeature.call(this, feature, uniqueId, block, scale, + + var featDiv = this.inherited( arguments ); + + if (featDiv && featDiv != null) { + this.annot_context_menu.bindDomNode(featDiv); + } + return featDiv; + }, + + reverseComplement: function(seq) { + return this.reverse(this.complement(seq)); + }, + + reverse: function(seq) { + return seq.split("").reverse().join(""); + }, + + complement: (function() { + var compl_rx = /[ACGT]/gi; + + // from bioperl: tr/acgtrymkswhbvdnxACGTRYMKSWHBVDNX/tgcayrkmswdvbhnxTGCAYRKMSWDVBHNX/ + // generated with: + // perl -MJSON -E '@l = split "","acgtrymkswhbvdnxACGTRYMKSWHBVDNX"; print to_json({ map { my $in = $_; tr/acgtrymkswhbvdnxACGTRYMKSWHBVDNX/tgcayrkmswdvbhnxTGCAYRKMSWDVBHNX/; $in => $_ } @l})' + var compl_tbl = {"S":"S","w":"w","T":"A","r":"y","a":"t","N":"N","K":"M","x":"x","d":"h","Y":"R","V":"B","y":"r","M":"K","h":"d","k":"m","C":"G","g":"c","t":"a","A":"T","n":"n","W":"W","X":"X","m":"k","v":"b","B":"V","s":"s","H":"D","c":"g","D":"H","b":"v","R":"Y","G":"C"}; + + var compl_func = function(m) { return compl_tbl[m] || SequenceTrack.nbsp; }; + return function( seq ) { + return seq.replace( compl_rx, compl_func ); + }; + })(), + + //given the start and end coordinates, and the sequence bases, makes a + //div containing the sequence + // SequenceTrack.prototype.renderResidues = function ( start, end, seq ) { + renderResidues: function ( seq ) { + var container = document.createElement("div"); + $(container).addClass("dna-residues"); + container.appendChild( document.createTextNode( seq ) ); + return container; + }, + + /** end is ignored, assume all of seq is translated (except any extra bases at end) */ + renderTranslation: function ( input_seq, offset, blockLength, reverse) { + var CodonTable = this.translationTable; + var verbose = false; + // sequence of diagnostic block + // var verbose = (input_seq === "GTATATTTTGTACGTTAAAAATAAAAA" || input_seq === "GCGTATATTTTGTACGTTAAAAATAAA" ); + var seq; + if (reverse) { + seq = this.reverseComplement(input_seq); + if (verbose) { console.log("revcomped, input: " + input_seq + ", output: " + seq); } + } + else { + seq = input_seq; + } + var container = document.createElement("div"); + $(container).addClass("dna-residues"); + $(container).addClass("aa-residues"); + $(container).addClass("offset" + offset); + var prefix = ""; + var suffix = ""; + for (var i=0; i= 0) { aa = SequenceTrack.nbsp; } + else { aa = "?"; } + } + return prefix + aa + suffix; + // return aa; + } ); + var trimmedAaResidues = aaResidues.substring(0, blockLength); + if (verbose) { console.log("AaLength: " + aaResidues.length + ", trimmedAaLength = " + trimmedAaResidues.length); } + aaResidues = trimmedAaResidues; + if (reverse) { + var revAaResidues = this.reverse(aaResidues); + if (verbose) { console.log("reversing aa string, input: \"" + aaResidues + "\", output: \"" + revAaResidues + "\""); } + aaResidues = revAaResidues; + while (aaResidues.length < blockLength) { + aaResidues = SequenceTrack.nbsp + aaResidues; + } + } + container.appendChild( document.createTextNode( aaResidues ) ); + return container; + }, + + onResiduesMouseDown: function(event) { + this.last_mousedown_event = event; + }, + + onFeatureMouseDown: function(event) { + // _not_ calling DraggableFeatureTrack.prototyp.onFeatureMouseDown -- + // don't want to allow dragging (at least not yet) + // event.stopPropagation(); + this.last_mousedown_event = event; + var ftrack = this; + if (ftrack.verbose_selection || ftrack.verbose_drag) { + console.log("SequenceTrack.onFeatureMouseDown called"); + } + this.handleFeatureSelection(event); + }, + + initContextMenu: function() { + var thisObj = this; + thisObj.contextMenuItems = new Array(); + thisObj.annot_context_menu = new dijit.Menu({}); + + var index = 0; + if (this.annotTrack.permission & Permission.WRITE) { + thisObj.annot_context_menu.addChild(new dijit.MenuItem( { + label: "Delete", + onClick: function() { + thisObj.deleteSelectedFeatures(); + } + } )); + thisObj.contextMenuItems["delete"] = index++; + } + thisObj.annot_context_menu.addChild(new dijit.MenuItem( { + label: "Information", + onClick: function(event) { + thisObj.getInformation(); + } + } )); + thisObj.contextMenuItems["information"] = index++; + + thisObj.annot_context_menu.onOpen = function(event) { + // keeping track of mousedown event that triggered annot_context_menu popup, + // because need mouse position of that event for some actions + thisObj.annot_context_mousedown = thisObj.last_mousedown_event; + // if (thisObj.permission & Permission.WRITE) { thisObj.updateMenu(); } + dojo.forEach(this.getChildren(), function(item, idx, arr) { + if (item._setSelected) { item._setSelected(false); } // test for function existence first + if (item._onUnhover) { item._onUnhover(); } // test for function existence first + }); + }; + + /** + * context menu for right click on sequence residues + */ + thisObj.residuesMenuItems = new Array(); + thisObj.residues_context_menu = new dijit.Menu({}); + index = 0; + + thisObj.residuesMenuItems["toggle_reverse_strand"] = index++; + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "Toggle Reverse Strand", + onClick: function(event) { + thisObj.show_reverse_strand = ! thisObj.show_reverse_strand; + thisObj.clearHighlightedBases(); + // thisObj.hideAll(); shouldn't need to call hideAll() before changed() anymore + thisObj.changed(); + // thisObj.toggleReverseStrand(); + } + } )); + + thisObj.residuesMenuItems["toggle_protein_translation"] = index++; + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "Toggle Protein Translation", + onClick: function(event) { + thisObj.show_protein_translation = ! thisObj.show_protein_translation; + thisObj.clearHighlightedBases(); + // thisObj.hideAll(); shouldn't need to call hideAll() before changed() anymore + thisObj.changed(); + // thisObj.toggleProteinTranslation(); + } + } )); + + + if (this.annotTrack.permission & Permission.WRITE) { + + thisObj.residues_context_menu.addChild(new dijit.MenuSeparator()); + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "Create Genomic Insertion", + onClick: function() { + thisObj.freezeHighlightedBases = true; + thisObj.createGenomicInsertion(); + } + } )); + thisObj.residuesMenuItems["create_insertion"] = index++; + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "Create Genomic Deletion", + onClick: function(event) { + thisObj.freezeHighlightedBases = true; + thisObj.createGenomicDeletion(); + } + } )); + thisObj.residuesMenuItems["create_deletion"] = index++; + + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "Create Genomic Substitution", + onClick: function(event) { + thisObj.freezeHighlightedBases = true; + thisObj.createGenomicSubstitution(); + } + } )); + thisObj.residuesMenuItems["create_substitution"] = index++; + } + /* + thisObj.residues_context_menu.addChild(new dijit.MenuItem( { + label: "..." + } + )); + */ + + thisObj.residues_context_menu.onOpen = function(event) { + // keeping track of mousedown event that triggered residues_context_menu popup, + // because need mouse position of that event for some actions + thisObj.residues_context_mousedown = thisObj.last_mousedown_event; + // if (thisObj.permission & Permission.WRITE) { thisObj.updateMenu() } + dojo.forEach(this.getChildren(), function(item, idx, arr) { + if (item._setSelected) { item._setSelected(false); } + if (item._onUnhover) { item._onUnhover(); } + }); + + thisObj.freezeHighlightedBases = true; + + }; + + thisObj.residues_context_menu.onBlur = function() { + thisObj.freezeHighlightedBases = false; + }; + + thisObj.residues_context_menu.onClose = function(event) { + if (!thisObj.freezeHighlightedBases) { + thisObj.clearHighlightedBases(); + } + }; + + thisObj.annot_context_menu.startup(); + thisObj.residues_context_menu.startup(); + }, + + getUniqueTrackName: function() { + return this.name + "-" + this.refSeq.name; + }, + + createGenomicInsertion: function() { + var gcoord = this.getGenomeCoord(this.residues_context_mousedown); + + var content = this.createAddSequenceAlterationPanel("insertion", gcoord); + this.annotTrack.openDialog("Add Insertion", content); + + /* + var track = this; + var features = '[ { "uniquename": "insertion-' + gcoord + '","location": { "fmin": ' + gcoord + ', "fmax": ' + gcoord + ', "strand": 1 }, "residues": "A", "type": {"name": "insertion", "cv": { "name":"SO" } } } ]'; + dojo.xhrPost( { + postData: '{ "track": "' + track.annotTrack.getUniqueTrackName() + '", "features": ' + features + ', "operation": "add_sequence_alteration" }', + url: context_path + "/AnnotationEditorService", + handleAs: "json", + timeout: 5000, // Time in milliseconds + // The LOAD function will be called on a successful response. + load: function(response, ioArgs) { + }, + // The ERROR function will be called in an error case. + error: function(response, ioArgs) { // + return response; + } + }); + */ + + }, + + createGenomicDeletion: function() { + var gcoord = this.getGenomeCoord(this.residues_context_mousedown); + + var content = this.createAddSequenceAlterationPanel("deletion", gcoord); + this.annotTrack.openDialog("Add Deletion", content); + + }, + + createGenomicSubstitution: function() { + var gcoord = this.getGenomeCoord(this.residues_context_mousedown); + var content = this.createAddSequenceAlterationPanel("substitution", gcoord); + this.annotTrack.openDialog("Add Substitution", content); + }, + + deleteSelectedFeatures: function() { + var selected = this.selectionManager.getSelection(); + this.selectionManager.clearSelection(); + this.requestDeletion(selected); + }, + + requestDeletion: function(selected) { + var track = this; + var features = "[ "; + for (var i = 0; i < selected.length; ++i) { + var annot = selected[i].feature; + if (i > 0) { + features += ", "; + } + features += '{ "uniquename": "' + annot.id() + '" }'; + } + features += "]"; + var postData = '{ "track": "' + track.annotTrack.getUniqueTrackName() + '", "features": ' + features + ', "operation": "delete_sequence_alteration" }'; + track.annotTrack.executeUpdateOperation(postData); + }, + + getInformation: function() { + var selected = this.selectionManager.getSelection(); + var annotTrack = this.getAnnotTrack(); + if (annotTrack) { + annotTrack.getInformationForSelectedAnnotations(selected); + } + }, + + /** + * sequence alteration annotation ADD command received by a ChangeNotificationListener, + * so telling SequenceTrack to add to it's SeqFeatureStore + */ + annotationsAddedNotification: function(responseFeatures) { + if (this.verbose_server_notification) { console.log("SequenceTrack.annotationsAddedNotification() called"); } + var track = this; + // add to store + for (var i = 0; i < responseFeatures.length; ++i) { + var feat = JSONUtils.createJBrowseSequenceAlteration( responseFeatures[i] ); + var id = responseFeatures[i].uniquename; + if (! this.store.getFeatureById(id)) { + this.store.insert(feat); + } + } + track.featureCount = track.storedFeatureCount(); + if (this.ALWAYS_SHOW || (this.SHOW_IF_FEATURES && this.featureCount > 0)) { + this.show(); + } + else { + this.hide(); + } + // track.hideAll(); shouldn't need to call hideAll() before changed() anymore + track.changed(); + }, + + /** + * sequence alteration annotation DELETE command received by a ChangeNotificationListener, + * so telling SequenceTrack to remove from it's SeqFeatureStore + */ + annotationsDeletedNotification: function(annots) { + if (this.verbose_server_notification) { console.log("SequenceTrack.removeSequenceAlterations() called"); } + var track = this; + // remove from SeqFeatureStore + for (var i = 0; i < annots.length; ++i) { + var id_to_delete = annots[i].uniquename; + this.store.deleteFeatureById(id_to_delete); + } + track.featureCount = track.storedFeatureCount(); + if (this.ALWAYS_SHOW || (this.SHOW_IF_FEATURES && this.featureCount > 0)) { + this.show(); + } + else { + this.hide(); + } + // track.hideAll(); shouldn't need to call hideAll() before changed() anymore + track.changed(); + }, + + /* + * sequence alteration UPDATE command received by a ChangeNotificationListener + * currently handled as if receiving DELETE followed by ADD command + */ + annotationsUpdatedNotification: function(annots) { + this.annotationsDeletedNotification(annots); + this.annotationAddedNotification(annots); + }, + + storedFeatureCount: function(start, end) { + // get accurate count of features loaded (should do this within the XHR.load() function + var track = this; + if (start == undefined) { + // start = 0; + start = track.refSeq.start; + } + if (end == undefined) { + // end = track.refSeq.length; + end = track.refSeq.end; + } + var count = 0; + track.store.getFeatures({ ref: track.refSeq.name, start: start, end: end}, function() { count++; }); + + return count; + }, + + createAddSequenceAlterationPanel: function(type, gcoord) { + var track = this; + var content = dojo.create("div"); + var charWidth = 15; + if (type == "deletion") { + var deleteDiv = dojo.create("div", { }, content); + var deleteLabel = dojo.create("label", { innerHTML: "Length", className: "sequence_alteration_input_label" }, deleteDiv); + var deleteField = dojo.create("input", { type: "text", size: 10, className: "sequence_alteration_input_field" }, deleteDiv); + + $(deleteField).keydown(function(e) { + var unicode = e.charCode || e.keyCode; + var isBackspace = (unicode == 8); // 8 = BACKSPACE + if (unicode == 13) { // 13 = ENTER/RETURN + addSequenceAlteration(); + } + else { + var newchar = String.fromCharCode(unicode); + // only allow numeric chars and backspace + if (! (newchar.match(/[0-9]/) || isBackspace)) { + return false; + } + } + }); + } + else { + var plusDiv = dojo.create("div", { }, content); + var minusDiv = dojo.create("div", { }, content); + var plusLabel = dojo.create("label", { innerHTML: "+ strand", className: "sequence_alteration_input_label" }, plusDiv); + var plusField = dojo.create("input", { type: "text", size: charWidth, className: "sequence_alteration_input_field" }, plusDiv); + var minusLabel = dojo.create("label", { innerHTML: "- strand", className: "sequence_alteration_input_label" }, minusDiv); + var minusField = dojo.create("input", { type: "text", size: charWidth, className: "sequence_alteration_input_field" }, minusDiv); + // not sure why, but dojo.connect doesn't work well here?? (at least in Chrome) + // dojo.connect(inputField, "keypress", null, function(e) { + // and JQuery keypress almost works, but doesn't register backspace events + // $(inputField).keypress(function(e) { + // apparently keypress in general doesn't report for some non-character keys: + // http://stackoverflow.com/questions/3911589/why-doesnt-keypress-handle-the-delete-key-and-the-backspace-key + // but jquery keydown seems to work + $(plusField).keydown(function(e) { + var unicode = e.charCode || e.keyCode; + // ignoring delete key, doesn't do anything in input elements? + var isBackspace = (unicode == 8); // 8 = BACKSPACE + if (unicode == 13) { // 13 = ENTER/RETURN + addSequenceAlteration(); + } + else { + var curval = e.srcElement.value; + var newchar = String.fromCharCode(unicode); + // only allow acgtnACGTN and backspace + // (and acgtn are transformed to uppercase in CSS) + if (newchar.match(/[acgtnACGTN]/) || isBackspace) { + // can't synchronize scroll position of two input elements, + // see http://stackoverflow.com/questions/10197194/keep-text-input-scrolling-synchronized + // but, if scrolling triggered (or potentially triggered), can hide other strand input element + // scrolling only triggered when length of input text exceeds character size of input element + if (isBackspace) { + minusField.value = track.complement(curval.substring(0,curval.length-1)); + } + else { + minusField.value = track.complement(curval + newchar); + } + if (curval.length > charWidth) { + $(minusDiv).hide(); + } + else { + $(minusDiv).show(); // make sure is showing to bring back from a hide + } + } + else { return false; } // prevents entering any chars other than ACGTN and backspace + } + }); + + $(minusField).keydown(function(e) { + var unicode = e.charCode || e.keyCode; + // ignoring delete key, doesn't do anything in input elements? + var isBackspace = (unicode == 8); // 8 = BACKSPACE + if (unicode == 13) { // 13 = ENTER + addSequenceAlteration(); + } + else { + var curval = e.srcElement.value; + var newchar = String.fromCharCode(unicode); + // only allow acgtnACGTN and backspace + // (and acgtn are transformed to uppercase in CSS) + if (newchar.match(/[acgtnACGTN]/) || isBackspace) { + // can't synchronize scroll position of two input elements, + // see http://stackoverflow.com/questions/10197194/keep-text-input-scrolling-synchronized + // but, if scrolling triggered (or potentially triggered), can hide other strand input element + // scrolling only triggered when length of input text exceeds character size of input element + if (isBackspace) { + plusField.value = track.complement(curval.substring(0,curval.length-1)); + } + else { + plusField.value = track.complement(curval + newchar); + } + if (curval.length > charWidth) { + $(plusDiv).hide(); + } + else { + $(plusDiv).show(); // make sure is showing to bring back from a hide + } + } + else { return false; } // prevents entering any chars other than ACGTN and backspace + } + }); + + } + var buttonDiv = dojo.create("div", { className: "sequence_alteration_button_div" }, content); + var addButton = dojo.create("button", { innerHTML: "Add", className: "sequence_alteration_button" }, buttonDiv); + + var addSequenceAlteration = function() { + var ok = true; + var inputField; + var inputField = ((type == "deletion") ? deleteField : plusField); + // if (type == "deletion") { inputField = deleteField; } + // else { inputField = plusField; } + var input = inputField.value.toUpperCase(); + if (input.length == 0) { + alert("Input cannot be empty for " + type); + ok = false; + } + if (ok) { + var input = inputField.value.toUpperCase(); + if (type == "deletion") { + if (input.match(/\D/)) { + alert("The length must be a number"); + ok = false; + } + else { + input = parseInt(input); + if (input <= 0) { + alert("The length must be a positive number"); + ok = false; + } + } + } + else { + if (input.match(/[^ACGTN]/)) { + alert("The sequence should only containg A, C, G, T, N"); + ok = false; + } + } + } + if (ok) { + var fmin = gcoord; + var fmax; + if (type == "insertion") { + fmax = gcoord; + } + else if (type == "deletion") { + fmax = gcoord + parseInt(input); + } + else if (type == "substitution") { + fmax = gcoord + input.length;; + } + if (track.storedFeatureCount(fmin, fmax == fmin ? fmin + 1 : fmax) > 0) { + alert("Cannot create overlapping sequence alterations"); + } + else { + var feature = '"location": { "fmin": ' + fmin + ', "fmax": ' + fmax + ', "strand": 1 }, "type": {"name": "' + type + '", "cv": { "name":"sequence" } }'; + if (type != "deletion") { + feature += ', "residues": "' + input + '"'; + } + var features = '[ { ' + feature + ' } ]'; + var postData = '{ "track": "' + track.annotTrack.getUniqueTrackName() + '", "features": ' + features + ', "operation": "add_sequence_alteration" }'; + track.annotTrack.executeUpdateOperation(postData); + track.annotTrack.popupDialog.hide(); + } + } + }; + + dojo.connect(addButton, "onclick", null, function() { + addSequenceAlteration(); + }); + + return content; + }, + + handleError: function(response) { + console.log("ERROR: "); + console.log(response); // in Firebug, allows retrieval of stack trace, jump to code, etc. + console.log(response.stack); + var error = eval('(' + response.responseText + ')'); + if (error && error.error) { + alert(error.error); + return; + } + }, + + setAnnotTrack: function(annotTrack) { + this.startStandby(); + // if (this.annotTrack) { console.log("WARNING: SequenceTrack.setAnnotTrack called but annoTrack already set"); } + var track = this; + + this.annotTrack = annotTrack; + this.initContextMenu(); + + this.loadTranslationTable().then( + function() { + track.loadSequenceAlterations().then(function() { + track.stopStandby(); + }); + }, + function() { + track.stopStandby(); + }); + }, + + /* + * Given an element that contains text, highlights a given range of the text + * If called repeatedly, removes highlighting from previous call first + * + * Assumes there is no additional markup within element, just a text node + * (would like to eventually rewrite to remove this restriction? Possibly could use HTML Range manipulation, + * i.e. range.surroundContents() etc. ) + * + * optionally specify what class to use to indicate highlighting (defaults to "text-highlight") + * + * adapted from http://stackoverflow.com/questions/9051369/highlight-substring-in-element + */ + setTextHighlight: function (element, start, end, classname) { + if (this.freezeHighlightedBases) { + return; + } + if (! classname) { classname = "text-highlight"; } + var item = $(element); + var str = item.data("origHTML"); + if (!str) { + str = item.html(); + item.data("origHTML", str); + } + str = str.substr(0, start) + + '' + + str.substr(start, end - start + 1) + + '' + + str.substr(end + 1); + item.html(str); + }, + + /* + * remove highlighting added with setTextHighlight + */ + removeTextHighlight: function(element) { + if (this.freezeHighlightedBases) { + return; + } + var item = $(element); + var str = item.data("origHTML"); + if (str) { + item.html(str); + } + }, + + clearHighlightedBases: function() { + this.freezeHighlightedBases = false; + this.removeTextHighlight(this.lastHighlightedForwardDNA); + if (this.lastHighlightedReverseDNA) { + this.removeTextHighlight(this.lastHighlightedReverseDNA); + } + }, + + getAnnotTrack: function() { + if (this.annotTrack) { + return this.annotTrack; + } + else { + var tracks = this.gview.tracks; + for (var i = 0; i < tracks.length; i++) { + // should be doing instanceof here, but class setup is not being cooperative + if (tracks[i].isWebApolloAnnotTrack) { + this.annotTrack = tracks[i]; + this.annotTrack.seqTrack = this; + break; + } + } + } + return this.annotTrack; + } + +}); + + SequenceTrack.nbsp = String.fromCharCode(160); + return SequenceTrack; +}); + +/* + * highlightText is nice, + * what would be _really_ good is a residue highlighter that works in genome coords, and + * does highlights across all blocks that overlap genome coord range + * NOT YET IMPLEMENTED + */ + /*SequenceTrack.prototype.highlightResidues = function(genomeStart, genomeEnd) { +} +*/ + +/* + * More efficient form + * residues_class is CSS class of residues: forward, reverse, frame0, frame1, frame2, frameMinus1, frameMinus2, frameMinus3 + * highlight_class is CSS class for the highlighted span + * ranges is an ordered array (min to max) of ranges, each range is itself an array of form [start, end] in genome coords + * ranges MUST NOT overlap + * + * assumes: + * ranges do not overlap + * any previous highlighting is removed (revert to raw residues before applying new highlighting) + * + * + * In implementation can insert span elements in reverse order, so that indexing into string is always accurate (not tripped up by span elements inserted farther upstream) + * will need to clamp to bounds of each block + */ +/*SequenceTrack.prototype.highlightResidues = function(highlight_class, residues_class, ranges) */ diff --git a/www/JBrowse/View/Track/VariantDetailMixin.js b/www/JBrowse/View/Track/VariantDetailMixin.js new file mode 100644 index 00000000..9b0e41dd --- /dev/null +++ b/www/JBrowse/View/Track/VariantDetailMixin.js @@ -0,0 +1,194 @@ +/** + * Mixin to provide a `defaultFeatureDetail` method that is optimized + * for displaying variant data from VCF files. + */ + +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'dojo/dom-construct', + 'dojo/dom-class', + 'JBrowse/Util', + 'JBrowse/View/Track/FeatureDetailMixin', + 'JBrowse/Model/NestedFrequencyTable' + ], + function( + declare, + array, + lang, + domConstruct, + domClass, + Util, + FeatureDetailMixin, + NestedFrequencyTable + ) { + +return declare( FeatureDetailMixin, { + + + defaultFeatureDetail: function( /** JBrowse.Track */ track, /** Object */ f, /** HTMLElement */ featDiv, /** HTMLElement */ container ) { + container = container || dojo.create('div', { className: 'detail feature-detail feature-detail-'+track.name, innerHTML: '' } ); + + this._renderCoreDetails( track, f, featDiv, container ); + + this._renderAdditionalTagsDetail( track, f, featDiv, container ); + + // genotypes in a separate section + this._renderGenotypes( container, track, f, featDiv ); + + return container; + }, + + _isReservedTag: function( t ) { + return this.inherited(arguments) || {genotypes:1}[t.toLowerCase()]; + }, + + _renderGenotypes: function( parentElement, track, f, featDiv ) { + var thisB = this; + var genotypes = f.get('genotypes'); + if( ! genotypes ) + return; + + var keys = Util.dojof.keys( genotypes ).sort(); + var gCount = keys.length; + if( ! gCount ) + return; + + // get variants and coerce to an array + var alt = f.get('alternative_alleles'); + if( alt && typeof alt == 'object' && 'values' in alt ) + alt = alt.values; + if( alt && ! lang.isArray( alt ) ) + alt = [alt]; + + var gContainer = domConstruct.create( + 'div', + { className: 'genotypes', + innerHTML: '

Genotypes (' + + gCount + ')

' + }, + parentElement ); + + var summaryElement = this._renderGenotypeSummary( gContainer, genotypes, alt ); + + var valueContainer = domConstruct.create( + 'div', + { + className: 'value_container genotypes' + }, gContainer ); + + this.renderDetailValueGrid( + valueContainer, + 'Genotypes', + // iterator + function() { + if( ! keys.length ) + return null; + var k = keys.shift(); + var value = genotypes[k]; + var item = { id: k }; + for( var field in value ) { + item[ field ] = thisB._genotypeValToString( value[field], field, alt ); + } + return item; + }, + // descriptions object + (function() { + if( ! keys.length ) + return {}; + + var subValue = genotypes[keys[0]]; + var descriptions = {}; + for( var k in subValue ) { + descriptions[k] = subValue[k].meta && subValue[k].meta.description || null; + } + return descriptions; + })() + ); + }, + + _genotypeValToString: function( value, fieldname, alt ) { + value = this._valToString( value ); + if( fieldname != 'GT' ) + return value; + + // handle the GT field specially, translating the genotype indexes into the actual ALT strings + var splitter = value.match(/\D/g)[0]; + return array.map( value.split( splitter ), function( gtIndex ) { + gtIndex = parseInt( gtIndex ); + return gtIndex ? alt ? alt[gtIndex-1] : gtIndex : 'ref'; + }).join( ' '+splitter+' ' ); + }, + + _renderGenotypeSummary: function( parentElement, genotypes, alt ) { + if( ! genotypes ) + return; + + var counts = new NestedFrequencyTable(); + for( var gname in genotypes ) { + if( genotypes.hasOwnProperty( gname ) ) { + // increment the appropriate count + var gt = genotypes[gname].GT; + if( typeof gt == 'object' && 'values' in gt ) + gt = gt.values[0]; + if( typeof gt == 'string' ) + gt = gt.split(/\||\//); + + if( lang.isArray( gt ) ) { + // if all zero, non-variant/hom-ref + if( array.every( gt, function( g ) { return parseInt(g) == 0; }) ) { + counts.getNested('non-variant').increment('homozygous for reference'); + } + else if( array.every( gt, function( g ) { return g == '.'; }) ) { + counts.getNested('non-variant').increment('no call'); + } + else if( array.every( gt, function( g ) { return g == gt[0]; } ) ) { + if( alt ) + counts.getNested('variant/homozygous').increment( alt[ parseInt(gt[0])-1 ] + ' variant' ); + else + counts.getNested('variant').increment( 'homozygous' ); + } + else { + counts.getNested('variant').increment('heterozygous'); + } + } + } + } + + var total = counts.total(); + if( ! total ) + return; + + var valueContainer = domConstruct.create( + 'div', { className: 'value_container big genotype_summary' }, + parentElement ); + //domConstruct.create('h3', { innerHTML: 'Summary' }, valueContainer); + + var tableElement = domConstruct.create('table', {}, valueContainer ); + + function renderFreqTable( table, level ) { + table.forEach( function( count, categoryName ) { + var tr = domConstruct.create( 'tr', {}, tableElement ); + domConstruct.create('td', { className: 'category level_'+level, innerHTML: categoryName }, tr ); + if( typeof count == 'object' ) { + var thisTotal = count.total(); + domConstruct.create('td', { className: 'count level_'+level, innerHTML: thisTotal }, tr ); + domConstruct.create('td', { className: 'pct level_'+level, innerHTML: Math.round(thisTotal/total*10000)/100 + '%' }, tr ); + renderFreqTable( count, level+1 ); + } else { + domConstruct.create('td', { className: 'count level_'+level, innerHTML: count }, tr ); + domConstruct.create('td', { className: 'pct level_'+level, innerHTML: Math.round(count/total*10000)/100+'%' }, tr ); + } + }); + } + + renderFreqTable( counts, 0 ); + + var totalTR = domConstruct.create('tr',{},tableElement ); + domConstruct.create('td', { className: 'category total', innerHTML: 'Total' }, totalTR ); + domConstruct.create('td', { className: 'count total', innerHTML: total }, totalTR ); + domConstruct.create('td', { className: 'pct total', innerHTML: '100%' }, totalTR ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Wiggle.js b/www/JBrowse/View/Track/Wiggle.js new file mode 100644 index 00000000..899f9f4f --- /dev/null +++ b/www/JBrowse/View/Track/Wiggle.js @@ -0,0 +1,6 @@ +define( [ + 'JBrowse/View/Track/Wiggle/XYPlot' + ], + function( xyplot ) { +return xyplot; +}); diff --git a/www/JBrowse/View/Track/Wiggle/Density.js b/www/JBrowse/View/Track/Wiggle/Density.js new file mode 100644 index 00000000..ba407d75 --- /dev/null +++ b/www/JBrowse/View/Track/Wiggle/Density.js @@ -0,0 +1,111 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/Color', + 'JBrowse/View/Track/WiggleBase', + 'JBrowse/Util' + ], + function( declare, array, Color, WiggleBase, Util ) { + +return declare( WiggleBase, + +/** + * Wiggle track that shows data with variations in color. + * + * @lends JBrowse.View.Track.Wiggle.Density + * @extends JBrowse.View.Track.WiggleBase + */ + +{ + + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + maxExportSpan: 500000, + style: { + height: 31, + pos_color: '#00f', + neg_color: '#f00', + bg_color: 'rgba(230,230,230,0.6)', + clip_marker_color: 'black' + } + } + ); + }, + + _drawFeatures: function( scale, leftBase, rightBase, block, canvas, pixels, dataScale ) { + var thisB = this; + var context = canvas.getContext('2d'); + var canvasHeight = canvas.height; + var normalize = dataScale.normalize; + + var featureColor = typeof this.config.style.color == 'function' ? this.config.style.color : + (function() { // default color function uses conf variables + var disableClipMarkers = thisB.config.disable_clip_markers; + var normOrigin = normalize( dataScale.origin ); + + return function( p , n) { + var feature = p['feat']; + return ( disableClipMarkers || n <= 1 && n >= 0 ) + // not clipped + ? Color.blendColors( + new Color( thisB.getConfForFeature('style.bg_color', feature ) ), + new Color( thisB.getConfForFeature( n >= normOrigin ? 'style.pos_color' : 'style.neg_color', feature ) ), + Math.abs(n-normOrigin) + ).toString() + // clipped + : ( n > 1 ? thisB.getConfForFeature( 'style.pos_color', feature ) + : thisB.getConfForFeature( 'style.neg_color', feature ) ); + + }; + })(); + + dojo.forEach( pixels, function(p,i) { + if (p) { + var score = p['score']; + var f = p['feat']; + + var n = normalize( score ); + context.fillStyle = ''+featureColor( p, n ); + context.fillRect( i, 0, 1, canvasHeight ); + if( n > 1 ) { // pos clipped + context.fillStyle = thisB.getConfForFeature('style.clip_marker_color', f) || 'red'; + context.fillRect( i, 0, 1, 3 ); + } + else if( n < 0 ) { // neg clipped + context.fillStyle = thisB.getConfForFeature('style.clip_marker_color', f) || 'red'; + context.fillRect( i, canvasHeight-3, 1, 3 ); + } + } + }); + }, + + /* If boolean track, mask accordingly */ + _maskBySpans: function( scale, leftBase, rightBase, block, canvas, pixels, dataScale, spans ) { + var context = canvas.getContext('2d'); + var canvasHeight = canvas.height; + context.fillStyle = this.config.style.mask_color || 'rgba(128,128,128,0.6)'; + this.config.style.mask_color = context.fillStyle; + + for ( var index in spans ) { + if (spans.hasOwnProperty(index)) { + var w = Math.ceil(( spans[index].end - spans[index].start ) * scale ); + var l = Math.round(( spans[index].start - leftBase ) * scale ); + context.fillRect( l, 0, w, canvasHeight ); + context.clearRect( l, 0, w, canvasHeight/3); + context.clearRect( l, (2/3)*canvasHeight, w, canvasHeight/3); + } + } + dojo.forEach( pixels, function(p,i) { + if (!p) { + // if there is no data at a point, erase the mask. + context.clearRect( i, 0, 1, canvasHeight ); + } + }); + }, + + _postDraw: function() {} + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/Wiggle/XYPlot.js b/www/JBrowse/View/Track/Wiggle/XYPlot.js new file mode 100644 index 00000000..ff165686 --- /dev/null +++ b/www/JBrowse/View/Track/Wiggle/XYPlot.js @@ -0,0 +1,242 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/Color', + 'dojo/on', + 'JBrowse/View/Track/WiggleBase', + 'JBrowse/View/Track/YScaleMixin', + 'JBrowse/Util', + './_Scale' + ], + function( declare, array, Color, on, WiggleBase, YScaleMixin, Util, Scale ) { + +var XYPlot = declare( [WiggleBase, YScaleMixin], + +/** + * Wiggle track that shows data with an X-Y plot along the reference. + * + * @lends JBrowse.View.Track.Wiggle.XYPlot + * @extends JBrowse.View.Track.WiggleBase + */ +{ + _defaultConfig: function() { + return Util.deepUpdate( + dojo.clone( this.inherited(arguments) ), + { + style: { + pos_color: 'blue', + neg_color: 'red', + origin_color: '#888', + variance_band_color: 'rgba(0,0,0,0.3)' + } + } + ); + }, + + _getScaling: function( successCallback, errorCallback ) { + + this._getScalingStats( dojo.hitch(this, function( stats ) { + + //calculate the scaling if necessary + if( ! this.lastScaling || ! this.lastScaling.sameStats( stats ) ) { + + var scaling = new Scale( this.config, stats ); + + // bump minDisplayed to 0 if it is within 0.5% of it + if( Math.abs( scaling.min / scaling.max ) < 0.005 ) + scaling.min = 0; + + // update our track y-scale to reflect it + this.makeYScale({ + fixBounds: true, + min: scaling.min, + max: scaling.max + }); + + // and finally adjust the scaling to match the ruler's scale rounding + scaling.min = this.ruler.scaler.bounds.lower; + scaling.max = this.ruler.scaler.bounds.upper; + scaling.range = scaling.max - scaling.min; + + this.lastScaling = scaling; + } + + successCallback( this.lastScaling ); + }), errorCallback ); + }, + + updateStaticElements: function( coords ) { + this.inherited( arguments ); + this.updateYScaleFromViewDimensions( coords ); + }, + + /** + * Draw a set of features on the canvas. + * @private + */ + _drawFeatures: function( scale, leftBase, rightBase, block, canvas, pixels, dataScale ) { + var context = canvas.getContext('2d'); + var canvasHeight = canvas.height; + var toY = dojo.hitch( this, function( val ) { + return canvasHeight * ( 1-dataScale.normalize.call(this, val) ); + }); + var originY = toY( dataScale.origin ); + + var disableClipMarkers = this.config.disable_clip_markers; + + dojo.forEach( pixels, function(p,i) { + if (!p) + return + var score = toY(p['score']); + var f = p['feat']; + + // draw the background color if we are configured to do so + if( score >= 0 ) { + var bgColor = this.getConfForFeature('style.bg_color', f ); + if( bgColor ) { + context.fillStyle = bgColor; + context.fillRect( i, 0, 1, canvasHeight ); + } + } + + + if( score <= canvasHeight || score > originY) { // if the rectangle is visible at all + if( score <= originY ) { + // bar goes upward + context.fillStyle = this.getConfForFeature('style.pos_color',f); + context.fillRect( i, score, 1, originY-score+1); + if( !disableClipMarkers && score < 0 ) { // draw clip marker if necessary + context.fillStyle = this.getConfForFeature('style.clip_marker_color',f) || this.getConfForFeature('style.neg_color',f); + context.fillRect( i, 0, 1, 3 ); + + } + } + else { + // bar goes downward + context.fillStyle = this.getConfForFeature('style.neg_color',f); + context.fillRect( i, originY, 1, score-originY+1 ); + if( !disableClipMarkers && score >= canvasHeight ) { // draw clip marker if necessary + context.fillStyle = this.getConfForFeature('style.clip_marker_color',f) || this.getConfForFeature('style.pos_color',f); + context.fillRect( i, canvasHeight-3, 1, 3 ); + + } + } + } + }, this ); + }, + + _calculatePixelScores: function( canvasWidth, features, featureRects ) { + /* A variant of calculatePixelScores that stores the feature used at each pixel. + * If there are multiple features, use the first one */ + var pixelValues = new Array( canvasWidth ); + dojo.forEach( features, function( f, i ) { + var store = f.source; + var fRect = featureRects[i]; + var jEnd = fRect.r; + var score = f.get('score'); + for( var j = Math.round(fRect.l); j < jEnd; j++ ) { + if ( pixelValues[j] && pixelValues[j]['lastUsedStore'] == store ) { + /* Note: if the feature is from a different store, the condition should fail, + * and we will add to the value, rather than adjusting for overlap */ + pixelValues[j]['score'] = Math.max( pixelValues[j]['score'], score ); + } + else if ( pixelValues[j] ) { + pixelValues[j]['score'] = pixelValues[j]['score'] + score; + pixelValues[j]['lastUsedStore'] = store; + } + else { + pixelValues[j] = { score: score, lastUsedStore: store, feat: f } + } + } + },this); + // when done looping through features, forget the store information. + for (var i=0; i 0 ) { + context.fillText( '+'+label, 2, varTop ); + context.fillText( '-'+label, 2, varTop+varHeight ); + } + else { + context.fillText( label, 2, varTop ); + } + }; + + var maxColor = new Color( this.config.style.variance_band_color ); + var minColor = new Color( this.config.style.variance_band_color ); + minColor.a /= bandPositions.length; + + var bandOpacityStep = 1/bandPositions.length; + var minOpacity = bandOpacityStep; + + array.forEach( bandPositions, function( pos,i ) { + drawVarianceBand( pos*stats.scoreStdDev, + Color.blendColors( minColor, maxColor, (i+1)/bandPositions.length).toCss(true), + pos+'σ'); + }); + drawVarianceBand( 0, 'rgba(255,255,0,0.7)', 'mean' ); + } + })); + } + + // draw the origin line if it is not disabled + var originColor = this.config.style.origin_color; + if( typeof originColor == 'string' && !{'none':1,'off':1,'no':1,'zero':1}[originColor] ) { + var originY = toY( dataScale.origin ); + context.fillStyle = originColor; + context.fillRect( 0, originY, canvas.width, 1 ); + } + + } + +}); + +return XYPlot; +}); diff --git a/www/JBrowse/View/Track/Wiggle/_Scale.js b/www/JBrowse/View/Track/Wiggle/_Scale.js new file mode 100644 index 00000000..1db59959 --- /dev/null +++ b/www/JBrowse/View/Track/Wiggle/_Scale.js @@ -0,0 +1,175 @@ +/** + * The scaling used for drawing a Wiggle track, which is the data's + * origin. + * + * Has numeric attributes range, min, max, origin, and offset. + */ + +define([ + 'JBrowse/Util', + 'JBrowse/Digest/Crc32' + ], + function( Util, Digest ) { +return Util.fastDeclare({ + + // Returns a boolean value saying whether a stats object is needed + // to calculate the scale for the given configuration. + // + // This is invokable either on the class (prototype) or on + // the object itself, so does not use `this` in its implementation. + needStats: function( config ) { + return !( + ( 'min_score' in config ) + && ( 'max_score' in config ) + && ( config.bicolor_pivot != 'z_score' && config.bicolor_pivot != 'mean' ) + && ( config.scale != 'z_score' ) + ); + }, + + constructor: function( config, stats ) { + var needStats = this.needStats( config ); + if( needStats && !stats ) + throw 'No stats object provided, cannot calculate scale'; + + if( needStats && stats.scoreMin == stats.scoreMax ) { + stats = dojo.clone( stats ); + if( stats.scoreMin < 0 ) + stats.scoreMax = 0; + else + stats.scoreMin = 0; + } + + // if either autoscale or scale is set to z_score, the other one should default to z_score + if( config.autoscale == 'z_score' && ! config.scale + || config.scale == 'z_score' && !config.autoscale + ) { + config.scale = 'z_score'; + config.autoscale = 'z_score'; + } + + var z_score_bound = parseFloat( config.z_score_bound ) || 4; + var min = 'min_score' in config ? parseFloat( config.min_score ) : + (function() { + switch( config.autoscale ) { + case 'z_score': + return Math.max( -z_score_bound, (stats.scoreMin-stats.scoreMean) / stats.scoreStdDev ); + case 'global': + case 'local': + return stats.scoreMin; + case 'clipped_global': + default: + return Math.max( stats.scoreMin, stats.scoreMean - z_score_bound * stats.scoreStdDev ); + } + })(); + var max = 'max_score' in config ? parseFloat( config.max_score ) : + (function() { + switch( config.autoscale ) { + case 'z_score': + return Math.min( z_score_bound, (stats.scoreMax-stats.scoreMean) / stats.scoreStdDev ); + case 'global': + case 'local': + return stats.scoreMax; + case 'clipped_global': + default: + return Math.min( stats.scoreMax, stats.scoreMean + z_score_bound * stats.scoreStdDev ); + } + })(); + + if( typeof min != 'number' || isNaN(min) ) { + min = 0; + } + if( typeof max != 'number' || isNaN(max) ) { + max = min + 10; + } + + var offset = parseFloat( config.data_offset ) || 0; + + if( config.scale == 'log' ) { + max = this.log( max + offset ); + min = this.log( min + offset ); + } + else { + max += offset; + min += offset; + } + + var origin = (function() { + if ( 'bicolor_pivot' in config ) { + if ( config.bicolor_pivot == 'mean' ) { + return stats.scoreMean || 0; + } else if ( config.bicolor_pivot == 'zero' ) { + return 0; + } else { + return parseFloat( config.bicolor_pivot ); + } + } else if ( config.scale == 'z_score' ) { + return stats.scoreMean || 0; + } else if ( config.scale == 'log' ) { + return 1; + } else { + return 0; + } + })(); + + dojo.mixin( this, { + offset: offset, + min: min, + max: max, + range: max - min, + origin: origin, + _statsFingerprint: Digest.objectFingerprint( stats ) + }); + + // make this.normalize a func that converts wiggle values to a + // range between 0 and 1, depending on what kind of scale we + // are using + var thisB = this; + this.normalize = (function() { + switch( config.scale ) { + case 'z_score': + return function( value ) { + with(thisB) + return (value+offset-stats.scoreMean) / stats.scoreStdDev-min / range; + }; + case 'log': + return function( value ) { + with(thisB) + return ( thisB.log(value+offset) - min )/range; + }; + case 'linear': + default: + return function( value ) { + with(thisB) + return ( value + offset - min ) / range; + }; + } + })(); + }, + + log: function( value ) { + return value ? Math.log( Math.abs( value ) ) * ( value < 0 ? -1 : 1 ) + : 0; + }, + + /** + * Standard comparison function, compare this scale to another one. + */ + compare: function( b ) { + if( ! b ) return 1; + + var a = this; + return ( a.offset - b.offset ) + || ( a.min - b.min ) + || ( a.max - b.max ) + || ( a.range - b.range ) + || ( a.origin - b.origin ); + }, + + /** + * Return true if this scaling was generated from the same set of stats. + */ + sameStats: function( stats ) { + return this._statsFingerprint == Digest.objectFingerprint( stats ); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/WiggleBase.js b/www/JBrowse/View/Track/WiggleBase.js new file mode 100644 index 00000000..e72301d7 --- /dev/null +++ b/www/JBrowse/View/Track/WiggleBase.js @@ -0,0 +1,440 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/dom-construct', + 'dojo/on', + 'dojo/mouse', + 'JBrowse/View/Track/BlockBased', + 'JBrowse/View/Track/ExportMixin', + 'JBrowse/View/Track/_TrackDetailsStatsMixin', + 'JBrowse/Util', + './Wiggle/_Scale' + ], + function( declare, array, dom, on, mouse, BlockBasedTrack, ExportMixin, DetailStatsMixin, Util, Scale ) { + +return declare( [BlockBasedTrack,ExportMixin, DetailStatsMixin ], { + + constructor: function( args ) { + this.trackPadding = args.trackPadding || 0; + + if( ! ('style' in this.config ) ) { + this.config.style = {}; + } + + this.store = args.store; + }, + + _defaultConfig: function() { + return { + maxExportSpan: 500000, + autoscale: 'global' + }; + }, + + _getScaling: function( successCallback, errorCallback ) { + + this._getScalingStats( dojo.hitch(this, function( stats ) { + + //calculate the scaling if necessary + if( ! this.lastScaling || ! this.lastScaling.sameStats(stats) ) { + try { + this.lastScaling = new Scale( this.config, stats ); + successCallback( this.lastScaling ); + } catch( e ) { + errorCallback(e); + } + } else { + successCallback( this.lastScaling ); + } + + }), errorCallback ); + }, + + // get the statistics to use for scaling, if necessary, either + // from the global stats for the store, or from the local region + // if config.autoscale is 'local' + _getScalingStats: function( callback, errorCallback ) { + if( ! Scale.prototype.needStats( this.config ) ) { + callback( null ); + return null; + } + else if( this.config.autoscale == 'local' ) { + return this.getRegionStats.call( this, this.browser.view.visibleRegion(), callback, errorCallback ); + } + else { + return this.getGlobalStats.apply( this, arguments ); + } + }, + + getFeatures: function( query, callback, errorCallback ) { + this.store.getFeatures.apply( this.store, arguments ); + }, + + getGlobalStats: function( successCallback, errorCallback ) { + this.store.getGlobalStats( successCallback, errorCallback ); + }, + + getRegionStats: function( region, successCallback, errorCallback ) { + this.store.getRegionStats( region, successCallback, errorCallback ); + }, + + // the canvas width in pixels for a block + _canvasWidth: function( block ) { + return Math.ceil(( block.endBase - block.startBase ) * block.scale); + }, + + // the canvas height in pixels for a block + _canvasHeight: function() { + return parseInt(( this.config.style || {}).height) || 100; + }, + + _getBlockFeatures: function( args ) { + var thisB = this; + var blockIndex = args.blockIndex; + var block = args.block; + + var leftBase = args.leftBase; + var rightBase = args.rightBase; + + var scale = args.scale; + var finishCallback = args.finishCallback || function() {}; + + var canvasWidth = this._canvasWidth( args.block ); + + var features = []; + this.getFeatures( + { ref: this.refSeq.name, + basesPerSpan: 1/scale, + scale: scale, + start: leftBase, + end: rightBase+1 + }, + + function(f) { + if( thisB.filterFeature(f) ) + features.push(f); + }, + dojo.hitch( this, function(args) { + + // if the block has been freed in the meantime, + // don't try to render + if( ! (block.domNode && block.domNode.parentNode )) + return; + + var featureRects = array.map( features, function(f) { + return this._featureRect( scale, leftBase, canvasWidth, f ); + }, this ); + + block.features = features; //< TODO: remove this + block.featureRects = featureRects; + block.pixelScores = this._calculatePixelScores( this._canvasWidth(block), features, featureRects ); + + if (args && args.maskingSpans) + block.maskingSpans = args.maskingSpans; // used for masking + + finishCallback(); + }), + dojo.hitch( this, function(e) { + this._handleError( e, args ); + })); + }, + + // render the actual graph display for the block. should be called only after a scaling + // has been decided upon and stored in this.scaling + renderBlock: function( args ) { + var block = args.block; + + // don't render this block again if we have already rendered + // it with this scaling scheme + if( ! this.scaling.compare( block.scaling ) || ! block.pixelScores ) + return; + + + + block.scaling = this.scaling; + + dom.empty( block.domNode ); + + try { + dojo.create('canvas').getContext('2d').fillStyle = 'red'; + } catch( e ) { + this.fatalError = 'This browser does not support HTML canvas elements.'; + this.fillBlockError( blockIndex, block, this.fatalError ); + return; + } + + var features = block.features; + var featureRects = block.featureRects; + var dataScale = this.scaling; + var canvasHeight = this._canvasHeight(); + + var c = dojo.create( + 'canvas', + { height: canvasHeight, + width: this._canvasWidth(block), + style: { + cursor: 'default', + width: "100%", + height: canvasHeight + "px" + }, + innerHTML: 'Your web browser cannot display this type of track.', + className: 'canvas-track' + }, + block.domNode + ); + c.startBase = block.startBase; + + //Calculate the score for each pixel in the block + var pixels = this._calculatePixelScores( c.width, features, featureRects ); + + this._draw( block.scale, block.startBase, + block.endBase, block, + c, features, + featureRects, dataScale, + pixels, block.maskingSpans ); // note: spans may be undefined. + + this._makeScoreDisplay( args.scale, args.leftBase, args.rightBase, block, c, features, featureRects, pixels ); + + this.heightUpdate( c.height, args.blockIndex ); + if( !( c.parentNode && c.parentNode.parentNode )) { + var blockWidth = block.endBase - block.startBase; + + c.style.position = "absolute"; + c.style.left = (100 * ((c.startBase - block.startBase) / blockWidth)) + "%"; + switch (this.config.align) { + case "top": + c.style.top = "0px"; + break; + case "bottom": + default: + c.style.bottom = this.trackPadding + "px"; + break; + } + } + }, + + fillBlock: function( args ) { + var thisB = this; + this.heightUpdate( this._canvasHeight(), args.blockIndex ); + + // hook updateGraphs onto the end of the block feature fetch + var oldFinish = args.finishCallback || function() {}; + args.finishCallback = function() { + thisB.updateGraphs( oldFinish ); + }; + + // get the features for this block, and then set in motion the + // updating of the graphs + this._getBlockFeatures( args ); + }, + + updateGraphs: function( callback ) { + var thisB = this; + + // update the global scaling + this._getScaling( function( scaling ) { + thisB.scaling = scaling; + // render all of the blocks that need it + array.forEach( thisB.blocks, function( block, blockIndex ) { + if( block && block.domNode.parentNode ) + thisB.renderBlock({ + block: block, + blockIndex: blockIndex + }); + }); + callback(); + }, + function(e) { + thisB.error = e; + array.forEach( thisB.blocks, function( block, blockIndex ) { + if( block && block.domNode.parentNode ) + thisB.fillBlockError( blockIndex, block ); + }); + }); + + }, + + // Draw features + _draw: function(scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale, pixels, spans) { + this._preDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ); + this._drawFeatures( scale, leftBase, rightBase, block, canvas, pixels, dataScale ); + if ( spans ) { + this._maskBySpans( scale, leftBase, rightBase, block, canvas, pixels, dataScale, spans ); + } + this._postDraw( scale, leftBase, rightBase, block, canvas, features, featureRects, dataScale ); + }, + + startZoom: function(destScale, destStart, destEnd) { + }, + + endZoom: function(destScale, destBlockBases) { + this.clear(); + }, + + /** + * Calculate the left and width, in pixels, of where this feature + * will be drawn on the canvas. + * @private + * @returns {Object} with l, r, and w + */ + _featureRect: function( scale, leftBase, canvasWidth, feature ) { + var fRect = { + w: Math.ceil(( feature.get('end') - feature.get('start') ) * scale ), + l: Math.round(( feature.get('start') - leftBase ) * scale ) + }; + + // if fRect.l is negative (off the left + // side of the canvas), clip off the + // (possibly large!) non-visible + // portion + if( fRect.l < 0 ) { + fRect.w += fRect.l; + fRect.l = 0; + } + + // also don't let fRect.w get overly big + fRect.w = Math.min( canvasWidth-fRect.l, fRect.w ); + fRect.r = fRect.w + fRect.l; + + return fRect; + }, + + _preDraw: function( canvas ) { + }, + + /** + * Draw a set of features on the canvas. + * @private + */ + _drawFeatures: function( scale, leftBase, rightBase, block, canvas, features, featureRects ) { + }, + + // If we are making a boolean track, this will be called. Overwrite. + _maskBySpans: function( scale, leftBase, canvas, spans, pixels ) { + }, + + _postDraw: function() { + }, + + _calculatePixelScores: function( canvasWidth, features, featureRects ) { + // make an array of the max score at each pixel on the canvas + var pixelValues = new Array( canvasWidth ); + dojo.forEach( features, function( f, i ) { + var store = f.source; + var fRect = featureRects[i]; + var jEnd = fRect.r; + var score = f.get('score'); + for( var j = Math.round(fRect.l); j < jEnd; j++ ) { + if ( pixelValues[j] && pixelValues[j]['lastUsedStore'] == store ) { + /* Note: if the feature is from a different store, the condition should fail, + * and we will add to the value, rather than adjusting for overlap */ + pixelValues[j]['score'] = Math.max( pixelValues[j]['score'], score ); + } + else if ( pixelValues[j] ) { + pixelValues[j]['score'] = pixelValues[j]['score'] + score; + pixelValues[j]['lastUsedStore'] = store; + } + else { + pixelValues[j] = { score: score, lastUsedStore: store, feat: f } + } + } + },this); + // when done looping through features, forget the store information. + for (var i=0; i cPos.Height) { + scoreDisplay.style.display = 'none'; + verticalLine.style.display = 'none'; + } + */ + this.own( on( block.domNode, mouse.leave, function(evt) { + scoreDisplay.style.display = 'none'; + verticalLine.style.display = 'none'; + })); + }, + + _showPixelValue: function( scoreDisplay, score ) { + if( typeof score == 'number' ) { + // display the score with only 6 + // significant digits, avoiding + // most confusion about the + // approximative properties of + // IEEE floating point numbers + // parsed out of BigWig files + scoreDisplay.innerHTML = parseFloat( score.toPrecision(6) ); + return true; + } + else if( score && score['score'] && typeof score['score'] == 'number' ) { + // "score" may be an object. + scoreDisplay.innerHTML = parseFloat( score['score'].toPrecision(6) ); + return true; + } + else { + return false; + } + }, + + _exportFormats: function() { + return [{name: 'bedGraph', label: 'bedGraph', fileExt: 'bedgraph'}, {name: 'Wiggle', label: 'Wiggle', fileExt: 'wig'}, {name: 'GFF3', label: 'GFF3', fileExt: 'gff3'} ]; + } +}); +}); diff --git a/www/JBrowse/View/Track/YScaleMixin.js b/www/JBrowse/View/Track/YScaleMixin.js new file mode 100644 index 00000000..67ce076c --- /dev/null +++ b/www/JBrowse/View/Track/YScaleMixin.js @@ -0,0 +1,101 @@ +define( [ + 'dojo/_base/declare', + 'JBrowse/View/Ruler' + ], + function( + declare, + Ruler + ) { +/** + * Mixin for a track that has a Y-axis scale bar on its left side. + * Puts the scale div in this.yscale, stores the 'left' CSS pixel + * offset in this.yscale_left. + * @lends JBrowse.View.Track.YScaleMixin + */ + +return declare( null, { + /** + * @param {Number} [min] Optional minimum value for the scale. + * Defaults to value of this.minDisplayed. + * @param {Number} [max] Optional maximum value for the scale. + * Defaults to value of this.maxDisplayed. + */ + makeYScale: function( args ) { + args = args || {}; + var min = typeof args.min == 'number' ? args.min : this.minDisplayed; + var max = typeof args.max == 'number' ? args.max : this.maxDisplayed; + + // make and style the main container div for the axis + if( this.yscale ) { + this.yscale.parentNode.removeChild( this.yscale ); + } + var rulerdiv = + dojo.create('div', { + className: 'ruler vertical_ruler', + style: { + height: this.height+'px', + width: '10px', + position: 'absolute', + zIndex: 17 + } + }, this.div ); + this.yscale = rulerdiv; + + if( this.window_info && 'x' in this.window_info ) { + if ('yScalePosition' in this.config) { + if(this.config.yScalePosition == 'right') { + this.yscale.style.left = (this.window_info.x + (this.window_info.width-1||0)) + "px"; + } + else if(this.config.yScalePosition == 'left') { + this.yscale.style.left = this.window_info.x + 10 + 1 + "px"; + } + else if(this.config.yScalePosition == 'center') { + this.yscale.style.left = (this.window_info.x + (this.window_info.width||0)/2) + "px"; + } + } + else { + this.yscale.style.left = (this.window_info.x + (this.window_info.width||0)/2) + "px"; + } + } + + dojo.style( + rulerdiv, + ( this.config.align == 'top' ? { bottom: 0 } : + { top: 0 }) + ); + + // now make a Ruler and draw the axis in the div we just made + var ruler = new Ruler({ + min: min, + max: max, + direction: 'up', + leftBottom: !('yScalePosition' in this.config && this.config.yScalePosition == 'left'), + fixBounds: args.fixBounds || false + }); + ruler.render_to( rulerdiv ); + + this.ruler = ruler; + }, + + updateYScaleFromViewDimensions: function( coords ) { + if( typeof coords.x == 'number' || typeof coords.width == 'number' ) { + if( this.yscale ) { + if ('yScalePosition' in this.config) { + if(this.config.yScalePosition == 'right') { + this.yscale.style.left = (this.window_info.x + (this.window_info.width-1||0)) + "px"; + } + else if(this.config.yScalePosition == 'left') { + this.yscale.style.left = this.window_info.x + 10 + "px"; + } + else if(this.config.yScalePosition == 'center') { + this.yscale.style.left = (this.window_info.x + (this.window_info.width||0)/2) + "px"; + } + } + else { + this.yscale.style.left = (this.window_info.x + (this.window_info.width||0)/2) + "px"; + } + } + } + } +}); +}); diff --git a/www/JBrowse/View/Track/_AlignmentsMixin.js b/www/JBrowse/View/Track/_AlignmentsMixin.js new file mode 100644 index 00000000..e831e33d --- /dev/null +++ b/www/JBrowse/View/Track/_AlignmentsMixin.js @@ -0,0 +1,128 @@ +/** + * Mixin with methods used for displaying alignments and their mismatches. + */ +define([ + 'dojo/_base/declare', + 'dojo/_base/array', + 'JBrowse/Util', + 'JBrowse/Store/SeqFeature/_MismatchesMixin' + ], + function( + declare, + array, + Util, + MismatchesMixin + ) { + +return declare( MismatchesMixin ,{ + + /** + * Make a default feature detail page for the given feature. + * @returns {HTMLElement} feature detail page HTML + */ + defaultFeatureDetail: function( /** JBrowse.Track */ track, /** Object */ f, /** HTMLElement */ div ) { + var container = dojo.create('div', { + className: 'detail feature-detail feature-detail-'+track.name.replace(/\s+/g,'_').toLowerCase(), + innerHTML: '' + }); + var fmt = dojo.hitch( this, function( name, value ) { + name = Util.ucFirst( name.replace(/_/g,' ') ); + return this.renderDetailField(container, name, value); + }); + fmt( 'Name', f.get('name') ); + fmt( 'Type', f.get('type') ); + fmt( 'Score', f.get('score') ); + fmt( 'Description', f.get('note') ); + fmt( + 'Position', + Util.assembleLocString({ start: f.get('start'), + end: f.get('end'), + ref: this.refSeq.name }) + + ({'1':' (+)', '-1': ' (-)', 0: ' (no strand)' }[f.get('strand')] || '') + ); + + + if( f.get('seq') ) { + fmt('Sequence and Quality', this._renderSeqQual( f ) ); + } + + var additionalTags = array.filter( + f.tags(), function(t) { + return ! {name:1,score:1,start:1,end:1,strand:1,note:1,subfeatures:1,type:1}[t.toLowerCase()]; + } + ).sort(); + + dojo.forEach( additionalTags, function(t) { + fmt( t, f.get(t) ); + }); + + return container; + }, + + // takes a feature, returns an HTML representation of its 'seq' + // and 'qual', if it has at least a seq. empty string otherwise. + _renderSeqQual: function( feature ) { + + var seq = feature.get('seq'), + qual = feature.get('qual') || ''; + if( !seq ) + return ''; + + qual = qual.split(/\s+/); + + var html = ''; + for( var i = 0; i < seq.length; i++ ) { + html += '
' + + seq[i]+''; + if( qual[i] ) + html += ''+qual[i]+''; + html += '
'; + } + return '
'+html+'
'; + }, + + // recursively find all the stylesheets that are loaded in the + // current browsing session, traversing imports and such + _getStyleSheets: function( inSheets ) { + var outSheets = []; + array.forEach( inSheets, function( sheet ) { + outSheets.push( sheet ); + array.forEach( sheet.cssRules || sheet.rules, function( rule ) { + if( rule.styleSheet ) + outSheets.push.apply( outSheets, this._getStyleSheets( [rule.styleSheet] ) ); + },this); + },this); + return outSheets; + }, + + // get the appropriate HTML color string to use for a given base + // letter. case insensitive. 'reference' gives the color to draw matches with the reference. + colorForBase: function( base ) { + // get the base colors out of CSS + this._baseStyles = this._baseStyles || function() { + var colors = {}; + var styleSheets = this._getStyleSheets( document.styleSheets ); + array.forEach( styleSheets, function( sheet ) { + var classes = sheet.rules || sheet.cssRules; + if( ! classes ) return; + array.forEach( classes, function( c ) { + var match = /^\.base_([^\s_]+)$/.exec( c.selectorText ); + if( match && match[1] ) { + var base = match[1]; + match = /\#[0-9a-f]{3,6}|(?:rgb|hsl)a?\([^\)]*\)/gi.exec( c.cssText ); + if( match && match[0] ) { + colors[ base.toLowerCase() ] = match[0]; + colors[ base.toUpperCase() ] = match[0]; + } + } + }); + }); + + return colors; + }.call(this); + + return this._baseStyles[base] || '#999'; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/Track/_FeatureContextMenusMixin.js b/www/JBrowse/View/Track/_FeatureContextMenusMixin.js new file mode 100644 index 00000000..99557e76 --- /dev/null +++ b/www/JBrowse/View/Track/_FeatureContextMenusMixin.js @@ -0,0 +1,51 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang' + ], + function( + declare, + lang + ) { +return declare( null, { + +_refreshContextMenu: function( fRect ) { + // if we already have a menu generated for this feature, + // give it a new lease on life + if( ! fRect.contextMenu ) { + fRect.contextMenu = this._makeFeatureContextMenu( fRect, this.getConfForFeature( 'menuTemplate', fRect.f ) ); + } + + // give the menu a timeout so that it's cleaned up if it's not used within a certain time + if( fRect.contextMenuTimeout ) { + window.clearTimeout( fRect.contextMenuTimeout ); + } + var timeToLive = 30000; // clean menus up after 30 seconds + fRect.contextMenuTimeout = window.setTimeout( function() { + if( fRect.contextMenu ) { + fRect.contextMenu.destroyRecursive(); + delete fRect.contextMenu; + } + delete fRect.contextMenuTimeout; + }, timeToLive ); +}, + +/** + * Make the right-click dijit menu for a feature. + */ +_makeFeatureContextMenu: function( fRect, menuTemplate ) { + var context = lang.mixin( { track: this, feature: fRect.f, callbackArgs: [ this, fRect.f, fRect ] }, fRect ); + // interpolate template strings in the menuTemplate + menuTemplate = this._processMenuSpec( + dojo.clone( menuTemplate ), + context + ); + + // render the menu, start it up, and bind it to right-clicks + // both on the feature div and on the label div + var menu = this._renderContextMenu( menuTemplate, context ); + menu.startup(); + return menu; +} + +}); +}); diff --git a/www/JBrowse/View/Track/_TrackDetailsStatsMixin.js b/www/JBrowse/View/Track/_TrackDetailsStatsMixin.js new file mode 100644 index 00000000..de6aa685 --- /dev/null +++ b/www/JBrowse/View/Track/_TrackDetailsStatsMixin.js @@ -0,0 +1,29 @@ +define([ + 'dojo/_base/declare', + 'dojo/_base/lang', + 'dojo/Deferred' + ], + function( + declare, + lang, + Deferred + ) { + +return declare( null, { + + _trackDetailsContent: function() { + var thisB = this; + var d = new Deferred(); + var args = arguments; + // this.store.getRegionStats( + // { ref: this.refSeq.name, start: this.refSeq.start, end: this.refSeq.end }, + this.store.getGlobalStats( + function( stats ) { + d.resolve( thisB.inherited( args, [ { "Stats (current reference sequence)": stats } ] ) ); + }, + lang.hitch( d, 'reject' ) + ); + return d; + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/TrackConfigEditor.js b/www/JBrowse/View/TrackConfigEditor.js new file mode 100644 index 00000000..6dc4a85a --- /dev/null +++ b/www/JBrowse/View/TrackConfigEditor.js @@ -0,0 +1,163 @@ +/** + * Pops up a dialog to edit the configuration of a single track. + */ +define([ + 'dojo/_base/declare', + 'dojo/aspect', + 'dojo/json', + 'dojo/on', + 'dojo/dom-construct', + 'dijit/Dialog', + 'dijit/form/Button' + ], + function( + declare, + aspect, + JSON, + on, + dom, + Dialog, + Button + ) { + +return declare( null, { + + constructor: function( trackConfig ) { + this.trackConfig = trackConfig; + }, + + _makeActionBar: function( editCallback, cancelCallback ) { + var actionBar = dom.create( + 'div', { + className: 'dijitDialogPaneActionBar' + }); + + new Button({ iconClass: 'dijitIconDelete', label: 'Cancel', + onClick: dojo.hitch( this, function() { + cancelCallback && cancelCallback(); + this.dialog.hide(); + }) + }) + .placeAt( actionBar ); + this.applyButton = new Button({ + iconClass: 'dijitIconEdit', + label: 'Apply', + onClick: dojo.hitch( this, function() { + if( this.newConfig ) { + editCallback && editCallback({ + conf: this.newConfig + }); + } else { + cancelCallback && cancelCallback(); + } + this.dialog.hide(); + }) + }); + this.applyButton.placeAt( actionBar ); + + return { domNode: actionBar }; + }, + + show: function( editCallback, cancelCallback ) { + var dialog = this.dialog = new Dialog( + { title: "Edit track configuration", className: 'trackConfigEditor' } + ); + + var content = [ + this._makeEditControls().domNode, + this._makeActionBar( editCallback, cancelCallback ).domNode + ]; + dialog.set( 'content', content ); + dialog.show(); + + aspect.after( dialog, 'hide', dojo.hitch( this, function() { + setTimeout( function() { + dialog.destroyRecursive(); + }, 500 ); + })); + }, + + _makeEditControls: function() { + var realChange = dojo.hitch( this, function() { + this.newConfig = this._parseNewConfig( textArea.value ); + }); + + var container = dom.create( 'div', { className: 'editControls'} ); + + + var confString = this._stringifyConfig( this.trackConfig ); + var textArea = dom.create( + 'textarea',{ + rows: Math.min( (confString||'').match(/\n/g).length+4, 20 ), + cols: 70, + value: confString, + spellcheck: false, + onchange: realChange + }, container ); + // watch the input text for changes. just do it every 700ms + // because there are many ways that text can get changed (like + // pasting), not all of which fire the same events. not using + // the onchange event, because that doesn't fire until the + // textarea loses focus. + var previousText = ''; + var checkFrequency = 700; + var that = this; + var checkForChange = function() { + if( that.dialog.get('open') ) { + if( textArea.value != previousText ) { + realChange(); + previousText = textArea.value; + } + window.setTimeout( checkForChange, checkFrequency ); + } + }; + window.setTimeout( checkForChange, checkFrequency ); + + + + var errorArea = dom.create( 'div', { className: 'errors' }, container ); + this.errorReportArea = errorArea; + + + return { domNode: container }; + }, + + _stringifyConfig: function( config ) { + + // don't let people edit the store configuration, just the + // track configuration. make a shallow copy and delete the + // store conf. will add back in later. + var c = dojo.mixin( {}, config ); // shallow copy + delete c.store; + + // put a style in there if there isn't already one, for convenience + if( ! c.style ) c.style = {}; + if( ! c.metadata ) c.metadata = {}; + + return JSON.stringify( c, undefined, 2 ); + }, + + _reportError: function( error ) { + this.errorReportArea.innerHTML = '
'+error+'
'; + this.applyButton.set('disabled',true); + }, + _clearErrors: function() { + dom.empty( this.errorReportArea ); + this.applyButton.set('disabled',false); + }, + + _parseNewConfig: function( conf ) { + var newconf; + try { + newconf = JSON.parse( conf, true ); + this._clearErrors(); + } catch(e) { + this._reportError( e ); + } + if( newconf ) + newconf.store = this.trackConfig.store; + return newconf; + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/TrackList/Faceted.js b/www/JBrowse/View/TrackList/Faceted.js new file mode 100644 index 00000000..63ed012b --- /dev/null +++ b/www/JBrowse/View/TrackList/Faceted.js @@ -0,0 +1,874 @@ +define( + [ + 'dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/lang', + 'dijit/TitlePane', + 'dijit/layout/ContentPane', + 'JBrowse/Util', + 'dojox/grid/EnhancedGrid', + 'dojox/grid/enhanced/plugins/IndirectSelection' + ], + function ( + declare, + array, + lang, + TitlePane, + ContentPane, + Util, + EnhancedGrid + ) { + +var dojof = Util.dojof; +return declare( 'JBrowse.View.TrackList.Faceted', null, + /** + * @lends JBrowse.View.TrackList.Faceted.prototype + */ + { + + /** + * Track selector with facets and text searching. + * @constructs + */ + constructor: function(args) { + this.browser = args.browser; + this.tracksActive = {}; + this.config = args; + + // construct the discriminator for whether we will display a + // facet selector for this facet + this._isSelectableFacet = this._coerceFilter( + args.selectableFacetFilter + // default facet filtering function + || function( facetName, store ){ + return ( + // has an avg bucket size > 1 + store.getFacetStats( facetName ).avgBucketSize > 1 + && + // and not an ident or label attribute + ! array.some( store.getLabelAttributes() + .concat( store.getIdentityAttributes() ), + function(l) {return l == facetName;} + ) + ); + } + ); + + // construct a similar discriminator for which columns will be displayed + this.displayColumns = args.displayColumns; + this._isDisplayableColumn = this._coerceFilter( + args.displayColumnFilter || function(l) { return l.toLowerCase() != 'label'; } + ); + + // data store that fetches and filters our track metadata + this.trackDataStore = args.trackMetaData; + + // subscribe to commands coming from the the controller + this.browser.subscribe( '/jbrowse/v1/c/tracks/show', + lang.hitch( this, 'setTracksActive' )); + // subscribe to commands coming from the the controller + this.browser.subscribe( '/jbrowse/v1/c/tracks/hide', + lang.hitch( this, 'setTracksInactive' )); + this.browser.subscribe( '/jbrowse/v1/c/tracks/delete', + lang.hitch( this, 'setTracksInactive' )); + + this.renderInitial(); + + // once its data is loaded and ready + this.trackDataStore.onReady( this, function() { + + // render our controls and so forth + this.renderSelectors(); + + // connect events so that when a grid row is selected or + // deselected (with the checkbox), publish a message + // indicating that the user wants that track turned on or + // off + dojo.connect( this.dataGrid.selection, 'onSelected', this, function(index) { + this._ifNotSuppressed( 'selectionEvents', function() { + this._suppress( 'gridUpdate', function() { + this.browser.publish( '/jbrowse/v1/v/tracks/show', [this.dataGrid.getItem( index ).conf] ); + }); + }); + + }); + dojo.connect( this.dataGrid.selection, 'onDeselected', this, function(index) { + this._ifNotSuppressed( 'selectionEvents', function() { + this._suppress( 'gridUpdate', function() { + this.browser.publish( '/jbrowse/v1/v/tracks/hide', [this.dataGrid.getItem( index ).conf] ); + }); + }); + }); + }); + + this.trackDataStore.onReady( this, '_updateFacetCounts' ); // just once at start + this.trackDataStore.onReady( this, '_updateMatchCount' ); // just once at start + + dojo.connect( this.trackDataStore, 'onFetchSuccess', this, '_updateGridSelections' ); + dojo.connect( this.trackDataStore, 'onFetchSuccess', this, '_updateMatchCount' ); + + }, + + /** + * Coerces a string or array of strings into a function that, + * given a string, returns true if the string matches one of the + * given strings. If passed a function, just returns that + * function. + * @private + */ + _coerceFilter: function( filter ) { + // if we have a non-function filter, coerce to an array, + // then convert that array to a function + if( typeof filter == 'string' ) + filter = [filter]; + if( dojo.isArray( filter ) ) { + filter = function( store, facetName) { + return array.some( filter, function(fn) { + return facetName == fn; + }); + }; + } + return filter; + }, + + /** + * Call the given callback if none of the given event suppression flags are set. + * @private + */ + _ifNotSuppressed: function( suppressFlags, callback ) { + if( typeof suppressFlags == 'string') + suppressFlags = [suppressFlags]; + if( !this.suppress) + this.suppress = {}; + if( array.some( suppressFlags, function(f) {return this.suppress[f];}, this) ) + return undefined; + return callback.call(this); + }, + + /** + * Call the given callback while setting the given event suppression flags. + * @private + */ + _suppress: function( suppressFlags, callback ) { + if( typeof suppressFlags == 'string') + suppressFlags = [suppressFlags]; + if( !this.suppress) + this.suppress = {}; + dojo.forEach( suppressFlags, function(f) {this.suppress[f] = true; }, this); + var retval = callback.call( this ); + dojo.forEach( suppressFlags, function(f) {this.suppress[f] = false;}, this); + return retval; + }, + + /** + * Call a method of our object such that it cannot call itself + * by way of event cycles. + * @private + */ + _suppressRecursion: function( methodName ) { + var flag = ['method_'+methodName]; + var method = this[methodName]; + return this._ifNotSuppressed( flag, function() { this._suppress( flag, method );}); + }, + + renderInitial: function() { + this.containerElem = dojo.create( 'div', { + id: 'faceted_tracksel', + style: { + left: '-95%', + width: '95%', + zIndex: 500 + } + }, + document.body ); + + // make the tab that turns the selector on and off + dojo.create('div', + { + className: 'faceted_tracksel_on_off tab', + innerHTML: '
Select
tracks
' + }, + this.containerElem + ); + this.mainContainer = new dijit.layout.BorderContainer( + { design: 'headline', gutters: false }, + dojo.create('div',{ className: 'mainContainer' }, this.containerElem) + ); + + + this.topPane = new dijit.layout.ContentPane( + { region: 'top', + id: "faceted_tracksel_top", + content: '
Select Tracks
' + + '' + }); + dojo.query('div.topLink a[title="Track selector help"]',this.topPane.domNode) + .forEach(function(helplink){ + var helpdialog = new dijit.Dialog({ + "class": 'help_dialog', + refocus: false, + draggable: false, + title: 'Track Selection', + content: '
' + + '

The JBrowse Faceted Track Selector makes it easy to search through' + + ' large numbers of available tracks to find exactly the ones you want.' + + ' You can incrementally filter the track display to narrow it down to' + + ' those your are interested in. There are two types of filtering available,' + + ' which can be used together:' + + ' filtering with data fields, and free-form filtering with text.' + + '

' + + '
Filtering with Data Fields
' + + '
The left column of the display contains the available data fields. Click on the data field name to expand it, and then select one or more values for that field. This narrows the search to display only tracks that have one of those values for that field. You can do this for any number of fields.
' + + '
Filtering with Text
' + + '
Type text in the "Contains text" box to filter for tracks whose data contains that text. If you type multiple words, tracks are filtered such that they must contain all of those words, in any order. Placing "quotation marks" around the text filters for tracks that contain that phrase exactly. All text matching is case insensitive.
' + + '
Activating Tracks
' + + "
To activate and deactivate a track, click its check-box in the left-most column. When the box contains a check mark, the track is activated. You can also turn whole groups of tracks on and off using the check-box in the table heading.
" + + "
" + + "
" + }); + dojo.connect( helplink, 'onclick', this, function(evt) {helpdialog.show(); return false;}); + },this); + + this.mainContainer.addChild( this.topPane ); + + // make both buttons toggle this track selector + dojo.query( '.faceted_tracksel_on_off' ) + .onclick( lang.hitch( this, 'toggle' )); + + this.centerPane = new dijit.layout.BorderContainer({region: 'center', "class": 'gridPane', gutters: false}); + this.mainContainer.addChild( this.centerPane ); + var textFilterContainer = this.renderTextFilter(); + + this.busyIndicator = dojo.create( + 'div', { + innerHTML: '', + className: 'busy_indicator' + }, this.containerElem ); + + this.centerPane.addChild( + new dijit.layout.ContentPane( + { region: 'top', + "class": 'gridControls', + content: [ + dojo.create( 'button', { + className: 'faceted_tracksel_on_off', + innerHTML: '
Back to browser
', + onclick: lang.hitch( this, 'hide' ) + } + ), + dojo.create( 'button', { + className: 'clear_filters', + innerHTML:'' + + '
Clear All Filters
', + onclick: lang.hitch( this, function(evt) { + this._clearTextFilterControl(); + this._clearAllFacetControls(); + this._async( function() { + this.updateQuery(); + this._updateFacetCounts(); + },this).call(); + }) + } + ), + this.busyIndicator, + textFilterContainer, + dojo.create('div', { className: 'matching_record_count' }) + ] + } + ) + ); + + + }, + renderSelectors: function() { + + // make our main components + var facetContainer = this.renderFacetSelectors(); + // put them in their places in the overall layout of the track selector + facetContainer.set('region','left'); + this.mainContainer.addChild( facetContainer ); + + this.dataGrid = this.renderGrid(); + this.dataGrid.set('region','center'); + this.centerPane.addChild( this.dataGrid ); + + this.mainContainer.startup(); + }, + + /** do something in a timeout to avoid blocking the UI */ + _async: function( func, scope ) { + var that = this; + return function() { + var args = arguments; + var nativeScope = this; + that._busy( true ); + window.setTimeout( + function() { + func.apply( scope || nativeScope, args ); + that._busy( false ); + }, + 50 + ); + }; + }, + + _busy: function( busy ) { + this.busyCount = Math.max( 0, (this.busyCount || 0) + ( busy ? 1 : -1 ) ); + if( this.busyCount > 0 ) + dojo.addClass( this.containerElem, 'busy' ); + else + dojo.removeClass( this.containerElem, 'busy' ); + }, + + renderGrid: function() { + + var displayColumns = dojo.filter( + this.displayColumns || this.trackDataStore.getFacetNames(), + lang.hitch(this, '_isDisplayableColumn') + ); + var colWidth = 90/displayColumns.length; + + var grid = new EnhancedGrid({ + id: 'trackSelectGrid', + store: this.trackDataStore, + selectable: true, + escapeHTMLInData: ('escapeHTMLInData' in this.config) ? this.config.escapeHTMLInData : false, + noDataMessage: "No tracks match the filtering criteria.", + structure: [ + dojo.map( + displayColumns, + function(facetName) { + // rename name to key to avoid configuration confusion + facetName = {name: 'key'}[facetName.toLowerCase()] || facetName; + return {'name': this._facetDisplayName(facetName), 'field': facetName.toLowerCase(), 'width': colWidth+'%'}; + }, + this + ) + ], + plugins: { + indirectSelection: { + headerSelector: true + } + } + } + ); + + // set the grid's initial sort index + var sortIndex = this.config.initialSortColumn || 0; + if( typeof sortIndex == 'string' ) + sortIndex = array.indexOf( displayColumns, sortIndex ); + grid.setSortIndex( sortIndex+1 ); + + // monkey-patch the grid to customize some of its behaviors + this._monkeyPatchGrid( grid ); + + return grid; + }, + + /** + * Given a raw facet name, format it for user-facing display. + * @private + */ + _facetDisplayName: function( facetName ) { + // make renameFacets if needed, and lowercase all the keys to + // make it case-insensitive + this.renameFacets = this.renameFacets || function(){ + var renameFacets = this.config.renameFacets; + var lc = {}; + for( var k in renameFacets ) { + lc[ k.toLowerCase() ] = renameFacets[k]; + } + lc.key = lc.key || 'Name'; + return lc; + }.call(this); + + return this.renameFacets[facetName.toLowerCase()] || Util.ucFirst( facetName.replace('_',' ') ); + }, + + /** + * Apply several run-time patches to the dojox.grid.EnhancedGrid + * code to fix bugs and customize the behavior in ways that aren't + * quite possible using the regular Dojo APIs. + * @private + */ + _monkeyPatchGrid: function( grid ) { + + // 1. monkey-patch the grid's onRowClick handler to not do + // anything. without this, clicking on a row selects it, and + // deselects everything else, which is quite undesirable. + grid.onRowClick = function() {}; + + // 2. monkey-patch the grid's range-selector to refuse to select + // if the selection is too big + var origSelectRange = grid.selection.selectRange; + grid.selection.selectRange = function( inFrom, inTo ) { + var selectionLimit = 30; + if( inTo - inFrom > selectionLimit ) { + alert( "Too many tracks selected, please select fewer than "+selectionLimit+" tracks." ); + return undefined; + } + return origSelectRange.apply( this, arguments ); + }; + }, + + renderTextFilter: function( parent ) { + // make the text input for text filtering + this.textFilterLabel = dojo.create( + 'label', + { className: 'textFilterControl', + innerHTML: 'Contains text ', + id: 'tracklist_textfilter', + style: {position: 'relative'} + }, + parent + ); + this.textFilterInput = dojo.create( + 'input', + { type: 'text', + size: 40, + disabled: true, // disabled until shown + onkeypress: lang.hitch( this, function(evt) { + // don't pay attention to modifier keys + if( evt.keyCode == dojo.keys.SHIFT || evt.keyCode == dojo.keys.CTRL || evt.keyCode == dojo.keys.ALT ) + return; + + // use a timeout to avoid updating the display too fast + if( this.textFilterTimeout ) + window.clearTimeout( this.textFilterTimeout ); + this.textFilterTimeout = window.setTimeout( + lang.hitch( this, function() { + // do a new search and update the display + this._updateTextFilterControl(); + this._async( function() { + this.updateQuery(); + this._updateFacetCounts(); + this.textFilterInput.focus(); + },this).call(); + this.textFilterInput.focus(); + }), + 500 + ); + this._updateTextFilterControl(); + + evt.stopPropagation(); + }) + }, + this.textFilterLabel + ); + // make a "clear" button for the text filtering input + this.textFilterClearButton = dojo.create('img', { + src: this.browser.resolveUrl('img/red_x.png'), + className: 'text_filter_clear', + onclick: lang.hitch( this, function() { + this._clearTextFilterControl(); + this._async( function() { + this.updateQuery(); + this._updateFacetCounts(); + },this).call(); + }), + style: { + position: 'absolute', + right: '4px', + top: '20%' + } + }, this.textFilterLabel ); + + return this.textFilterLabel; + }, + + /** + * Clear the text filter control input. + * @private + */ + _clearTextFilterControl: function() { + this.textFilterInput.value = ''; + this._updateTextFilterControl(); + }, + /** + * Update the display of the text filter control based on whether + * it has any text in it. + * @private + */ + _updateTextFilterControl: function() { + if( this.textFilterInput.value.length ) + dojo.addClass( this.textFilterLabel, 'selected' ); + else + dojo.removeClass( this.textFilterLabel, 'selected' ); + + }, + + /** + * Create selection boxes for each searchable facet. + */ + renderFacetSelectors: function() { + var container = new ContentPane({style: 'width: 200px'}); + + var store = this.trackDataStore; + this.facetSelectors = {}; + + // render a facet selector for a pseudo-facet holding + // attributes regarding the tracks the user has been working + // with + var usageFacet = this._renderFacetSelector( + 'My Tracks', ['Currently Active', 'Recently Used'] ); + usageFacet.set('class', 'myTracks' ); + container.addChild( usageFacet ); + + // for the facets from the store, only render facet selectors + // for ones that are not identity attributes, and have an + // average bucket size greater than 1 + var selectableFacets = + dojo.filter( this.config.selectableFacets || store.getFacetNames(), + function( facetName ) { + return this._isSelectableFacet( facetName, this.trackDataStore ); + }, + this + ); + + dojo.forEach( selectableFacets, function(facetName) { + + // get the values of this facet + var values = store.getFacetValues(facetName).sort(); + if( !values || !values.length ) + return; + + var facetPane = this._renderFacetSelector( facetName, values ); + container.addChild( facetPane ); + },this); + + return container; + }, + + /** + * Make HTML elements for a single facet selector. + * @private + * @returns {dijit.layout.TitlePane} + */ + _renderFacetSelector: function( /**String*/ facetName, /**Array[String]*/ values ) { + + var facetPane = new TitlePane( + { + title: '' + + this._facetDisplayName(facetName) + + ' ' + + '' + }); + + // make a selection control for the values of this facet + var facetControl = dojo.create( 'table', {className: 'facetSelect'}, facetPane.containerNode ); + // populate selector's options + this.facetSelectors[facetName] = dojo.map( + values, + function(val) { + var that = this; + var node = dojo.create( + 'tr', + { className: 'facetValue', + innerHTML: ''+ val + '', + onclick: function(evt) { + dojo.toggleClass(this, 'selected'); + that._updateFacetControl( facetName ); + that._async( function() { + that.updateQuery(); + that._updateFacetCounts( facetName ); + }).call(); + } + }, + facetControl + ); + node.facetValue = val; + return node; + }, + this + ); + + return facetPane; + }, + + /** + * Clear all the selections from all of the facet controls. + * @private + */ + _clearAllFacetControls: function() { + dojo.forEach( dojof.keys( this.facetSelectors ), function( facetName ) { + this._clearFacetControl( facetName ); + },this); + }, + + /** + * Clear all the selections from the facet control with the given name. + * @private + */ + _clearFacetControl: function( facetName ) { + dojo.forEach( this.facetSelectors[facetName] || [], function(selector) { + dojo.removeClass(selector,'selected'); + },this); + this._updateFacetControl( facetName ); + }, + + /** + * Incrementally update the facet counts as facet values are selected. + * @private + */ + _updateFacetCounts: function( /**String*/ skipFacetName ) { + dojo.forEach( dojof.keys( this.facetSelectors ), function( facetName ) { + if( facetName == 'My Tracks' )// || facetName == skipFacetName ) + return; + var thisFacetCounts = this.trackDataStore.getFacetCounts( facetName ); + dojo.forEach( this.facetSelectors[facetName] || [], function( selectorNode ) { + dojo.query('.count',selectorNode) + .forEach( function(countNode) { + var count = thisFacetCounts ? thisFacetCounts[ selectorNode.facetValue ] || 0 : 0; + countNode.innerHTML = Util.addCommas( count ); + if( count ) + dojo.removeClass( selectorNode, 'disabled'); + else + dojo.addClass( selectorNode, 'disabled' ); + },this); + //dojo.removeClass(selector,'selected'); + },this); + this._updateFacetControl( facetName ); + },this); + }, + + /** + * Update the title bar of the given facet control to reflect + * whether it has selected values in it. + */ + _updateFacetControl: function( facetName ) { + var titleContent = dojo.byId('facet_title_'+facetName); + + // if all our values are disabled, add 'disabled' to our + // title's CSS classes + if( array.every( this.facetSelectors[facetName] ||[], function(sel) { + return dojo.hasClass( sel, 'disabled' ); + },this) + ) { + dojo.addClass( titleContent, 'disabled' ); + } + + // if we have some selected values, make a "clear" button, and + // add 'selected' to our title's CSS classes + if( array.some( this.facetSelectors[facetName] || [], function(sel) { + return dojo.hasClass( sel, 'selected' ); + }, this ) ) { + var clearFunc = lang.hitch( this, function(evt) { + this._clearFacetControl( facetName ); + this._async( function() { + this.updateQuery(); + this._updateFacetCounts( facetName ); + },this).call(); + evt.stopPropagation(); + }); + dojo.addClass( titleContent.parentNode.parentNode, 'activeFacet' ); + dojo.query( '> a', titleContent ) + .forEach(function(node) { node.onclick = clearFunc; },this) + .attr('title','clear selections'); + } + // otherwise, no selected values + else { + dojo.removeClass( titleContent.parentNode.parentNode, 'activeFacet' ); + dojo.query( '> a', titleContent ) + .onclick( function(){return false;}) + .removeAttr('title'); + } + }, + + /** + * Update the query we are using with the track metadata store + * based on the values of the search form elements. + */ + updateQuery: function() { + this._suppressRecursion( '_updateQuery' ); + }, + _updateQuery: function() { + var newQuery = {}; + + var is_selected = function(node) { + return dojo.hasClass(node,'selected'); + }; + + // update from the My Tracks pseudofacet + (function() { + var mytracks_options = this.facetSelectors['My Tracks']; + + // index the optoins by name + var byname = {}; + dojo.forEach( mytracks_options, function(opt){ byname[opt.facetValue] = opt;}); + + // if filtering for active tracks, add the labels for the + // currently selected tracks to the query + if( is_selected( byname['Currently Active'] ) ) { + var activeTrackLabels = dojof.keys(this.tracksActive || {}); + newQuery.label = Util.uniq( + (newQuery.label ||[]) + .concat( activeTrackLabels ) + ); + } + + // if filtering for recently used tracks, add the labels of recently used tracks + if( is_selected( byname['Recently Used'])) { + var recentlyUsed = dojo.map( + this.browser.getRecentlyUsedTracks(), + function(t){ + return t.label; + } + ); + + newQuery.label = Util.uniq( + (newQuery.label ||[]) + .concat(recentlyUsed) + ); + } + + // finally, if something is selected in here, but we have + // not come up with any track labels, then insert a dummy + // track label value that will never match, because the + // query engine ignores empty arrayrefs. + if( ( ! newQuery.label || ! newQuery.label.length ) + && array.some( mytracks_options, is_selected ) + ) { + newQuery.label = ['FAKE LABEL THAT IS HIGHLY UNLIKELY TO EVER MATCH ANYTHING']; + } + + }).call(this); + + // update from the text filter + if( this.textFilterInput.value.length ) { + newQuery.text = this.textFilterInput.value; + } + + // update from the data-based facet selectors + dojo.forEach( this.trackDataStore.getFacetNames(), function(facetName) { + var options = this.facetSelectors[facetName]; + if( !options ) return; + + var selectedFacets = dojo.map( + dojo.filter( options, is_selected ), + function(opt) {return opt.facetValue;} + ); + if( selectedFacets.length ) + newQuery[facetName] = selectedFacets; + },this); + + this.query = newQuery; + this.dataGrid.setQuery( this.query ); + this._updateMatchCount(); + }, + + /** + * Update the match-count text in the grid controls bar based + * on the last query that was run against the store. + * @private + */ + _updateMatchCount: function() { + var count = this.dataGrid.store.getCount(); + dojo.query( '.matching_record_count', this.containerElem ) + .forEach( function(n) { + n.innerHTML = + Util.addCommas(count) + + ' '+( dojof.keys(this.query||{}).length ? 'matching ' : '' ) + +'track' + ( count == 1 ? '' : 's' ); + }, + this + ); + }, + + /** + * Update the grid to have only rows checked that correspond to + * tracks that are currently active. + * @private + */ + _updateGridSelections: function() { + // keep selection events from firing while we mess with the + // grid + this._ifNotSuppressed('gridUpdate', function(){ + this._suppress('selectionEvents', function() { + this.dataGrid.selection.deselectAll(); + + // check the boxes that should be checked, based on our + // internal memory of what tracks should be on. + for( var i= 0; i < Math.min( this.dataGrid.get('rowCount'), this.dataGrid.get('rowsPerPage') ); i++ ) { + var item = this.dataGrid.getItem( i ); + if( item ) { + var label = this.dataGrid.store.getIdentity( item ); + if( this.tracksActive[label] ) + this.dataGrid.rowSelectCell.toggleRow( i, true ); + } + } + + }); + }); + }, + + /** + * Given an array of track configs, update the track list to show + * that they are turned on. + */ + setTracksActive: function( /**Array[Object]*/ trackConfigs ) { + dojo.forEach( trackConfigs, function(conf) { + this.tracksActive[conf.label] = true; + },this); + this._updateGridSelections(); + }, + + /** + * Given an array of track configs, update the track list to show + * that they are turned off. + */ + setTracksInactive: function( /**Array[Object]*/ trackConfigs ) { + dojo.forEach( trackConfigs, function(conf) { + delete this.tracksActive[conf.label]; + },this); + this._updateGridSelections(); + }, + + /** + * Make the track selector visible. + */ + show: function() { + window.setTimeout( lang.hitch( this, function() { + this.textFilterInput.disabled = false; + this.textFilterInput.focus(); + }), 300); + + dojo.addClass( this.containerElem, 'active' ); + dojo.animateProperty({ + node: this.containerElem, + properties: { + left: { start: -95, end: 0, units: '%' } + } + }).play(); + + this.shown = true; + }, + + /** + * Make the track selector invisible. + */ + hide: function() { + + dojo.removeClass( this.containerElem, 'active' ); + + dojo.animateProperty({ + node: this.containerElem, + properties: { + left: { start: 0, end: -95, units: '%' } + } + }).play(); + + this.textFilterInput.blur(); + this.textFilterInput.disabled = true; + + this.shown = false; + }, + + /** + * Toggle whether the track selector is visible. + */ + toggle: function() { + this.shown ? this.hide() : this.show(); + } +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/TrackList/Null.js b/www/JBrowse/View/TrackList/Null.js new file mode 100644 index 00000000..8e3c10bc --- /dev/null +++ b/www/JBrowse/View/TrackList/Null.js @@ -0,0 +1,16 @@ +define(['dojo/_base/declare'],function(declare) { + +return declare(null, + +/** + * @lends JBrowse.View.TrackList.Null.prototype + */ +{ + setTracksActive: function() {}, + setTracksInactive: function() {}, + show: function() {}, + hide: function() {}, + toggle: function() {} +}); +}); + diff --git a/www/JBrowse/View/TrackList/Simple.js b/www/JBrowse/View/TrackList/Simple.js new file mode 100644 index 00000000..fe5d7ccd --- /dev/null +++ b/www/JBrowse/View/TrackList/Simple.js @@ -0,0 +1,457 @@ +define(['dojo/_base/declare', + 'dojo/_base/array', + 'dojo/_base/event', + 'dojo/keys', + 'dojo/on', + 'dojo/dom-construct', + 'dojo/dom-class', + 'dijit/layout/ContentPane', + 'dojo/dnd/Source', + 'dojo/fx/easing', + 'dijit/form/TextBox' + ], + function( + declare, + array, + event, + keys, + on, + dom, + domClass, + ContentPane, + dndSource, + animationEasing, + dijitTextBox + ) { + +return declare( 'JBrowse.View.TrackList.Simple', null, + + /** @lends JBrowse.View.TrackList.Simple.prototype */ + { + + /** + * Simple drag-and-drop track selector. + * @constructs + */ + constructor: function( args ) { + this.browser = args.browser; + + // make the track list DOM nodes and widgets + this.createTrackList( args.browser.container ); + + // maintain a list of the HTML nodes of inactive tracks, so we + // can flash them and whatnot + this.inactiveTrackNodes = {}; + + // populate our track list (in the right order) + this.trackListWidget.insertNodes( + false, + args.trackConfigs + ); + + // subscribe to drop events for tracks being DND'ed + this.browser.subscribe( + "/dnd/drop", + dojo.hitch( this, + function( source, nodes, copy, target ){ + if( target !== this.trackListWidget ) + return; + + // get the configs from the tracks being dragged in + var confs = dojo.filter( + dojo.map( nodes, function(n) { + return n.track && n.track.config; + } + ), + function(c) {return c;} + ); + + // return if no confs; whatever was + // dragged here probably wasn't a + // track + if( ! confs.length ) + return; + + this.dndDrop = true; + this.browser.publish( '/jbrowse/v1/v/tracks/hide', confs ); + this.dndDrop = false; + } + )); + + // subscribe to commands coming from the the controller + this.browser.subscribe( '/jbrowse/v1/c/tracks/show', + dojo.hitch( this, 'setTracksActive' )); + this.browser.subscribe( '/jbrowse/v1/c/tracks/hide', + dojo.hitch( this, 'setTracksInactive' )); + this.browser.subscribe( '/jbrowse/v1/c/tracks/new', + dojo.hitch( this, 'addTracks' )); + this.browser.subscribe( '/jbrowse/v1/c/tracks/replace', + dojo.hitch( this, 'replaceTracks' )); + this.browser.subscribe( '/jbrowse/v1/c/tracks/delete', + dojo.hitch( this, 'deleteTracks' )); + }, + + addTracks: function( trackConfigs ) { + // note that new tracks are, by default, hidden, so we just put them in the list + this.trackListWidget.insertNodes( + false, + trackConfigs + ); + + this._blinkTracks( trackConfigs ); + }, + + replaceTracks: function( trackConfigs ) { + // for each one + array.forEach( trackConfigs, function( conf ) { + // figure out its position in the genome view and delete it + var oldNode = this.inactiveTrackNodes[ conf.label ]; + if( ! oldNode ) + return; + delete this.inactiveTrackNodes[ conf.label ]; + + this.trackListWidget.delItem( oldNode.id ); + if( oldNode.parentNode ) + oldNode.parentNode.removeChild( oldNode ); + + // insert the new track config into the trackListWidget after the 'before' + this.trackListWidget.insertNodes( false, [conf], false, oldNode.previousSibling ); + },this); + }, + + /** @private */ + createTrackList: function( renderTo ) { + var leftPane = dojo.create( + 'div', + { id: 'trackPane', + style: { width: '12em' } + }, + renderTo + ); + + //splitter on left side + var leftWidget = new ContentPane({region: "left", splitter: true}, leftPane); + + var trackListDiv = this.div = dojo.create( + 'div', + { id: 'tracksAvail', + className: 'container handles', + style: { width: '100%', height: '100%', overflowX: 'hidden', overflowY: 'auto' }, + innerHTML: '

Available Tracks

' + }, + leftPane + ); + + this.textFilterDiv = dom.create( 'div', { + className: 'textfilter', + style: { + width: '100%', + position: 'relative', + overflow: 'hidden' + } + }, trackListDiv ); + this.textFilterInput = dom.create( + 'input', + { type: 'text', + style: { + paddingLeft: '18px', + height: '16px', + width: '80%' + }, + placeholder: 'filter by text', + onkeypress: dojo.hitch( this, function( evt ) { + if( evt.keyCode == keys.ESCAPE ) { + this.textFilterInput.value = ''; + } + + if( this.textFilterTimeout ) + window.clearTimeout( this.textFilterTimeout ); + this.textFilterTimeout = window.setTimeout( + dojo.hitch( this, function() { + this._updateTextFilterControl(); + this._textFilter( this.textFilterInput.value ); + }), + 500 + ); + this._updateTextFilterControl(); + + evt.stopPropagation(); + }) + }, + dom.create('div',{ style: 'overflow: show;' }, this.textFilterDiv ) + ); + + // make a "clear" button for the text filtering input + this.textFilterClearButton = dom.create('div', { + className: 'jbrowseIconCancel', + onclick: dojo.hitch( this, function() { + this._clearTextFilterControl(); + this._textFilter( this.textFilterInput.value ); + }), + style: { + position: 'absolute', + left: '4px', + top: '6px' + } + }, this.textFilterDiv ); + + this._updateTextFilterControl(); + + this.trackListWidget = new dndSource( + trackListDiv, + { + accept: ["track"], // accepts only tracks into left div + withHandles: false, + creator: dojo.hitch( this, function( trackConfig, hint ) { + var key = trackConfig.key || trackConfig.name || trackConfig.label; + var node = dojo.create( + 'div', + { className: 'tracklist-label', + title: key+' (drag or double-click to activate)', + innerHTML: key + } + ); + + //in the list, wrap the list item in a container for + //border drag-insertion-point monkeying + if ("avatar" != hint) { + on(node, "dblclick", dojo.hitch(this, function() { + this.browser.publish( '/jbrowse/v1/v/tracks/show', [trackConfig] ); + })); + + var container = dojo.create( 'div', { className: 'tracklist-container' }); + container.appendChild(node); + node = container; + node.id = dojo.dnd.getUniqueId(); + this.inactiveTrackNodes[trackConfig.label] = node; + } + return {node: node, data: trackConfig, type: ["track"]}; + }) + } + ); + + // The dojo onMouseDown and onMouseUp methods don't support the functionality we're looking for, + // so we'll substitute our own + this.trackListWidget.onMouseDown = dojo.hitch(this, "onMouseDown"); + this.trackListWidget.onMouseUp = dojo.hitch(this, "onMouseUp"); + + // We want the escape key to deselect all tracks + on(document, "keydown", dojo.hitch(this, "onKeyDown")); + + return trackListDiv; + }, + + onKeyDown: function(e) { + switch(e.keyCode) { + case keys.ESCAPE: + this.trackListWidget.selectNone(); + break; + } + }, + + onMouseDown: function(e) { + var thisW = this.trackListWidget; + if(!thisW.mouseDown && thisW._legalMouseDown(e)){ + thisW.mouseDown = true; + thisW._lastX = e.pageX; + thisW._lastY = e.pageY; + this._onMouseDown(thisW.current, e); + } + }, + + _onMouseDown: function(current, e) { + if(!current) return; + var thisW = this.trackListWidget; + if(!e.ctrlKey && !e.shiftKey) { + thisW.simpleSelection = true; + if(!this._isSelected(current)) { + thisW.selectNone(); + thisW.simpleSelection = false; + } + } + if(e.shiftKey && this.anchor) { + var i = 0; + var nodes = thisW.getAllNodes(); + this._select(current); + if(current != this.anchor) { + for(; i < nodes.length; i++) { + if(nodes[i] == this.anchor || nodes[i] == current) break; + } + i++; + for(; i < nodes.length; i++) { + if(nodes[i] == this.anchor || nodes[i] == current) break; + this._select(nodes[i]); + } + } + } else { + e.ctrlKey ? this._toggle(current) : this._select(current); + this.anchor = current; + } + event.stop(e); + }, + + onMouseUp: function(e) { + var thisW = this.trackListWidget; + if(thisW.mouseDown){ + thisW.mouseDown = false; + this._onMouseUp(e); + } + }, + + _onMouseUp: function(e) { + var thisW = this.trackListWidget; + if(thisW.simpleSelection && thisW.current) { + thisW.selectNone(); + this._select(thisW.current); + } + }, + + _isSelected: function(node) { + return this.trackListWidget.selection[node.id]; + }, + + _select: function(node) { + this.trackListWidget.selection[node.id] = 1; + this.trackListWidget._addItemClass(node, "Selected"); + }, + + _deselect: function(node) { + delete this.trackListWidget.selection[node.id]; + this.trackListWidget._removeItemClass(node, "Selected"); + }, + + _toggle: function(node) { + if(this.trackListWidget.selection[node.id]) { + this._deselect(node); + } else { + this._select(node); + } + }, + + _textFilter: function( text ) { + if( text && /\S/.test(text) ) { + + text = text.toLowerCase(); + + dojo.query( '.tracklist-label', this.div ) + .forEach( function( labelNode, i ) { + if( labelNode.innerHTML.toLowerCase().indexOf( text ) != -1 ) { + dojo.removeClass( labelNode, 'collapsed'); + } else { + dojo.addClass( labelNode, 'collapsed'); + } + }); + } else { + dojo.query( '.tracklist-label', this.div ) + .removeClass('collapsed'); + } + }, + + /** + * Clear the text filter control input. + * @private + */ + _clearTextFilterControl: function() { + this.textFilterInput.value = ''; + this._updateTextFilterControl(); + }, + /** + * Update the display of the text filter control based on whether + * it has any text in it. + * @private + */ + _updateTextFilterControl: function() { + if( this.textFilterInput.value.length ) + dojo.removeClass( this.textFilterDiv, 'dijitDisabled' ); + else + dojo.addClass( this.textFilterDiv, 'dijitDisabled' ); + }, + + /** + * Given an array of track configs, update the track list to show + * that they are turned on. For this list, that just means + * deleting them from our widget. + */ + setTracksActive: function( /**Array[Object]*/ trackConfigs ) { + this.deleteTracks( trackConfigs ); + }, + + deleteTracks: function( /**Array[Object]*/ trackConfigs ) { + // remove any tracks in our track list that are being set as visible + array.forEach( trackConfigs || [], function( conf ) { + var oldNode = this.inactiveTrackNodes[ conf.label ]; + if( ! oldNode ) + return; + delete this.inactiveTrackNodes[ conf.label ]; + + if( oldNode.parentNode ) + oldNode.parentNode.removeChild( oldNode ); + + this.trackListWidget.delItem( oldNode.id ); + },this); + }, + + /** + * Given an array of track configs, update the track list to show + * that they are turned off. + */ + setTracksInactive: function( /**Array[Object]*/ trackConfigs ) { + + // remove any tracks in our track list that are being set as visible + if( ! this.dndDrop ) { + var n = this.trackListWidget.insertNodes( false, trackConfigs ); + + // blink the track(s) that we just turned off to make it + // easier for users to tell where they went. + // note that insertNodes will have put its html element in + // inactivetracknodes + this._blinkTracks( trackConfigs ); + } + }, + + _blinkTracks: function( trackConfigs ) { + // scroll the tracklist all the way to the bottom so we can see the blinking nodes + this.trackListWidget.node.scrollTop = this.trackListWidget.node.scrollHeight; + + array.forEach( trackConfigs, function(c) { + var label = this.inactiveTrackNodes[c.label].firstChild; + if( label ) { + dojo.animateProperty({ + node: label, + duration: 400, + properties: { + backgroundColor: { start: '#DEDEDE', end: '#FFDE2B' } + }, + easing: animationEasing.sine, + repeat: 2, + onEnd: function() { + label.style.backgroundColor = null; + } + }).play(); + } + },this); + }, + + /** + * Make the track selector visible. + * This does nothing for the Simple track selector, since it is always visible. + */ + show: function() { + }, + + /** + * Make the track selector invisible. + * This does nothing for the Simple track selector, since it is always visible. + */ + hide: function() { + }, + + /** + * Toggle visibility of this track selector. + * This does nothing for the Simple track selector, since it is always visible. + */ + toggle: function() { + } + +}); +}); \ No newline at end of file diff --git a/www/JBrowse/View/_FeatureDescriptionMixin.js b/www/JBrowse/View/_FeatureDescriptionMixin.js new file mode 100644 index 00000000..edb02ab6 --- /dev/null +++ b/www/JBrowse/View/_FeatureDescriptionMixin.js @@ -0,0 +1,70 @@ +define( [ + 'dojo/_base/declare', + 'dojo/_base/lang' + ], + function( + declare, + lang + ) { + +return declare( null, { + + // get the label string for a feature, based on the setting + // of this.config.label + getFeatureLabel: function( feature ) { + return this._getFeatureDescriptiveThing( 'label', 'name,id', feature ); + }, + + // get the description string for a feature, based on the setting + // of this.config.description + getFeatureDescription: function( feature ) { + return this._getFeatureDescriptiveThing( 'description', 'note,description', feature ); + }, + + _getFeatureDescriptiveThing: function( field, defaultFields, feature ) { + var dConf = this.config.style[field] || this.config[field]; + + if( ! dConf ) + return null; + + // if the description is a function, just call it + if( typeof dConf == 'function' ) { + return dConf.call( this, feature ); + } + // otherwise try to parse it as a field list + else { + if( ! this.descriptionFields ) + this.descriptionFields = {}; + + // parse our description varname conf if necessary + var fields = this.descriptionFields[field] || function() { + var f = dConf; + if( f ) { + if( lang.isArray( f ) ) { + f = f.join(','); + } + else if( typeof f != 'string' ) { + console.warn( 'invalid `description` setting ('+f+') for "'+(this.name||this.track.name)+'" track, falling back to "note,description"' ); + f = defaultFields; + } + f = f.toLowerCase().split(/\s*\,\s*/); + } + else { + f = []; + } + this.descriptionFields[field] = f; + return f; + }.call(this); + + // return the value of the first field that contains something + for( var i=0; i + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/www/img/jbrowse/chevron.png b/www/img/jbrowse/chevron.png new file mode 100644 index 00000000..59fc5b8e Binary files /dev/null and b/www/img/jbrowse/chevron.png differ diff --git a/www/img/jbrowse/chevron.svg b/www/img/jbrowse/chevron.svg new file mode 100644 index 00000000..d59af115 --- /dev/null +++ b/www/img/jbrowse/chevron.svg @@ -0,0 +1,113 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/chevron2.png b/www/img/jbrowse/chevron2.png new file mode 100644 index 00000000..28ebbc45 Binary files /dev/null and b/www/img/jbrowse/chevron2.png differ diff --git a/www/img/jbrowse/chevron2.svg b/www/img/jbrowse/chevron2.svg new file mode 100644 index 00000000..d4c4c166 --- /dev/null +++ b/www/img/jbrowse/chevron2.svg @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/chevron3.png b/www/img/jbrowse/chevron3.png new file mode 100644 index 00000000..07496990 Binary files /dev/null and b/www/img/jbrowse/chevron3.png differ diff --git a/www/img/jbrowse/chevron3.svg b/www/img/jbrowse/chevron3.svg new file mode 100644 index 00000000..2284ce83 --- /dev/null +++ b/www/img/jbrowse/chevron3.svg @@ -0,0 +1,442 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/commonIconsDisabled.png b/www/img/jbrowse/commonIconsDisabled.png new file mode 100644 index 00000000..4af5e661 Binary files /dev/null and b/www/img/jbrowse/commonIconsDisabled.png differ diff --git a/www/img/jbrowse/commonIconsEnabled.png b/www/img/jbrowse/commonIconsEnabled.png new file mode 100644 index 00000000..0736ddc9 Binary files /dev/null and b/www/img/jbrowse/commonIconsEnabled.png differ diff --git a/www/img/jbrowse/dark_20x2.png b/www/img/jbrowse/dark_20x2.png new file mode 100644 index 00000000..12a17504 Binary files /dev/null and b/www/img/jbrowse/dark_20x2.png differ diff --git a/www/img/jbrowse/dark_20x3.png b/www/img/jbrowse/dark_20x3.png new file mode 100644 index 00000000..6f239114 Binary files /dev/null and b/www/img/jbrowse/dark_20x3.png differ diff --git a/www/img/jbrowse/dblhelix-red.png b/www/img/jbrowse/dblhelix-red.png new file mode 100644 index 00000000..a5937b64 Binary files /dev/null and b/www/img/jbrowse/dblhelix-red.png differ diff --git a/www/img/jbrowse/dblhelix-red.svg b/www/img/jbrowse/dblhelix-red.svg new file mode 100644 index 00000000..62c02f9a --- /dev/null +++ b/www/img/jbrowse/dblhelix-red.svg @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/glyphs_black.png b/www/img/jbrowse/glyphs_black.png new file mode 100644 index 00000000..893112e0 Binary files /dev/null and b/www/img/jbrowse/glyphs_black.png differ diff --git a/www/img/jbrowse/glyphs_white.png b/www/img/jbrowse/glyphs_white.png new file mode 100644 index 00000000..95f2fa9a Binary files /dev/null and b/www/img/jbrowse/glyphs_white.png differ diff --git a/www/img/jbrowse/helix-green.png b/www/img/jbrowse/helix-green.png new file mode 100644 index 00000000..4ce80fa7 Binary files /dev/null and b/www/img/jbrowse/helix-green.png differ diff --git a/www/img/jbrowse/helix-green.svg b/www/img/jbrowse/helix-green.svg new file mode 100644 index 00000000..e0fd6cea --- /dev/null +++ b/www/img/jbrowse/helix-green.svg @@ -0,0 +1,268 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/helix2-green.png b/www/img/jbrowse/helix2-green.png new file mode 100644 index 00000000..a6bfa4db Binary files /dev/null and b/www/img/jbrowse/helix2-green.png differ diff --git a/www/img/jbrowse/helix2-green.svg b/www/img/jbrowse/helix2-green.svg new file mode 100644 index 00000000..a6281b1a --- /dev/null +++ b/www/img/jbrowse/helix2-green.svg @@ -0,0 +1,356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/helix3-green.png b/www/img/jbrowse/helix3-green.png new file mode 100644 index 00000000..81fa8c00 Binary files /dev/null and b/www/img/jbrowse/helix3-green.png differ diff --git a/www/img/jbrowse/helix3-green.svg b/www/img/jbrowse/helix3-green.svg new file mode 100644 index 00000000..4e93a92e --- /dev/null +++ b/www/img/jbrowse/helix3-green.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/www/img/jbrowse/herringbone-blue.png b/www/img/jbrowse/herringbone-blue.png new file mode 100644 index 00000000..c8be53ae Binary files /dev/null and b/www/img/jbrowse/herringbone-blue.png differ diff --git a/www/img/jbrowse/herringbone-pal.png b/www/img/jbrowse/herringbone-pal.png new file mode 100644 index 00000000..7ec2868a Binary files /dev/null and b/www/img/jbrowse/herringbone-pal.png differ diff --git a/www/img/jbrowse/herringbone.png b/www/img/jbrowse/herringbone.png new file mode 100644 index 00000000..b905177a Binary files /dev/null and b/www/img/jbrowse/herringbone.png differ diff --git a/www/img/jbrowse/herringbone.svg b/www/img/jbrowse/herringbone.svg new file mode 100644 index 00000000..81af316f --- /dev/null +++ b/www/img/jbrowse/herringbone.svg @@ -0,0 +1,320 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone10.png b/www/img/jbrowse/herringbone10.png new file mode 100644 index 00000000..c24eac81 Binary files /dev/null and b/www/img/jbrowse/herringbone10.png differ diff --git a/www/img/jbrowse/herringbone10.svg b/www/img/jbrowse/herringbone10.svg new file mode 100644 index 00000000..9b6ef85d --- /dev/null +++ b/www/img/jbrowse/herringbone10.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone11.png b/www/img/jbrowse/herringbone11.png new file mode 100644 index 00000000..320f58f6 Binary files /dev/null and b/www/img/jbrowse/herringbone11.png differ diff --git a/www/img/jbrowse/herringbone11.svg b/www/img/jbrowse/herringbone11.svg new file mode 100644 index 00000000..cd07484b --- /dev/null +++ b/www/img/jbrowse/herringbone11.svg @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone12.png b/www/img/jbrowse/herringbone12.png new file mode 100644 index 00000000..52821a82 Binary files /dev/null and b/www/img/jbrowse/herringbone12.png differ diff --git a/www/img/jbrowse/herringbone12.svg b/www/img/jbrowse/herringbone12.svg new file mode 100644 index 00000000..fec623a8 --- /dev/null +++ b/www/img/jbrowse/herringbone12.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone13.svg b/www/img/jbrowse/herringbone13.svg new file mode 100644 index 00000000..2798bb5d --- /dev/null +++ b/www/img/jbrowse/herringbone13.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone14.svg b/www/img/jbrowse/herringbone14.svg new file mode 100644 index 00000000..8b64b9c5 --- /dev/null +++ b/www/img/jbrowse/herringbone14.svg @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone15.png b/www/img/jbrowse/herringbone15.png new file mode 100644 index 00000000..40e06218 Binary files /dev/null and b/www/img/jbrowse/herringbone15.png differ diff --git a/www/img/jbrowse/herringbone15.svg b/www/img/jbrowse/herringbone15.svg new file mode 100644 index 00000000..5b3bdc9f --- /dev/null +++ b/www/img/jbrowse/herringbone15.svg @@ -0,0 +1,527 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone16.png b/www/img/jbrowse/herringbone16.png new file mode 100644 index 00000000..bd5652d9 Binary files /dev/null and b/www/img/jbrowse/herringbone16.png differ diff --git a/www/img/jbrowse/herringbone16.svg b/www/img/jbrowse/herringbone16.svg new file mode 100644 index 00000000..4cd9de34 --- /dev/null +++ b/www/img/jbrowse/herringbone16.svg @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone16m.svg b/www/img/jbrowse/herringbone16m.svg new file mode 100644 index 00000000..4342556b --- /dev/null +++ b/www/img/jbrowse/herringbone16m.svg @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone2.svg b/www/img/jbrowse/herringbone2.svg new file mode 100644 index 00000000..ced6ed25 --- /dev/null +++ b/www/img/jbrowse/herringbone2.svg @@ -0,0 +1,319 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone3.png b/www/img/jbrowse/herringbone3.png new file mode 100644 index 00000000..187cc9b1 Binary files /dev/null and b/www/img/jbrowse/herringbone3.png differ diff --git a/www/img/jbrowse/herringbone3.svg b/www/img/jbrowse/herringbone3.svg new file mode 100644 index 00000000..88f4ad0b --- /dev/null +++ b/www/img/jbrowse/herringbone3.svg @@ -0,0 +1,330 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone4.png b/www/img/jbrowse/herringbone4.png new file mode 100644 index 00000000..df2679e5 Binary files /dev/null and b/www/img/jbrowse/herringbone4.png differ diff --git a/www/img/jbrowse/herringbone4.svg b/www/img/jbrowse/herringbone4.svg new file mode 100644 index 00000000..55f314e6 --- /dev/null +++ b/www/img/jbrowse/herringbone4.svg @@ -0,0 +1,282 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone5.png b/www/img/jbrowse/herringbone5.png new file mode 100644 index 00000000..1c0e58b1 Binary files /dev/null and b/www/img/jbrowse/herringbone5.png differ diff --git a/www/img/jbrowse/herringbone5.svg b/www/img/jbrowse/herringbone5.svg new file mode 100644 index 00000000..2679fec9 --- /dev/null +++ b/www/img/jbrowse/herringbone5.svg @@ -0,0 +1,282 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone6.png b/www/img/jbrowse/herringbone6.png new file mode 100644 index 00000000..fc3d749e Binary files /dev/null and b/www/img/jbrowse/herringbone6.png differ diff --git a/www/img/jbrowse/herringbone6.svg b/www/img/jbrowse/herringbone6.svg new file mode 100644 index 00000000..2206064e --- /dev/null +++ b/www/img/jbrowse/herringbone6.svg @@ -0,0 +1,282 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone7.png b/www/img/jbrowse/herringbone7.png new file mode 100644 index 00000000..e9c77e80 Binary files /dev/null and b/www/img/jbrowse/herringbone7.png differ diff --git a/www/img/jbrowse/herringbone7.svg b/www/img/jbrowse/herringbone7.svg new file mode 100644 index 00000000..0cded020 --- /dev/null +++ b/www/img/jbrowse/herringbone7.svg @@ -0,0 +1,281 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone8.png b/www/img/jbrowse/herringbone8.png new file mode 100644 index 00000000..3282da07 Binary files /dev/null and b/www/img/jbrowse/herringbone8.png differ diff --git a/www/img/jbrowse/herringbone8.svg b/www/img/jbrowse/herringbone8.svg new file mode 100644 index 00000000..76fe6ed2 --- /dev/null +++ b/www/img/jbrowse/herringbone8.svg @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/herringbone9.png b/www/img/jbrowse/herringbone9.png new file mode 100644 index 00000000..c0ea9975 Binary files /dev/null and b/www/img/jbrowse/herringbone9.png differ diff --git a/www/img/jbrowse/herringbone9.svg b/www/img/jbrowse/herringbone9.svg new file mode 100644 index 00000000..163fb6b5 --- /dev/null +++ b/www/img/jbrowse/herringbone9.svg @@ -0,0 +1,316 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/left_arrow.png b/www/img/jbrowse/left_arrow.png new file mode 100644 index 00000000..a2aded69 Binary files /dev/null and b/www/img/jbrowse/left_arrow.png differ diff --git a/www/img/jbrowse/loops.png b/www/img/jbrowse/loops.png new file mode 100644 index 00000000..6bb7d579 Binary files /dev/null and b/www/img/jbrowse/loops.png differ diff --git a/www/img/jbrowse/loops.svg b/www/img/jbrowse/loops.svg new file mode 100644 index 00000000..36d267af --- /dev/null +++ b/www/img/jbrowse/loops.svg @@ -0,0 +1,347 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/minus-arrowhead.png b/www/img/jbrowse/minus-arrowhead.png new file mode 100644 index 00000000..eeb6260f Binary files /dev/null and b/www/img/jbrowse/minus-arrowhead.png differ diff --git a/www/img/jbrowse/minus-cds0.png b/www/img/jbrowse/minus-cds0.png new file mode 100644 index 00000000..7f617c70 Binary files /dev/null and b/www/img/jbrowse/minus-cds0.png differ diff --git a/www/img/jbrowse/minus-cds0.svg b/www/img/jbrowse/minus-cds0.svg new file mode 100644 index 00000000..da4a48d9 --- /dev/null +++ b/www/img/jbrowse/minus-cds0.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/minus-cds1.png b/www/img/jbrowse/minus-cds1.png new file mode 100644 index 00000000..bb9d7475 Binary files /dev/null and b/www/img/jbrowse/minus-cds1.png differ diff --git a/www/img/jbrowse/minus-cds1.svg b/www/img/jbrowse/minus-cds1.svg new file mode 100644 index 00000000..69f45f94 --- /dev/null +++ b/www/img/jbrowse/minus-cds1.svg @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/minus-cds2.png b/www/img/jbrowse/minus-cds2.png new file mode 100644 index 00000000..8c5f7f53 Binary files /dev/null and b/www/img/jbrowse/minus-cds2.png differ diff --git a/www/img/jbrowse/minus-cds2.svg b/www/img/jbrowse/minus-cds2.svg new file mode 100644 index 00000000..0d0108d6 --- /dev/null +++ b/www/img/jbrowse/minus-cds2.svg @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/minus-chevron.png b/www/img/jbrowse/minus-chevron.png new file mode 100644 index 00000000..cd526789 Binary files /dev/null and b/www/img/jbrowse/minus-chevron.png differ diff --git a/www/img/jbrowse/minus-chevron2.png b/www/img/jbrowse/minus-chevron2.png new file mode 100644 index 00000000..3ba1bdaf Binary files /dev/null and b/www/img/jbrowse/minus-chevron2.png differ diff --git a/www/img/jbrowse/minus-chevron3.png b/www/img/jbrowse/minus-chevron3.png new file mode 100644 index 00000000..938bfb41 Binary files /dev/null and b/www/img/jbrowse/minus-chevron3.png differ diff --git a/www/img/jbrowse/minus-herringbone10.png b/www/img/jbrowse/minus-herringbone10.png new file mode 100644 index 00000000..3aaf7b39 Binary files /dev/null and b/www/img/jbrowse/minus-herringbone10.png differ diff --git a/www/img/jbrowse/minus-herringbone11.png b/www/img/jbrowse/minus-herringbone11.png new file mode 100644 index 00000000..9644e715 Binary files /dev/null and b/www/img/jbrowse/minus-herringbone11.png differ diff --git a/www/img/jbrowse/minus-herringbone12.png b/www/img/jbrowse/minus-herringbone12.png new file mode 100644 index 00000000..47eef050 Binary files /dev/null and b/www/img/jbrowse/minus-herringbone12.png differ diff --git a/www/img/jbrowse/minus-herringbone13.png b/www/img/jbrowse/minus-herringbone13.png new file mode 100644 index 00000000..2e5932da Binary files /dev/null and b/www/img/jbrowse/minus-herringbone13.png differ diff --git a/www/img/jbrowse/minus-herringbone14.png b/www/img/jbrowse/minus-herringbone14.png new file mode 100644 index 00000000..07b93b65 Binary files /dev/null and b/www/img/jbrowse/minus-herringbone14.png differ diff --git a/www/img/jbrowse/minus-herringbone16.png b/www/img/jbrowse/minus-herringbone16.png new file mode 100644 index 00000000..9205956a Binary files /dev/null and b/www/img/jbrowse/minus-herringbone16.png differ diff --git a/www/img/jbrowse/minus-pacman.png b/www/img/jbrowse/minus-pacman.png new file mode 100644 index 00000000..7368086f Binary files /dev/null and b/www/img/jbrowse/minus-pacman.png differ diff --git a/www/img/jbrowse/path11828.png b/www/img/jbrowse/path11828.png new file mode 100644 index 00000000..24492df1 Binary files /dev/null and b/www/img/jbrowse/path11828.png differ diff --git a/www/img/jbrowse/path2160.png b/www/img/jbrowse/path2160.png new file mode 100644 index 00000000..968042b8 Binary files /dev/null and b/www/img/jbrowse/path2160.png differ diff --git a/www/img/jbrowse/path3415.png b/www/img/jbrowse/path3415.png new file mode 100644 index 00000000..2561c80c Binary files /dev/null and b/www/img/jbrowse/path3415.png differ diff --git a/www/img/jbrowse/plus-arrowhead.png b/www/img/jbrowse/plus-arrowhead.png new file mode 100644 index 00000000..f4daf7e0 Binary files /dev/null and b/www/img/jbrowse/plus-arrowhead.png differ diff --git a/www/img/jbrowse/plus-cds0.png b/www/img/jbrowse/plus-cds0.png new file mode 100644 index 00000000..640a52d1 Binary files /dev/null and b/www/img/jbrowse/plus-cds0.png differ diff --git a/www/img/jbrowse/plus-cds0.svg b/www/img/jbrowse/plus-cds0.svg new file mode 100644 index 00000000..9903a7de --- /dev/null +++ b/www/img/jbrowse/plus-cds0.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/plus-cds1.png b/www/img/jbrowse/plus-cds1.png new file mode 100644 index 00000000..3132e508 Binary files /dev/null and b/www/img/jbrowse/plus-cds1.png differ diff --git a/www/img/jbrowse/plus-cds1.svg b/www/img/jbrowse/plus-cds1.svg new file mode 100644 index 00000000..6fe1419a --- /dev/null +++ b/www/img/jbrowse/plus-cds1.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/plus-cds2.png b/www/img/jbrowse/plus-cds2.png new file mode 100644 index 00000000..d54e113f Binary files /dev/null and b/www/img/jbrowse/plus-cds2.png differ diff --git a/www/img/jbrowse/plus-cds2.svg b/www/img/jbrowse/plus-cds2.svg new file mode 100644 index 00000000..e80e2280 --- /dev/null +++ b/www/img/jbrowse/plus-cds2.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/plus-chevron.png b/www/img/jbrowse/plus-chevron.png new file mode 100644 index 00000000..59fc5b8e Binary files /dev/null and b/www/img/jbrowse/plus-chevron.png differ diff --git a/www/img/jbrowse/plus-chevron2.png b/www/img/jbrowse/plus-chevron2.png new file mode 100644 index 00000000..28ebbc45 Binary files /dev/null and b/www/img/jbrowse/plus-chevron2.png differ diff --git a/www/img/jbrowse/plus-chevron3.png b/www/img/jbrowse/plus-chevron3.png new file mode 100644 index 00000000..07496990 Binary files /dev/null and b/www/img/jbrowse/plus-chevron3.png differ diff --git a/www/img/jbrowse/plus-herringbone10.png b/www/img/jbrowse/plus-herringbone10.png new file mode 100644 index 00000000..c24eac81 Binary files /dev/null and b/www/img/jbrowse/plus-herringbone10.png differ diff --git a/www/img/jbrowse/plus-herringbone11.png b/www/img/jbrowse/plus-herringbone11.png new file mode 100644 index 00000000..320f58f6 Binary files /dev/null and b/www/img/jbrowse/plus-herringbone11.png differ diff --git a/www/img/jbrowse/plus-herringbone12.png b/www/img/jbrowse/plus-herringbone12.png new file mode 100644 index 00000000..52821a82 Binary files /dev/null and b/www/img/jbrowse/plus-herringbone12.png differ diff --git a/www/img/jbrowse/plus-herringbone13.png b/www/img/jbrowse/plus-herringbone13.png new file mode 100644 index 00000000..043dbcfc Binary files /dev/null and b/www/img/jbrowse/plus-herringbone13.png differ diff --git a/www/img/jbrowse/plus-herringbone14.png b/www/img/jbrowse/plus-herringbone14.png new file mode 100644 index 00000000..01447c85 Binary files /dev/null and b/www/img/jbrowse/plus-herringbone14.png differ diff --git a/www/img/jbrowse/plus-herringbone16.png b/www/img/jbrowse/plus-herringbone16.png new file mode 100644 index 00000000..33787782 Binary files /dev/null and b/www/img/jbrowse/plus-herringbone16.png differ diff --git a/www/img/jbrowse/plus-pacman.png b/www/img/jbrowse/plus-pacman.png new file mode 100644 index 00000000..f9ddfcbb Binary files /dev/null and b/www/img/jbrowse/plus-pacman.png differ diff --git a/www/img/jbrowse/plus-pacman.svg b/www/img/jbrowse/plus-pacman.svg new file mode 100644 index 00000000..f749b72e --- /dev/null +++ b/www/img/jbrowse/plus-pacman.svg @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/pluswalk-green.svg b/www/img/jbrowse/pluswalk-green.svg new file mode 100644 index 00000000..e86e1c2b --- /dev/null +++ b/www/img/jbrowse/pluswalk-green.svg @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/pluswalk-orange.svg b/www/img/jbrowse/pluswalk-orange.svg new file mode 100644 index 00000000..31cc24f2 --- /dev/null +++ b/www/img/jbrowse/pluswalk-orange.svg @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/red_crosshatch_bg.png b/www/img/jbrowse/red_crosshatch_bg.png new file mode 100644 index 00000000..783c4c45 Binary files /dev/null and b/www/img/jbrowse/red_crosshatch_bg.png differ diff --git a/www/img/jbrowse/red_x.png b/www/img/jbrowse/red_x.png new file mode 100644 index 00000000..ce9a15fe Binary files /dev/null and b/www/img/jbrowse/red_x.png differ diff --git a/www/img/jbrowse/right_arrow.png b/www/img/jbrowse/right_arrow.png new file mode 100644 index 00000000..3d1d1084 Binary files /dev/null and b/www/img/jbrowse/right_arrow.png differ diff --git a/www/img/jbrowse/right_arrow.svg b/www/img/jbrowse/right_arrow.svg new file mode 100644 index 00000000..ce95365e --- /dev/null +++ b/www/img/jbrowse/right_arrow.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/www/img/jbrowse/slide-left.png b/www/img/jbrowse/slide-left.png new file mode 100644 index 00000000..ff56792a Binary files /dev/null and b/www/img/jbrowse/slide-left.png differ diff --git a/www/img/jbrowse/slide-left.svg b/www/img/jbrowse/slide-left.svg new file mode 100644 index 00000000..7f7d1f4e --- /dev/null +++ b/www/img/jbrowse/slide-left.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/www/img/jbrowse/slide-right.png b/www/img/jbrowse/slide-right.png new file mode 100644 index 00000000..4f6bb368 Binary files /dev/null and b/www/img/jbrowse/slide-right.png differ diff --git a/www/img/jbrowse/slide-right.svg b/www/img/jbrowse/slide-right.svg new file mode 100644 index 00000000..e34712d1 --- /dev/null +++ b/www/img/jbrowse/slide-right.svg @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/www/img/jbrowse/spinner.gif b/www/img/jbrowse/spinner.gif new file mode 100644 index 00000000..529e72f4 Binary files /dev/null and b/www/img/jbrowse/spinner.gif differ diff --git a/www/img/jbrowse/spriteArrows.png b/www/img/jbrowse/spriteArrows.png new file mode 100644 index 00000000..6746ea22 Binary files /dev/null and b/www/img/jbrowse/spriteArrows.png differ diff --git a/www/img/jbrowse/tracklist_bg.png b/www/img/jbrowse/tracklist_bg.png new file mode 100644 index 00000000..028d418c Binary files /dev/null and b/www/img/jbrowse/tracklist_bg.png differ diff --git a/www/img/jbrowse/transcript.png b/www/img/jbrowse/transcript.png new file mode 100644 index 00000000..dde0b860 Binary files /dev/null and b/www/img/jbrowse/transcript.png differ diff --git a/www/img/jbrowse/transcript.svg b/www/img/jbrowse/transcript.svg new file mode 100644 index 00000000..361653ec --- /dev/null +++ b/www/img/jbrowse/transcript.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + diff --git a/www/img/jbrowse/utr.png b/www/img/jbrowse/utr.png new file mode 100644 index 00000000..0e08af70 Binary files /dev/null and b/www/img/jbrowse/utr.png differ diff --git a/www/img/jbrowse/utr.svg b/www/img/jbrowse/utr.svg new file mode 100644 index 00000000..9eac0e00 --- /dev/null +++ b/www/img/jbrowse/utr.svg @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/www/img/jbrowse/zoom-in-1.png b/www/img/jbrowse/zoom-in-1.png new file mode 100644 index 00000000..cc8c83c2 Binary files /dev/null and b/www/img/jbrowse/zoom-in-1.png differ diff --git a/www/img/jbrowse/zoom-in-1.svg b/www/img/jbrowse/zoom-in-1.svg new file mode 100644 index 00000000..3b663c6f --- /dev/null +++ b/www/img/jbrowse/zoom-in-1.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/www/img/jbrowse/zoom-in-2.png b/www/img/jbrowse/zoom-in-2.png new file mode 100644 index 00000000..751bb730 Binary files /dev/null and b/www/img/jbrowse/zoom-in-2.png differ diff --git a/www/img/jbrowse/zoom-in-2.svg b/www/img/jbrowse/zoom-in-2.svg new file mode 100644 index 00000000..f2c59a77 --- /dev/null +++ b/www/img/jbrowse/zoom-in-2.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/www/img/jbrowse/zoom-out-1.png b/www/img/jbrowse/zoom-out-1.png new file mode 100644 index 00000000..31cb3dc8 Binary files /dev/null and b/www/img/jbrowse/zoom-out-1.png differ diff --git a/www/img/jbrowse/zoom-out-1.svg b/www/img/jbrowse/zoom-out-1.svg new file mode 100644 index 00000000..e65a2659 --- /dev/null +++ b/www/img/jbrowse/zoom-out-1.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/www/img/jbrowse/zoom-out-2.png b/www/img/jbrowse/zoom-out-2.png new file mode 100644 index 00000000..83b90d19 Binary files /dev/null and b/www/img/jbrowse/zoom-out-2.png differ diff --git a/www/img/jbrowse/zoom-out-2.svg b/www/img/jbrowse/zoom-out-2.svg new file mode 100644 index 00000000..fe0c51ac --- /dev/null +++ b/www/img/jbrowse/zoom-out-2.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/www/img/nostrap-bbsrc-colour.png b/www/img/nostrap-bbsrc-colour.png new file mode 100644 index 00000000..bbabb0b9 Binary files /dev/null and b/www/img/nostrap-bbsrc-colour.png differ diff --git a/www/img/qmul-logo.png b/www/img/qmul-logo.png new file mode 100644 index 00000000..06c1a5ce Binary files /dev/null and b/www/img/qmul-logo.png differ diff --git a/www/index.html b/www/index.html new file mode 100644 index 00000000..e0fdd056 --- /dev/null +++ b/www/index.html @@ -0,0 +1,31 @@ + + + + + Afra + + + + + + diff --git a/www/styles.less b/www/styles.less new file mode 100644 index 00000000..78661802 --- /dev/null +++ b/www/styles.less @@ -0,0 +1,94 @@ +// Pick what we want from Bootstrap. +@import "lib/bootstrap/less/variables.less"; +@import "lib/bootstrap/less/mixins.less"; +@import "lib/bootstrap/less/normalize.less"; +@import "lib/bootstrap/less/scaffolding.less"; +@import "lib/bootstrap/less/grid.less"; +@import "lib/bootstrap/less/type.less"; +@import "lib/bootstrap/less/code.less"; +@import "lib/bootstrap/less/forms.less"; +@import "lib/bootstrap/less/tables.less"; + +@import "lib/bootstrap/less/dropdowns.less"; +@import "lib/bootstrap/less/list-group.less"; +@import "lib/bootstrap/less/panels.less"; +@import "lib/bootstrap/less/wells.less"; +@import "lib/bootstrap/less/component-animations.less"; +@import "lib/bootstrap/less/close.less"; + +@import "lib/bootstrap/less/buttons.less"; +@import "lib/bootstrap/less/button-groups.less"; +@import "lib/bootstrap/less/alerts.less"; + +@import "lib/bootstrap/less/navs.less"; +@import "lib/bootstrap/less/navbar.less"; + +@import "lib/bootstrap/less/modals.less"; +@import "lib/bootstrap/less/tooltip.less"; +@import "lib/bootstrap/less/popovers.less"; + +@import "lib/bootstrap/less/thumbnails.less"; +@import "lib/bootstrap/less/media.less"; +@import "lib/bootstrap/less/labels.less"; +@import "lib/bootstrap/less/badges.less"; +@import "lib/bootstrap/less/progress-bars.less"; +@import "lib/bootstrap/less/carousel.less"; +@import "lib/bootstrap/less/jumbotron.less"; +@import "lib/bootstrap/less/utilities.less"; + +// Use Font Awesome (http://fontawesome.io/) instead of Bootstrap's glyphicons. +@import "lib/font-awesome/less/variables.less"; +@import "lib/font-awesome/less/mixins.less"; + +@fa-font-path: "../lib/font-awesome/fonts"; + +@import "lib/font-awesome/less/path"; +@import "lib/font-awesome/less/core"; +@import "lib/font-awesome/less/larger"; +@import "lib/font-awesome/less/fixed-width"; +@import "lib/font-awesome/less/list"; +@import "lib/font-awesome/less/bordered-pulled"; +@import "lib/font-awesome/less/spinning"; +@import "lib/font-awesome/less/rotated-flipped"; +@import "lib/font-awesome/less/stacked"; +@import "lib/font-awesome/less/icons"; + +// Animate.css +@import "lib/animate.css/animate.css"; + +// Global styles. +html, body { + height: 100%; + width: 100%; + padding: 0; + border: 0; +} + +.navbar-brand { + font-size: floor(@font-size-base * 2.15); +} + +.navbar .container-full { + padding-left: 15px; + padding-right: 15px; +} + +.navbar .container-full #user-links { + margin-left: -10px; +} + +article { + padding-top: (@navbar-height + 1); +} + +article > section { + margin-bottom: 30px; +} + +nav > section > ul { + margin-top: 15px; +} + +@import 'styles/about'; +@import 'styles/curate'; +@import 'styles/dashboard'; diff --git a/www/styles/about.less b/www/styles/about.less new file mode 100644 index 00000000..5e45c68d --- /dev/null +++ b/www/styles/about.less @@ -0,0 +1,48 @@ +.justified { + text-align: justify; +} + +section.user { + float: right; + display: block; + padding: 15px 20px 15px; + margin-left: -20px; + font-size: 20px; + font-weight: 200; + color: #ffffff; + text-shadow: 0 1px 0 #54b4eb; +} + +.problem p { + margin-left: 15px; + .justified; +} + +.the-deal li { + padding: 20px; + padding-top: 0px; + .justified; +} + +.the-deal li:last-child { + padding-bottom: 0px; +} + +.auth { + width: 100%; + height: 340px; + display: table; +} + +.auth > div { + display: table-cell; + vertical-align: middle; +} + +.form-group .btn { + width: 100%; +} + +footer { + text-align: center; +} diff --git a/www/styles/curate.less b/www/styles/curate.less new file mode 100644 index 00000000..fc98bf28 --- /dev/null +++ b/www/styles/curate.less @@ -0,0 +1,2197 @@ +@import url("../lib/dijit/themes/tundra/tundra.css"); +@import url("../lib/jquery.ui/themes/base/jquery.ui.all.css"); + +.curate { + height: 100%; + padding-left: 15px; + padding-right: 15px; +} + +.curate .row { + height: 100%; +} + +.curate .row [class*='col-'] { + height: 100%; +} + +.curate .row .col-md-9 { + padding: 0px; +} + +.controls-top { + top: 18px; + right: 0px; + z-index: 10; + position: absolute; + background: white; + opacity: 0.9; +} + +#genome { + width: 100%; + height: 100%; + font-size: 12px; + font-family: Univers,Trebuchet MS,Helvetica,Arial,sans-serif; +} + +.controls-bot { + bottom: 1px; + right: 0px; + z-index: 10; + position: absolute; + width: 157px; +} + +.panel-title { + cursor: pointer; +} + +.container { + height: 100%; +} + +.tundra input { + outline: none; +} + +.ghosted { + color: #aaa; +} + +fieldset { + padding-left: 1em; + margin: 0.7em 0.5em; +} +fieldset > legend { + font-weight: bold; + margin-left: -1em; +} + +div.container { + position: absolute; + z-index: 0; +} + +div.dragWindow { + position:absolute; + overflow: hidden; + z-index: 1; +} + +div.vertical_scrollbar { + display: none; + width: 6px; + background: #eee; + background: rgba(235, 235, 235, 0.62); + border-left: 1px solid #DDD9D9; +} +div.vertical_scrollbar .vertical_position_marker { + background: #555; + opacity: 0.8; + border-radius: 5px; + width: 100%; + border-right: 1px solid #ccc; +} + +.draggable { + cursor: move; +} + +.rubberBandAvailable { + cursor: crosshair; +} + +div.locationTrap { + position: absolute; + z-index: -10; + height: 0; + top: 0; + left: 0; + border-color: transparent; + border-style: solid; + border-bottom-color: #A9C6EB; + border-top: 0px dotted transparent; +} + +div.locationThumb { + position: absolute; + top: 0px; + /* if you change this border from 2px, change GenomeView.showTrap */ + border: 2px solid red; + margin: 0px -2px 0px -2px; + height: 23px; + cursor: move; + background: rgba(0, 121, 245, 0.1); +} + +div.locationThumb.dojoMoveItem { + cursor: move; +} + +div.topLink { + position: absolute; + right: 0; + top: 0; + z-index: 50; + background: white; + border: 1px solid #888; + border-width: 0 0 1px 1px; +} + +a.topLink { + padding: 0 0.5ex 0 0.5ex; + text-decoration: none; + color: blue; +} + +div.overview { + position: relative; + width: 100%; + padding: 4px 0 0 0; + z-index: -5; + display: block; + height: 23px; + + background: #FAFAFA url(../lib/dijit/themes/tundra/images/titleBar.png) repeat-x top left; + + border-style: solid; + border-width: 1px 0px 1px 0px; + border-color: #555; + + color:#aaa; + text-align: center; + cursor: crosshair; +} + +div.block { + position: absolute; + overflow: visible; + top: 0px; + height: 100%; +} + +div.block.timed_out { + background: #ddd; + background: rgba( 0,0,0, 0.1 ); +} + +div.track { + position: absolute; + left: 0px; + width: 100%; + z-index: 5; + padding: 0; + margin: 0; +} + +.track.dojoDndItemOver { + cursor: inherit; + background: inherit; +} +.track.dojoDndItemAnchor { + background: inherit; +} + +.track.dojoDndItemBefore { + border-top: 3px solid #999; + margin-top: -3px; +} + +.track.dojoDndItemAfter { + border-bottom: 3px solid #999; + margin-bottom: -3px; +} + +div#static_track { + top: 0px; + position: absolute; + z-index: 20; +} + +div.gridline { + position: absolute; + left: 0px; + top: 0px + width: 0px; + height: 100%; + border-style: none none none solid; + border-width: 1px; + border-color: red; + z-index: 0; +} + +div.gridline_major { + border-color: #bbb; +} + +div.gridline_minor { + border-color: #eee; +} + +div.pos-label { + position: absolute; + left: -0.35em; + top: 0px; + z-index: 100; + margin: 1px; + font-family: sans-serif; +} + +div.overview-pos { + position: absolute; + left: 0px; + color: black; + padding-left: 4px; + font-family: sans-serif; + border: 0; +} + +div.overview-pos:first-child { + margin-left: 1px; +} + +div.blank-block { + font-family: sans-serif; + position: absolute; + overflow: visible; + top: 0px; + height: 100%; + background-color: white; + z-index: 19; +} + +div.sequence { + position: absolute; + left: 0px; +} +div.sequence .highlighted { + background: #ff0; +} +div.sequence .revcom { + color: red; +} +div.sequence .base, div.sequence > div { + height: 14px; + line-height: 14px; +} +div.sequence .base.big { + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; + + border: 1px solid #ccc; + border-right-color: #333; + border-bottom-color: #333; + height: 16px; +} + +div.sequence > div { + border-top: 1px solid #4d4d4d; + border-bottom: 1px solid black; + background: #C6C6C6; +} +div.sequence > div:first-child { + border-bottom-color: #4d4d4d; +} + +span.base { + display: inline-block; + text-align: center; + z-index: 2000; +} +.base { + color: black; + font-family: Courier New,monospace; + font-weight: bold; + text-shadow: white 0px 0px 1px; +} +.base_n { + background-color: #C6C6C6; +} +.base_a { + background-color: #00BF00; + background-color: rgba( 0, 191, 0, 0.9 ); +} +.base_c { + background-color: #4747ff; + background-color: rgba( 71, 71, 255, 0.9); +} +.base_t { + background-color: red; + background-color: rgba( 255, 0, 0, 0.9); +} +.base_g { + background-color: #d5bb04; + background-color: rgba( 213, 187, 4, 0.9); +} +.base_reference { + background-color: #999; +} + +div.sequence_blur { + background: none; + height: 8px; + border-top: 2px solid #aaa; + border-bottom: 2px solid #f55; + border-left-width: 0; + border-right-width: 0; + margin-top: 5px; +} + +div.track-label, div.tracklist-label { + z-index: 20; + + padding: 1px 6px; + overflow: hidden; + cursor: pointer; + + border-width: 1px; + border-style: solid; + + border-top-color: #999; + border-left-color: #999; + border-bottom-color: #555; + border-right-color: #555; + + border-radius: 6px; + + color: #111; + font-weight: bold; + + width: 110px; + text-align: center; +} + +/* NOTE: browsers that don't support rgba colors will fall back to all + track labels being #bcbcbc */ + +.notfound-dialog .message { + margin: 1em; + text-align: center; +} + +.track .loading { + background: #fafafa; + color: #777; + margin: 0; + font-weight: bold; + height: 100%; + width: 100%; + z-index: 15; + position: absolute; +} +.track .loading .text { + display: inline; + line-height: 40px; + margin: 1em; +} + +div.track-label { + color: black; + margin: 0px 0 0 0px; + overflow: show; + background: white; + padding: 0 5px; + + height: 23px; + line-height: 23px; + + z-index: 20; + border-color: #eee; + opacity: 1.0; + + /* setting white-space to "nowrap" prevents Chrome-specific bug with + label text sometimes disappearing after zoom in Chrome was + wrapping track-label text to next line, which falls outside of + track-label fixed height and therefore not seen. see chromium + bug report for more details on underlying issue: + http://code.google.com/p/chromium/issues/detail?id=138918 + */ + white-space: nowrap; +} + +div.track-label:hover { +} + +div.track-label .track-label-text { + display: inline-block; + white-space: nowrap; +} + +div.track-label .feature-density { + font-size: 90%; + font-weight: normal; +} + +/* styles for feature labels */ +.feature-label { + position: absolute; + border: 0px; + margin: -2px 0px 0px 0px; + /* padding: 0px 0px 2px 0px; for more space below labels */ + padding: 0px 0px 0px 0px; + /* font-size: 80%; */ + white-space: nowrap; + z-index: 10; + cursor: pointer; +} +a.feature-label { + color: black; +} +.feature-description { + color: blue; + margin-top: -0.2em; +} + +.rubber-highlight { + border: 1px solid black; + height: 100%; + border-color: rgba( 0, 0, 0, 0.6 ); + background-color: #8087ff; + background-color: rgba( 128, 136, 255, 0.6 ); + padding: 0; + margin: 0; + overflow: hidden; + cursor: crosshair; +} +.rubber-highlight div { + color: white; + padding: 0; + margin-top: 30px; + font-size: 160%; + text-align: center; + font-weight: bold; + text-shadow: #6374AB 1px 1px 0; +} +div.overview .rubber-highlight { + font-size: 0; + height: 100%; + margin-top: -4px; /* corresponds to padding on div.overview */ + border-top: none; + border-bottom: none; +} +div.overview .rubber-highlight * { + display: none; +} + +.fatal_error { + font-size: 14px; + margin: 1em; +} +div.error, div.message { + margin: 1px 1em; + padding: 2px 6px; + border: 1px outset rgba( 0, 0, 0, 0.3 ); +} +div.error { + background: #ff8888; +} +div.track > div.error { + width: 30em; + position: absolute; +} +div.error h2 { + margin-top: 0; +} + +div.error .codecaption { + font-size: 90%; + font-weight: bold; + margin-top: 1em; + margin-left: 0.2em; +} +div.error code { + display: block; + font-size: 10px; + padding: 0.4em 1.2em; + margin: 0 0.3em 0.3em 0.3em; + overflow: auto; + max-height: 6em; +} +div.message { + background: #eee; +} +div.block > div.message { + margin-top: 1em; + position: absolute; +} +div.block:hover > div.message { + z-index: 30000; +} + +.tundra .dijitDialogPaneContent { + border-top: 1px solid #acacac; +} + +/* styles dealing with popups launched by clicking on features */ +.popup-dialog-iframe .dijitDialogPaneContent { + padding: 0; +} + +.export-view-dialog .dijitDialogPaneContent { + background: #fafafa; +} + +/* styles for popup dialogs */ +a.dialog-new-window { + padding-left: 1em; + font-size: 90%; +} + +/* styles for popup feature detail dialogs from tracks */ +.feature-detail { + width: 50em; + color: #333; +} +.feature-detail .subfeature-detail { + background: #fafafa; + background: rgba( 0, 0, 0, 0.1 ); + border: 1px outset #B9B9B9; + padding: 0.6em; +} +.feature-detail .fasta { + clear: both; + padding: 1em 1.5em; + margin: 0.2em 0; + border: 1px solid #aaa; + background: transparent; +} +.feature-detail div.core { + font-size: 110%; +} + +.feature-detail div.core h2.sectiontitle { + margin-top: 0; +} +.feature-detail h2.sectiontitle { + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + margin: 1em 0 0.7em 0; +} + +.detail .value { + margin-left: 1em; + display: inline-block; + vertical-align: top; + font-family: sans-serif; +} +.detail .field { + margin: 0; + display: inline-block; + min-width: 90px; + vertical-align: top; +} + +/* + force long sequences in feature and alignment detail dialogs to + wrap at 45em +*/ +.detail .value.seq, + .detail .value.sequence { + word-wrap: break-word; + width: 45em; +} + +.detail .field_container { + padding: 0 4em 0 1em; +} + +.sharePane input { + padding: 1px 0 2px 1px; +} +.sharePane .copyReminder { + background-color: #396494; + text-align: center; + width: 50%; + margin: 0 auto; + color: white; + padding: 2px; + font-weight: bold; +} + +.tundra .sharePane input { + border: 1px solid #ccc; +} + +/*styles for vertical line and BP label*/ +.basePairLabel { + color: black; + position: fixed; + font-weight: bold; + font-size: 9px; + display: none; + background: #fefefe; + padding: 0 0.7em; + z-index: 1000; + text-align: center; + cursor: crosshair; + border: 1px solid #888; +} + +.basePairLabel.rubber { + z-index: 25; +} + +.trackVerticalPositionIndicatorMain { + position: fixed; + display: none; + cursor: crosshair; + left: -2px; + height: 100%; + width: 1px; + background-color: #FF0000; + z-index: 15; + top: 0; +} + +/* styles for per-base quality table in alignment detail pages */ +.baseQuality { + font-family: Courier New, monospace; +} +table.baseQuality { + margin-bottom: 1em; +} +table.baseQuality td { + padding: 0 0.2em; + line-height: 0.95; + text-align: center; +} +table.baseQuality tr.seq td { + padding-top: 0.8em; + font-weight: bold; +} + +@import url("../lib/dojox/form/resources/UploaderFileList.css"); +.fileDialog { + color: #333; +} + +.fileDialog label { + font-weight: bold; + padding: 0 0.5em; +} +.fileDialog th { + font-weight: bold; + border-bottom: 2px solid black; +} +.fileDialog .dijitDialogPaneContent > div.intro { + width: 27em; + text-align: justify; + position: relative; + left: 12%; + margin: 1.4em 0 2.4em 0; +} + +.fileDialog .connector { + background: #333; + height: 6px; + width: 12px; + position: absolute; + + bottom: -6px; + left: 50%; + margin-left: -6px; +} + +.fileDialog h2, .fileDialog h3 { + margin: 0; + padding: 0; + font-size: 125%; +} + +.fileDialog .dijitDialogPaneContent > div { + position: relative; + + width: 40em; + padding: 0 0 0.75em 0; + margin: 6px 0; +} +.fileDialog div.aux { + text-align: center; + margin-bottom: 1em; +} +.fileDialog .resourceControls { + height: 10em; + position: relative; +} +.fileDialog .resourceControls > div { + width: 19.5em; + box-sizing: border-box; + height: 100%; +} +.fileDialog .resourceControls > div > h3 { + height: 19%; +} + +.fileDialog .localFilesControl { + position: absolute; + top: 0; + left: 0; +} + +.fileDialog .dijitUploader { + position: absolute; + margin: 0; +} + +.fileDialog .remoteURLsControl textarea, +.fileDialog .localFilesControl .dragArea { + height: 81%; + position: relative; + border: 1px solid #b3b3b3; + width: 100%; + box-sizing: border-box; +} +.fileDialog .localFilesControl .dragArea:hover { + border: 1px dashed green; +} + +.fileDialog .localFilesControl .dragArea .dragMessage { + height: 2em; + position: absolute; + top: 60%; + font-weight: bold; + margin-top: -1em; + text-align: center; + width: 100%; +} +.fileDialog .remoteURLsControl textarea { + font-size: 10px; + background: #f2f2f2; +} +.fileDialog .remoteURLsControl textarea:hover { + background: white; + border-color: #333; +} + +.fileDialog .remoteURLsControl { + position: absolute; + top: 0; + right: 0; +} + +.fileDialog .resourceList { + background: #bcd3ef; +} + +.fileDialog .dijitSelect td.dijitStretch { + width: 6em; +} +.fileDialog .resourceList > h3, .fileDialog .trackList > h3 { + padding: 0 0.6em; + line-height: 2.1; + margin-bottom: 0.5em; +} + +.fileDialog .emptyMessage { + width: 100%; + font-size: 110%; + color: #686868; + font-weight: bold; + text-align: center; + line-height: 4; +} + +.fileDialog .trackList { + background: #8cb1dd; +} + +.fileDialog .resourceList > table, .fileDialog .trackList > table { + width: 95%; + padding: 0 0.75em 0.5em 0.75em; + margin: 0 auto; + border-collapse: collapse; +} + +/* CSS styles for the various types of feature glyphs */ +/* + NOTES ON STYLING FEATURES: + - avoid using any margins in feature styles. Layout is done + by JBrowse. + + - when possible, make all element heights an odd number of + pixels, so that vertical centering is possible with + pixel-perfect accuracy. +*/ + +.basic, +.plus-basic, +.minus-basic { + position: absolute; + cursor: pointer; + z-index: 10; + min-width: 1px; +} + +div.hist { + position: absolute; + z-index: 10; + + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +} + +.feature, +.plus-feature, +.minus-feature { + position:absolute; + height: 7px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; + z-index: 10; + background-color: #eee; + + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +} + +.plus-feature { background-image: url('img/plus-chevron3.png'); } +.minus-feature { background-image: url('img/minus-chevron3.png'); } + +.subfeature, +.plus-subfeature, +.minus-subfeature { + position:absolute; + background-color: #dadada; + height: 11px; + min-width: 1px; + z-index: 12; + + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +} + +.alignment, +.plus-alignment, +.minus-alignment { + background-color: #ccc; + + /* these should all 3 match */ + height: 11px; + line-height: 11px; + font-size: 11px; +} +.plus-alignment { + background-color: #EC8B8B; +} +.minus-alignment { + background-color: #898FD8; +} +.alignment.missing_mate, .plus-alignment.missing_mate, .minus-alignment.missing_mate { + background-image: url('img/red_crosshatch_bg.png'); +} + +.alignment > .mismatch, .minus-alignment > .mismatch, .plus-alignment > .mismatch { + height: 100%; +} +.alignment > .deletion, .minus-alignment > .deletion, .plus-alignment > .deletion { + background-color: black; + height: 100%; +} +.alignment > .deletion *, .plus-alignment > .deletion *, .minus-alignment > .deletion * { + color: white; +} +.alignment > .insertion, .plus-alignment > .insertion, .minus-alignment > .insertion { + background-color: white; + color: black; + height: 100%; +} +.alignment > .skip, .plus-alignment > .skip, .minus-alignment > .skip { + background: url('img/dark_20x2.png') repeat-x 0 50% white; + height: 100%; + opacity: 0.7; +} + +div.feature-hist { + background-color: blue; + border-color: #5858C4; +} + +.feature2, .plus-feature2, .minus-feature2 { + position:absolute; + height: 7px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; + z-index: 10; + background-color: #62d335; +} + +div.feature2-hist { + background-color: #9f9; + border-color: #ada; +} + +.feature3, .plus-feature3, .minus-feature3 { + position:absolute; + height: 7px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; + z-index: 10; + background-color: goldenrod; +} + +div.feature3-hist { + background-color: yellow; + border-color: black; +} + +.feature4, .plus-feature4, .minus-feature4 { + position:absolute; + height: 11px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; + z-index: 10; + background: yellow; +} + +div.feature4-hist { + background-color: yellow; + border-color: black; +} + +.feature5, .plus-feature5, .minus-feature5 { + position:absolute; + height: 7px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; + z-index: 10; + background-color: blue; +} + +div.feature5-hist { + background-color: blue; + border-color: lightblue; +} + +div.exon-hist { + background-color: #4B76E8; + border-color: #00f; +} + +div.est-hist { + background-color: #ED9185; + border-color: #c33; +} + +.est, +.plus-est, +.minus-est { + position: absolute; + height: 7px; + background-color: #ED9185; +} + +.dblhelix, +.plus-dblhelix, +.minus-dblhelix { + position:absolute; + height: 11px; + background-image: url('img/dblhelix-red.png'); + background-repeat: repeat-x; + min-width: 1px; + cursor: pointer; + z-index: 10; +} + +div.dblhelix-hist { + background-color: #fcc; + border-color: #daa; +} + +.plus-helix, +.minus-helix { + position:absolute; + height: 11px; + background-image: url('img/helix3-green.png'); + background-repeat: repeat-x; + min-width: 1px; + cursor: pointer; + z-index: 10; +} + +div.helix-hist { + background-color: #cfc; + border-color: #ada; +} + +.loops, .minus-loops, .plus-loops { + position:absolute; + height: 13px; + background-image: url('img/loops.png'); + background-repeat: repeat-x; + cursor: pointer; +} + +.plus-cds, .minus-cds { + position:absolute; + height: 13px; + background-repeat: repeat-x; + cursor: pointer; + min-width: 1px; +} + +.plus-cds_phase0 { background-image: url('img/plus-cds0.png'); } +.plus-cds_phase1 { background-image: url('img/plus-cds1.png'); } +.plus-cds_phase2 { background-image: url('img/plus-cds2.png'); } +.minus-cds_phase0 { background-image: url('img/minus-cds0.png'); } +.minus-cds_phase1 { background-image: url('img/minus-cds1.png'); } +.minus-cds_phase2 { background-image: url('img/minus-cds2.png'); } + +div.cds-hist { + background-color: #fcc; + border-color: #daa; +} + +.topbracket { + position:absolute; + height: 8px; + border-style: solid solid none solid; + border-width: 2px; + border-color: orange; + cursor: pointer; +} + +.bottombracket { + position:absolute; + height: 8px; + border-style: none solid solid solid; + border-width: 2px; + border-color: green; + cursor: pointer; +} + +.hourglass, .plus-hourglass, .minus-hourglass { + position:absolute; + height: 0px; + border-style: solid; + border-width: 6px 3px 6px 3px; + cursor: pointer; +} + +.triangle, .plus-triangle, .minus-triangle { + position:absolute; + height: 0px; + border-style: solid; + border-width: 6px 3px 0px 3px; + cursor: pointer; +} + +.hgred { + border-color: #f99 white #f99 white; +} +div.hgred-hist { + background-color: #daa; + border-color: #d44; +} + +.hgblue { + border-color: #99f white #99f white; +} +div.hgblue-hist { + background-color: #aad; + border-color: #99f; +} + +.ibeam, .plus-ibeam, .minus-ibeam { + position:absolute; + height: 2px; + background-color: blue; + border-style: solid; + border-width: 8px 4px 8px 4px; + border-color: white blue white blue; + cursor: pointer; +} + +div.transcript-hist { + background-color: #ddd; + border-color: #FF9185; +} + +.transcript, +.plus-transcript, +.minus-transcript { + position: absolute; + height: 11px; + background: url('../img/jbrowse/dark_20x3.png') repeat-x 0 4px; + z-index: 6; + min-width: 1px; + cursor: pointer; +} + +.plus-transcript-arrowhead, +.plus-arrowhead { + position: absolute; + width: 12px; + height: 100%; + background-image: url('../img/jbrowse/plus-arrowhead.png'); + background-repeat: no-repeat; + background-position: left center; /* center image vertically */ + } + +.minus-transcript-arrowhead, +.minus-arrowhead { + position: absolute; + width: 12px; + height: 100%; + background-image: url('../img/jbrowse/minus-arrowhead.png'); + background-repeat: no-repeat; + background-position: right center; /* center image vertically */ +} + +/* introns are hidden by default */ +.plus-intron, .minus-intron { + display: none; +} + +/* can also set a class of 'hidden' to hide something */ +.hidden, .plus-hidden, .minus-hidden { + display: none; +} + +.plus-CDS, +.plus-transcript-CDS, +.minus-CDS, +.minus-transcript-CDS { + position: absolute; + height: 11px; + background-color: #B66; + cursor: pointer; + z-index: 10; + min-width: 1px; +} + +.plus-exon, +.minus-exon, +.plus-UTR, +.minus-UTR, +.plus-five_prime_UTR, +.minus-five_prime_UTR, +.plus-three_prime_UTR, +.minus-three_prime_UTR { + position: absolute; + height: 11px; + background-color: #B66; + z-index: 8; + min-width: 1px; + cursor: pointer; +} + +.generic_parent, +.plus-generic_parent, +.minus-generic_parent { + position: absolute; + height: 11px; + background: url('../img/jbrowse/dark_20x3.png') repeat-x 0 4px white; + z-index: 6; + min-width: 1px; + cursor: pointer; +} + +div.generic_parent-hist { + background-color: #ddd; + border-color: #555; +} + +.match_part, +.plus-match_part, +.minus-match_part { + position: absolute; + height: 11px; + background-color: #66B; + z-index: 8; + min-width: 1px; + cursor: pointer; +} + +.generic_part_a, +.plus-generic_part_a, +.minus-generic_part_a { + position: absolute; + height: 4px; + background-color: #6B6; + border-style: solid; + border-color: #8D8; + border-width: 2px 0px 2px 0px; + z-index: 8; + min-width: 1px; + cursor: pointer; +} + + +/* floating score display for wiggle tracks */ +.wiggleValueDisplay { + background: #FFFEF0; + border: 1px solid #aaa; + padding: 2px; + font-family: Courier New, monospace; + font-weight: bold; + cursor: default; + + -moz-box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); + -webkit-box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); + box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); +} +.wiggleValueDisplay table { + border-spacing: 0; +} +.wiggleValueDisplay td { + padding: 0.2em 0.4em; +} +.wiggleValueDisplay td.count, .wiggleValueDisplay td.pct { + text-align: right; +} +.wiggleValueDisplay tr.total > td { + border-top: 1px solid #aaa; + font-weight: bold; +} + +.wigglePositionIndicator { + background: #333; + border: none; + width: 1px; + cursor: default; +} + +/* Dojo and Dijit stuff */ + +@import url("../lib/dojox/grid/resources/tundraGrid.css"); +@import url("../lib/dijit/themes/tundra/layout/AccordionContainer.css"); + + +.dojoxGrid table { + margin: 0; +} +.dojoxGridRowSelectorStatusText { + display: none; +} + +/* JBrowse stuff */ + +#faceted_tracksel { + position: fixed; + top: 0; + left: 0; + height: 100%; +} + +#faceted_tracksel.active { + -moz-box-shadow: 4px 4px 10px 3px rgba( 30, 30, 50, 0.3 ); + -webkit-box-shadow: 4px 4px 10px 3px rgba( 30, 30, 50, 0.3 ); + box-shadow: 4px 4px 10px 3px rgba( 30, 30, 50, 0.3 ); +} + +#faceted_tracksel button, #faceted_tracksel input { + font-size: 12px; +} + +#faceted_tracksel div.mainContainer { + height: 100%; + width: 100%; +} +.tundra #faceted_tracksel div.mainContainer { + border-right: 2px solid #555; + background: #e9e9e9; +} + +/* Track grid */ + +#faceted_tracksel .dojoxGridCellFocus { + border-color: transparent; + border-color: transparent !important; +} +#faceted_tracksel .gridPane .gridControls { + padding: 2px 3px; + font-size: 110%; +} +.tundra #faceted_tracksel .gridPane .gridControls { + background: #e9e9e9; + border: 1px solid #aaa; + border-right: none; +} + +#faceted_tracksel .gridPane .gridControls > * { + margin: 2px 3px; + display: inline-block; + vertical-align: middle; +} +#faceted_tracksel .gridPane .gridControls button { + height: 2.2em; + margin: 4px; + white-space: nowrap; +} +#faceted_tracksel .gridPane .gridControls button > * { + display: inline-block; + vertical-align: middle; +} +#faceted_tracksel .gridPane .gridControls button img { + padding: 0 0.4em 0 0; +} +#faceted_tracksel.busy .gridControls .busy_indicator { + visibility: visible; +} +#faceted_tracksel .gridControls .busy_indicator { + z-index: 20; + visibility: hidden; +} + + +#faceted_tracksel label.textFilterControl img.text_filter_clear { + display: none; +} +#faceted_tracksel label.textFilterControl.selected img.text_filter_clear { + display: block; +} +#faceted_tracksel label.textFilterControl input { + border-top: 3px solid transparent; + font-weight: bold; + padding: 0.2em; +} +#faceted_tracksel label.textFilterControl.selected input { + border-top: 3px solid #396494; + background: #D2E1F1; +} + +/* Track selector title bar */ +#faceted_tracksel_top { + border-bottom: 1px solid #ccc; + padding: 5px; +} +.tundra #faceted_tracksel_top { + background: #396494; +} +.tundra #faceted_tracksel_top .topLink { + color: white; +} + +#faceted_tracksel_top > * { + display: inline-block; + vertical-align: middle; + margin-left: 5px; +} +#faceted_tracksel_top .title { + padding: 0; + width: 185px; + + font-weight: bold; + color: white; + font-size: 180%; +} + +#faceted_tracksel .faceted_tracksel_on_off.tab { + position: absolute; + top: 1.8em; + left: 100%; + z-index: 5; + + padding: 5px 0px; + white-space: nowrap; + + cursor: pointer; + + -moz-box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); + -webkit-box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); + box-shadow: 4px 4px 10px 2px rgba( 80, 80, 80, 0.3 ); +} +#faceted_tracksel div.faceted_tracksel_on_off.tab > * { + display: inline-block; + vertical-align: middle; + padding: 0 6px; +} +.tundra #faceted_tracksel .faceted_tracksel_on_off.tab { + background: #e9e9e9 url("../lib/dijit/themes/tundra/images/titleBar.png") top repeat-x; + border: 2px solid #666; + + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + + +#faceted_tracksel_top div.topLink { + position: absolute; + top: 0; + right: 0; + + background: none; + border: none; + color: white; + padding: 3px 0.6em; + + font-weight: bold; +} + + +/* Facet selection controls */ + +#faceted_tracksel .facetSelect { + width: 100%; + border-spacing: 0; +} +#faceted_tracksel .facetSelect .facetValue { + padding: 0.1em 0.4em; + cursor: pointer; +} +#faceted_tracksel .facetSelect .facetValue > * { + vertical-align: top; +} +#faceted_tracksel .facetSelect .disabled { + color: gray; +} + +#faceted_tracksel .facetSelect .facetValue.disabled { + display: none; +} +#faceted_tracksel .facetSelect .facetValue.disabled.selected { + display: block; +} + +.tundra #faceted_tracksel .facetSelect .facetValue:hover { + background: #D2E1F1; +} + +#faceted_tracksel .facetSelect .facetValue .count { + padding: 0 0.7em 0 0.4em; + color: #333; + text-align: right; +} +#faceted_tracksel .facetSelect .facetValue .value { + width: 80%; +} + +#faceted_tracksel .facetSelect .selected { + background: #b1d3f6; +} +.tundra #faceted_tracksel .facetSelect .facetValue.selected, +.tundra #faceted_tracksel .facetSelect .facetValue.selected:hover { + background: #AEC7E3; +} + + +#faceted_tracksel .dijitAccordionContainer-dijitContentPane { + padding: 0; +} +#faceted_tracksel .dijitAccordionContainer .dijitAccordionTitle { + padding: 0; +} +#faceted_tracksel .dijitAccordionTitleFocus { + position: relative; +} +.tundra #faceted_tracksel .dijitAccordionTitleFocus { + border-top: 3px solid transparent; + padding: 2px 2px 2px 0.6em; + color: #1B3047 +} +.tundra #faceted_tracksel .dijitAccordionTitleSelected { + background: white; +} +#faceted_tracksel .dijitAccordionTitleSelected .facetTitle { + color: black; +} + +#faceted_tracksel .activeFacet .facetTitle { + font-weight: bold; + color: black; +} +.tundra #faceted_tracksel .activeFacet { + border-top: 3px solid #396494; + background-color: #AEC7E3; +} + +#faceted_tracksel .facetTitle a { + position: absolute; + top: 2px; + right: -4px; + visibility: hidden; +} +#faceted_tracksel .activeFacet a.clearFacet { + visibility: visible; + padding: 1px 6px; +} + + +/* styling specifically for the title of the first facet title, which is 'My Tracks' */ +.tundra #faceted_tracksel .dijitAccordionInnerContainer:first-child .facetTitle:after { + content: url("../lib/dijit/themes/tundra/images/circleIcon.png"); + margin-left: 7px; +} +#faceted_tracksel .dijitAccordionInnerContainer:first-child .facetTitle { + color: black; + font-weight: bold; + padding-bottom: 6px; +} + +/* style the 'empty' and similar messages that show up in the grid master pane */ +#faceted_tracksel .dojoxGridMasterMessages { + font-size: 16px; +} + +/* for WA */ +.feature-render { + position: absolute; + min-width: 1px; + width: 100%; + /* feature render div may be added to feature div _after_ subfeature divs, so + if want subfeature divs in front of feature render div, make sure feature render div has lower + z-index than subfeature divs */ + z-index: 2; +} + +.track .global_highlight { + background: rgba( 255, 255, 0, 0.5 ); +} + +.CDS { + background-color: transparent; +} + +.mRNA, +.plus-mRNA, +.minus-mRNA { + height: 14px; + /* outline: 2px dashed green; */ + background-color: transparent; +} + +div.track_edit { + background-color: #FFFFDD !important; +} + +/* + To support WebApollo with sequence alteration features shown on SequenceTrack, + sequence style MUST NOT have a z-index specified +*/ +div.wa-sequence { + position: absolute; + left: 0px; + /* Courier New is preferred by JBrowse, but it looks too light in Firefox, + * and jagged in Chrome */ + /* font-family: Courier New,monospace; */ + font-family: monospace; + font-size: 10px; + letter-spacing: 2px; + padding-left: 2px; + cursor: pointer; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; + user-select: none; +} + +/* + block_seq_container are SequenceTrack divs that are child of .block and parent of .dna-residues + need to break this out from div.sequence because .sequence is also used for determining + font character width and height in GenomeView.calculateSequenceCharacterSize(), + and for that don't want a specified width +*/ +div.block-seq-container { + top: 15px; + width: 100%; + /* outline: 1px solid #00FF00; */ +} + +div.wa-sequence .dna-container { + position: absolute; + width: 100%; +} + +div.wa-sequence .dna-residues.forward-strand { + color: black; + z-index: 5; + /* outline: 1px solid pink; */ +} + +div.wa-sequence .dna-residues.reverse-strand { + color: gray; + border-top: 1px solid lightgray; + z-index: 5; + /* outline: 1px solid pink; */ +} + +div.wa-sequence .aa-residues { + color: black; + z-index: 5; +/* + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +*/ +/* outline: 1px solid orange; */ +} + +div.wa-sequence .aa-residues.frame0 { + background-color: #999999; + z-index: 5; +} + +div.wa-sequence .aa-residues.frame1 { + background-color: #BBBBBB; + z-index: 5; +} + +div.wa-sequence .aa-residues.frame2 { + background-color: #DDDDDD; + z-index: 5; +} + +/* highlighting of dna residues in DNA track on mouseover */ +div.wa-sequence .dna-highlighted { + background: #F9BF3A +} + +/* don't think this is currently (April 2012) being used */ +div.wa-sequence .highlighted { + background: #ff0; +} + +div.annot-sequence { + position: absolute; + left: 0px; + font-family: monospace; + font-size: 10px; + letter-spacing: 2px; + padding-left: 2px; + z-index: 15; + /* + need pointer-events:none so that any events pass through annot-sequence overlay + and onto the block or feature div underneath + */ + pointer-events: none; + + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; +} + +/* +div.basic-hist { + position: absolute; + z-index: 10; +} +*/ + +/******************************************************** +* invisible containers, +* for features that also have a renderClass that gets centered, +* and for subfeatures that have rendered children +* (currently only subfeatures like this are exons, which have CDS and UTR child divs) +********************************************************/ +.container-16px, +.plus-container-16px, +.minus-container-16px { + height: 16px; + background-color: transparent; +} + +.container-14px, +.plus-container-14px, +.minus-container-14px { + height: 14px; + background-color: transparent; +} + +.container-12px, +.plus-container-12px, +.minus-container-12px { + height: 12px; + background-color: transparent; +} + +.container-10px, +.plus-container-10px, +.minus-container-10px { + height: 10px; + background-color: transparent; +} + +.container-8px, +.plus-container-8px, +.minus-container-8px { + height: 8px; + background-color: transparent; +} + +/* intended for subfeatures that have rendered children, +* want to size subfeature container to same height as parent feature +* (currently only subfeatures like this are exons, which have CDS and UTR child divs) +*/ +.container-100pct, +.plus-container-100pct, +.minus-container-100pct { + height: 100%; + background-color: transparent; +} + +.feature-render { + position: absolute; + min-width: 1px; + width: 100%; + /* feature render div may be added to feature div _after_ subfeature divs, so + if want subfeature divs in front of feature render div, make sure feature render div has lower + z-index than subfeature divs */ + z-index: 2; +} + +/** + * Basic boxes for subfeatures (and for "render-*" rendering div for features with container divs) + * height is % of parent feature, top % determined based on height to ensure vertically centered + */ +.gray-center-30pct { + background-color: gray; + height: 30%; + top: 35%; +} + + +.gray-center-50pct { + background-color: lightgray; + height: 50%; + top: 25%; +} + + +.gray-center-20pct { + position:absolute; + background-color: gray; + min-width: 1px; + width: 100%; + height: 20%; + top: 40%; + /* annotline div may be added to annot div _after_ child feature divs, so + if want child divs in front of annotline, make sure has lower + z-index than child divs */ + z-index: 2; +} + +.gray-center-10pct { + position:absolute; + background-color: gray; + min-width: 1px; + width: 100%; + height: 10%; + top: 45%; + /* annotline div may be added to annot div _after_ child feature divs, so + if want child divs in front of annotline, make sure has lower + z-index than child divs */ + z-index: 2; +} + + + + +.pink-90pct, +.plus-pink-90pct, +.minus-pink-90pct { + height: 90%; + top: 5%; + background-color: #D8BDEB; + border: 1px solid #01F; +/* border-style: solid; + border-color: #01F; + border-width: 1px; +*/ +} + +.pink-12px, +.plus-pink-12px, +.minus-pink-12px { + background-color: #D8BDEB; + border: 1px solid #01F; + height: 12px; + /* margin-top: 2px; */ /* rely on centering in code instead? */ +} + +.pink-16px, +.plus-pink-16px, +.minus-pink-16px { + background-color: #D8BDEB; + border: 1px solid #01F; + height: 16px; + /* margin-top: 2px; */ /* rely on centering in code instead? */ +} + +.purple-60pct, +.plus-purple-60pct, +.minus-purple-60pct { + background-color: #8F408F; + height: 60%; + top: 20%; +} + +.purple-8px, +.plus-purple-8px, +.minus-purple-8px { + background-color: #8F408F; + height: 8px; + /* margin-top: 4px; */ /* rely on centering in code instead? */ +} + +.darkblue-80pct, +.plus-darkblue-80pct, +.minus-darkblue-80pct { + background-color: #1F3DDE; + height: 80%; + top: 10%; +} + +.bluegreen-80pct, +.plus-bluegreen-80pct, +.minus-bluegreen-80pct { + background-color: #3BA08E; + height: 80%; + top: 10%; +} + + +.brightgreen-80pct, +.plus-brightgreen-80pct, +.minus-brightgreen-80pct { + background-color: #21D61F; + border: 1px solid #555; + height: 80%; + top: 10%; +} + +.darkgreen-60pct, +.plus-darkgreen-60pct, +.minus-darkgreen-60pct { + height: 60%; + top: 20%; + background-color: #8DB890; +} + +.trellis-CDS, +.plus-trellis-CDS, +.minus-trellis-CDS { + background-color: gold; + border: 1px solid gray; + height: 80%; + top: 10%; +} + +.trellis-UTR, +.plus-trellis-UTR, +.minus-trellis-UTR { + background-color: #B39700; + height: 60%; + top: 20%; +} + +.trellis-match-part, +.plus-trellis-match-part, +.minus-trellis-match-part { + background-color: #1F3DDE; + height: 60%; + top: 10%; +} + +/* defaults for rendering aligned read from BAM files */ +.bam-read, +.plus-bam-read, +.minus-bam-read { + height: 5px; + background-color: #AACDDC; + z-index: 8; +} + +/* testing different coloration of BAM alignments on minus strand +.minus-bam { + height: 5px; + background-color: #AA00AA; + z-index: 8; +} +*/ + +/* CIGAR string "M" subfeature, indicating "alignment match" (can be a sequence match or mismatch) of aligned sequence relative to viewed sequence */ +.cigarM, +.plus-cigarM, +.minus-cigarM { + height: 100%; + background-color: #1B8A99; + z-index: 8; /* rendered below most other subfeatures */ + min-width: 1px; +} + +/* CIGAR string "D" subfeature, indicating deletion in aligned sequence relative to viewed sequence */ +/* setting z-index higher than "cigarM" (and default "subfeature") to ensure not hidden by neighboring match regions when zoomed out */ +/* deletions are rendered above matches/mismatches, but below insertion */ +/* turned off, deletion rendering in DraggableAlignments tracks is being handled as non-features by _drawMismatches() now */ +/* +.cigarD, +.plus-cigarD, +.minus-cigarD { + height: 100%; + background-color: #FF0000; + z-index: 12; + min-width: 3px; +} +*/ + +/* CIGAR string "I" subfeature, indicating insertion in aligned sequence relative to viewed sequence */ +/* rendered above sibling subfeature types to increase visibility, since will always be a zero-width feature (?) */ +/* turned off, insertion rendering in DraggableAlignments tracks is being handled as non-features by _drawMismatches() now */ +/* +.cigarI, +.plus-cigarI, +.minus-cigarI { + height: 100%; + background-color: #00FF00; + z-index: 13; + min-width: 3px; +} +*/ + + +/* CIGAR string "=" (or "E") subfeature, indicating exact sequence match of aligned sequence relative to viewed sequence */ +.cigarEQ, +.plus-cigarEQ, +.minus-cigarEQ { + height: 100%; + background-color: #1B8A99; + z-index: 10; /* rendered above more generic "M" type */ + min-width: 1px; +} + +/* CIGAR string "X" subfeature, indicating mismatch of aligned sequence relative to viewed sequence */ +.cigarX, +.plus-cigarX, +.minus-cigarX { + height: 100%; + background-color: rgb(182, 167, 0); + z-index: 11; /* rendered above matches, but below deletions and insertions */ + min-width: 3px; +} + + +/* don't render skips, just let parent bam show through (similar to not rendering intron but letting transcript show through) +.cigarN, +.plus-cigarN, +.minus-cigarN { + position: absolute; + height: 2px; + margin-top: 0px; + background-color: #AACDDC; + z-index: 8; + min-width: 1px; +} +*/ + +/* align_insertion, align_deletion, align_skip, align_mismatch are assigned to + non-feature child divs of bam-read features in DraggableAlignments._drawMismatches() +*/ +.align_insertion { + background-color: #00FF00; + height: 100%; + min-width: 2px; + z-index: 20; // render on top of mismatches etc. +} + +.align_deletion { + background-color: #FF0000; + height: 100%; + min-width: 2px; + z-index: 20; // render on top of mismatches etc. +} + +.align_skip { +} + +.align_mismatch { + background-color: #D8C046; + height: 100%; + min-width: 2px; + z-index: 12; +} + +.noncanonical-splice-site, +.plus-noncanonical-splice-site, +.minus-noncanonical-splice-site { + margin-left: -8px; + /* margin-top: -11px; */ + /* moved noncanonical icon to bottom of annotation, prefer top of annotation but + need to have a some padding at top of track before that works */ + margin-top: 9px; + padding-left: 8px; + padding-right: 8px; + position: absolute; + height: 16px; + z-index: 100; + background-color: transparent; + background-image: url('img/exclamation_circle_orange.png'); + background-repeat: no-repeat; + pointer-events: none; /* attempting to route around issue with centered non-canon splice sites preventing edge-drag */ +} + + +/* + for styles of features that are on SequenceTrack, + z-index MUST be > z-index of + (div.sequence .dna-residues.forward-strand) and + (div.sequence .dna-residues.reverse-strand) styles +*/ +.sequence-alteration.deletion, +.sequence-alteration.plus-deletion, +.sequence-alteration.minus-deletion { + border-style: solid; + border-color: blue; + border-width: 1px; + height: 100%; + background-color: rgba(150,0,0,0.3); + z-index: 20; +} + +.sequence-alteration.insertion, +.sequence-alteration.plus-insertion, +.sequence-alteration.minus-insertion { + border-style: solid; + border-color: green; + border-width: 1px; + height: 100%; + background-color: rgba(0,150,0,0.3); + z-index: 20; +} + +.sequence-alteration.substitution, +.sequence-alteration.plus-substitution, +.sequence-alteration.minus-substitution { + border-style: solid; + border-color: blue; + border-width: 1px; + height: 100%; + background-color: rgba(250,250,0,0.3); + z-index: 20; +} + +.cds-frame0 { + background-color: #FF8080 !important; +} + +.cds-frame1 { + background-color: #80FF80 !important; +} + +.cds-frame2 { + background-color: #8080FF !important; +} + +/** +* appearance of resizing box when dragging annotation edges +* if case browser doesn't support transparency via "rgba", fall back on solid background? +*/ +.ui-resizable-helper { + border: 2px dotted red; + background: rgb(100, 150, 255); + background: rgba(100, 150, 255, 0.5); + +} + +/** +* By default, no styling associated with custom multifeature draggable helper +* But leaving here for easing toggling of diagnostic rendering +*/ +.custom-multifeature-draggable-helper { +/* + outline-style: dotted; + outline-color: red; + outline-width: 4px; +*/ +} + +/** + * Appearance of annotations (features in AnnoTracks) when they are drop + * targets and are hovered over. +*/ +.annot-drop-hover { + outline-style: solid; + outline-color: green; + outline-width: 4px; +} + +/* +* overriding JQueryUI .ui-resizable style, which changes anything getting resized to "position:absolute" +* for this to work this CSS _must_ come after jquery-ui.css in load order +*/ +.ui-resizable { + position: absolute; +} + +/** + * Style to highlight left feature edges that match selected feature(s) edges. + * Setting box-sizing to border-box keeps the border _inside_ the element + * rather than outside. +*/ +.left-edge-match { + border-left: solid black 2px; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +} + +.right-edge-match { + border-right: solid black 2px; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + -ms-box-sizing: border-box; +} + +.selected-feature { + outline-style: solid; + outline-color: black; + outline-width: 4px; +} + +.selected-annotation { + outline-style: solid; + outline-color: black; + outline-width: 4px; +} + +.shadow { + -moz-box-shadow: 5px 5px 5px #444; + -webkit-box-shadow: 5px 5px 5px #444; + box-shadow: 5px 5px 5px #444; +} + +/* maker background-color: rgb(255,204,204); */ +/* blastn background-color: rgb(102,204,102); */ +/* blastx background-color: rgb(0,200,204); */ +/* tblastx background-color: rgb(0,200,104); */ +/* snap background-color: rgb(153,100,204); */ +/* est2genome background-color: rgb(100,100,210); */ +/* protein2genome background-color: rgb(117,150,255); */ +/* repeatmasker background-color: red; */ +/* repeatrunner background-color: rgb(255,152,255); */ +/* default-alignment-block background-color: #C87C8E; */ +/* nvit-alignment background-color: #848DBF; */ + +.stop_codon_read_through, +.plus-stop_codon_read_through, +.minus-stop_codon_read_through { + position: absolute; + height: 16px; + background-color: purple; + border-style: solid; + border-color: #01F; + border-width: 1px; + cursor: pointer; + z-index: 15; + min-width: 1px; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; +} diff --git a/www/styles/dashboard.less b/www/styles/dashboard.less new file mode 100644 index 00000000..06600da1 --- /dev/null +++ b/www/styles/dashboard.less @@ -0,0 +1,43 @@ +article.dashboard { + padding-top: 71px; +} + +h3.contribution-activity { + margin: 35px 0 10px 0; + height: 0; + text-align: center; + font-size: 16px; + font-weight: normal; + color: #999999; + border-bottom: 1px solid #ddd; +} + +h3.contribution-activity .inner { + display: inline-block; + position: relative; + top: -10px; + padding: 0 5px; + background: #fff; +} + +.time { + color: #999999; +} + +.list-group-item { + border: none; + border-top: 1px solid #dddddd; +} + +.list-group-item:first-child { + border-top: none; +} + +.list-group-item-text { + padding-top: 4px; + padding-bottom: 4px; +} + +.details h3 { + margin-top: 0px; +} diff --git a/www/templates/_nav-full.html b/www/templates/_nav-full.html new file mode 100644 index 00000000..739da18b --- /dev/null +++ b/www/templates/_nav-full.html @@ -0,0 +1,30 @@ + diff --git a/www/templates/_nav.html b/www/templates/_nav.html new file mode 100644 index 00000000..e4edb398 --- /dev/null +++ b/www/templates/_nav.html @@ -0,0 +1,25 @@ + + diff --git a/www/templates/about.html b/www/templates/about.html new file mode 100644 index 00000000..484139de --- /dev/null +++ b/www/templates/about.html @@ -0,0 +1,207 @@ + +
+
+

Gene Annotation for the Masses

+

+ Why don't naked mole rats ever get cancer? How can ant queens live + for up to 30 years while workers live for a few months and males for + only a few weeks? What makes dog breeds so different? +

+ +

+ Answering such questions requires understanding the relevant genomes. + The genome of an organism – stored in the form of DNA in each cell – + is represented as a long sequence of the letters ‘A’, ‘G’, ‘C’, and + ‘T’. Parts of this sequence represent genes – instructions which + define the shape, size, behavior, lifespan and disease susceptibility + of the organism. +

+ +

+ Obtaining a genome sequence is easy. But computers and biologists are + having hard time correctly identifying the genes it contains. +

+
+ +
+
+
+

Biologists Need Your Help.

+
    + +
  • + Analyze and + correct gene models guided by our smart interface. +
  • + + +
  • + Share your + contribution to science with your peers. +
  • + + +
  • + Learn about genes + and genomes. +
  • + + +
  • + Earn + recognition and make the world a better place. +
  • +
+
+ +
+
+ +
+

Are You a Biologist?

+
    +
  • + Sequenced a genome + de novo but stuck with bad gene predictions? Our + community can manually inspect and refine them for you. +
  • + +
  • + Performing + research on a gene family but uncertain of the quality of gene + models you have? Our community can verify and improve their + quality for you. +
  • +
+
+
+ +
+
+
+ + + Sign up for Beta + + + + + + + +
+ +
+
+

Sign up

+
+
+
+ +
+
+ +
+ + + + + + + +
+ +
+
+
+

+ Thanks! We will get in touch with you soon. +

+
+
+
+ +
+
+

+ We are in a super alpha stage and support only Google + Chrome at the moment. +

+
+

Sign in

+
+
+
+ +
+
+ +
+
+ +
+ + +
+
+
+
+
+ +
+
+ + +
diff --git a/www/templates/curate.html b/www/templates/curate.html new file mode 100644 index 00000000..ecc6bcc3 --- /dev/null +++ b/www/templates/curate.html @@ -0,0 +1,268 @@ + +
+
+
+ +
+
+ +
+
+ + +
+ + +
diff --git a/www/templates/dashboard.html b/www/templates/dashboard.html new file mode 100644 index 00000000..69845667 --- /dev/null +++ b/www/templates/dashboard.html @@ -0,0 +1,102 @@ + +
+
+ +
+ + + +

+ {{user.name}} +
+ + beginner + +

+

+ + Started {{user.joined_on | amDateFormat: 'MMMM Do, YYYY'}} +

+
+ + +
+

Challenges

+
+
+
+ + Solenopsis invicta + +
+

+ Fire ant (Solenopsis invicta) is a + very aggressive eusocial species, with a painful, persitently + irritating sting. Fire ant is a major pest causing annual loss + of over $5000 million in the United States. +

+

+ A group lead by Dr. Yannick Wurm sequenced and studied the + genome of fire ant. They found several unique aspects of the + fire ant genome possibly linked to the complex social behavior + of this species. +

+ + Curate + + +
+
+
+
+ +

Contribution Activity

+
+

+
+ + + + {{contributions.length}} + + {{task_type}} +
+

+
    +
  • +

    + + {{contribution.status}} + +    + {{contribution.description}} + + +

    +
  • +
+
+
+
+
+