Merge branch 'releases/Release_2_11_3_Branch'
[jalview.git] / src / jalview / io / SequenceAnnotationReport.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.io;
22
23 import java.util.ArrayList;
24 import java.util.Collection;
25 import java.util.Comparator;
26 import java.util.LinkedHashMap;
27 import java.util.List;
28 import java.util.Locale;
29 import java.util.Map;
30
31 import jalview.api.FeatureColourI;
32 import jalview.datamodel.DBRefEntry;
33 import jalview.datamodel.DBRefSource;
34 import jalview.datamodel.GeneLociI;
35 import jalview.datamodel.MappedFeatures;
36 import jalview.datamodel.SequenceFeature;
37 import jalview.datamodel.SequenceI;
38 import jalview.util.MessageManager;
39 import jalview.util.StringUtils;
40 import jalview.util.UrlLink;
41 import jalview.viewmodel.seqfeatures.FeatureRendererModel;
42
43 /**
44  * generate HTML reports for a sequence
45  * 
46  * @author jimp
47  */
48 public class SequenceAnnotationReport
49 {
50   private static final int MAX_DESCRIPTION_LENGTH = 40;
51
52   private static final String COMMA = ",";
53
54   private static final String ELLIPSIS = "...";
55
56   private static final int MAX_REFS_PER_SOURCE = 4;
57
58   private static final int MAX_SOURCES = 5;
59
60   private static String linkImageURL;
61
62   // public static final String[][] PRIMARY_SOURCES moved to DBRefSource.java
63
64   /*
65    * Comparator to order DBRefEntry by Source + accession id (case-insensitive),
66    * with 'Primary' sources placed before others, and 'chromosome' first of all
67    */
68   private static Comparator<DBRefEntry> comparator = new Comparator<DBRefEntry>()
69   {
70
71     @Override
72     public int compare(DBRefEntry ref1, DBRefEntry ref2)
73     {
74       if (ref1 instanceof GeneLociI)
75       {
76         return -1;
77       }
78       if (ref2 instanceof GeneLociI)
79       {
80         return 1;
81       }
82
83       String s1 = ref1.getSource();
84       String s2 = ref2.getSource();
85       boolean s1Primary = DBRefSource.isPrimarySource(s1);
86       boolean s2Primary = DBRefSource.isPrimarySource(s2);
87       if (ref1.isCanonical() && !ref2.isCanonical())
88       {
89         return -1;
90       }
91       if (!ref1.isCanonical() && ref2.isCanonical())
92       {
93         return 1;
94       }
95       if (s1Primary && !s2Primary)
96       {
97         return -1;
98       }
99       if (!s1Primary && s2Primary)
100       {
101         return 1;
102       }
103       int comp = s1 == null ? -1
104               : (s2 == null ? 1 : s1.compareToIgnoreCase(s2));
105       if (comp == 0)
106       {
107         String a1 = ref1.getAccessionId();
108         String a2 = ref2.getAccessionId();
109         comp = a1 == null ? -1
110                 : (a2 == null ? 1 : a1.compareToIgnoreCase(a2));
111       }
112       return comp;
113     }
114
115     // private boolean isPrimarySource(String source)
116     // {
117     // for (String[] primary : DBRefSource.PRIMARY_SOURCES)
118     // {
119     // for (String s : primary)
120     // {
121     // if (source.equals(s))
122     // {
123     // return true;
124     // }
125     // }
126     // }
127     // return false;
128     // }
129   };
130
131   private boolean forTooltip;
132
133   /**
134    * Constructor given a flag which affects behaviour
135    * <ul>
136    * <li>if true, generates feature details suitable to show in a tooltip</li>
137    * <li>if false, generates feature details in a form suitable for the sequence
138    * details report</li>
139    * </ul>
140    * 
141    * @param isForTooltip
142    */
143   public SequenceAnnotationReport(boolean isForTooltip)
144   {
145     this.forTooltip = isForTooltip;
146     if (linkImageURL == null)
147     {
148       linkImageURL = getClass().getResource("/images/link.gif").toString();
149     }
150   }
151
152   /**
153    * Append text for the list of features to the tooltip. Returns the number of
154    * features not added if maxlength limit is (or would have been) reached.
155    * 
156    * @param sb
157    * @param residuePos
158    * @param features
159    * @param minmax
160    * @param maxlength
161    */
162   public int appendFeatures(final StringBuilder sb, int residuePos,
163           List<SequenceFeature> features, FeatureRendererModel fr,
164           int maxlength)
165   {
166     for (int i = 0; i < features.size(); i++)
167     {
168       SequenceFeature feature = features.get(i);
169       if (appendFeature(sb, residuePos, fr, feature, null, maxlength))
170       {
171         return features.size() - i;
172       }
173     }
174     return 0;
175   }
176
177   /**
178    * Appends text for mapped features (e.g. CDS feature for peptide or vice
179    * versa) Returns number of features left if maxlength limit is (or would have
180    * been) reached.
181    * 
182    * @param sb
183    * @param residuePos
184    * @param mf
185    * @param fr
186    * @param maxlength
187    */
188   public int appendFeatures(StringBuilder sb, int residuePos,
189           MappedFeatures mf, FeatureRendererModel fr, int maxlength)
190   {
191     for (int i = 0; i < mf.features.size(); i++)
192     {
193       SequenceFeature feature = mf.features.get(i);
194       if (appendFeature(sb, residuePos, fr, feature, mf, maxlength))
195       {
196         return mf.features.size() - i;
197       }
198     }
199     return 0;
200   }
201
202   /**
203    * Appends the feature at rpos to the given buffer
204    * 
205    * @param sb
206    * @param rpos
207    * @param minmax
208    * @param feature
209    */
210   boolean appendFeature(final StringBuilder sb0, int rpos,
211           FeatureRendererModel fr, SequenceFeature feature,
212           MappedFeatures mf, int maxlength)
213   {
214     int begin = feature.getBegin();
215     int end = feature.getEnd();
216
217     /*
218      * if this is a virtual features, convert begin/end to the
219      * coordinates of the sequence it is mapped to
220      */
221     int[] beginRange = null; // feature start in local coordinates
222     int[] endRange = null; // feature end in local coordinates
223     if (mf != null)
224     {
225       if (feature.isContactFeature())
226       {
227         /*
228          * map start and end points individually
229          */
230         beginRange = mf.getMappedPositions(begin, begin);
231         endRange = begin == end ? beginRange
232                 : mf.getMappedPositions(end, end);
233       }
234       else
235       {
236         /*
237          * map the feature extent
238          */
239         beginRange = mf.getMappedPositions(begin, end);
240         endRange = beginRange;
241       }
242       if (beginRange == null || endRange == null)
243       {
244         // something went wrong
245         return false;
246       }
247       begin = beginRange[0];
248       end = endRange[endRange.length - 1];
249     }
250
251     StringBuilder sb = new StringBuilder();
252     if (feature.isContactFeature())
253     {
254       /*
255        * include if rpos is at start or end position of [mapped] feature
256        */
257       boolean showContact = (mf == null) && (rpos == begin || rpos == end);
258       boolean showMappedContact = (mf != null) && ((rpos >= beginRange[0]
259               && rpos <= beginRange[beginRange.length - 1])
260               || (rpos >= endRange[0]
261                       && rpos <= endRange[endRange.length - 1]));
262       if (showContact || showMappedContact)
263       {
264         if (sb0.length() > 6)
265         {
266           sb.append("<br/>");
267         }
268         sb.append(feature.getType()).append(" ").append(begin).append(":")
269                 .append(end);
270       }
271       return appendText(sb0, sb, maxlength);
272     }
273
274     if (sb0.length() > 6)
275     {
276       sb.append("<br/>");
277     }
278     // TODO: remove this hack to display link only features
279     boolean linkOnly = feature.getValue("linkonly") != null;
280     if (!linkOnly)
281     {
282       sb.append(feature.getType()).append(" ");
283       if (rpos != 0)
284       {
285         // we are marking a positional feature
286         sb.append(begin);
287         if (begin != end)
288         {
289           sb.append(" ").append(end);
290         }
291       }
292
293       String description = feature.getDescription();
294       if (description != null && !description.equals(feature.getType()))
295       {
296         description = StringUtils.stripHtmlTags(description);
297
298         /*
299          * truncate overlong descriptions unless they contain an href
300          * before the truncation point (as truncation could leave corrupted html)
301          */
302         int linkindex = description.toLowerCase(Locale.ROOT).indexOf("<a ");
303         boolean hasLink = linkindex > -1
304                 && linkindex < MAX_DESCRIPTION_LENGTH;
305         if (description.length() > MAX_DESCRIPTION_LENGTH && !hasLink)
306         {
307           description = description.substring(0, MAX_DESCRIPTION_LENGTH)
308                   + ELLIPSIS;
309         }
310
311         sb.append("; ").append(description);
312       }
313
314       if (showScore(feature, fr))
315       {
316         sb.append(" Score=").append(String.valueOf(feature.getScore()));
317       }
318       String status = (String) feature.getValue("status");
319       if (status != null && status.length() > 0)
320       {
321         sb.append("; (").append(status).append(")");
322       }
323
324       /*
325        * add attribute value if coloured by attribute
326        */
327       if (fr != null)
328       {
329         FeatureColourI fc = fr.getFeatureColours().get(feature.getType());
330         if (fc != null && fc.isColourByAttribute())
331         {
332           String[] attName = fc.getAttributeName();
333           String attVal = feature.getValueAsString(attName);
334           if (attVal != null)
335           {
336             sb.append("; ").append(String.join(":", attName)).append("=")
337                     .append(attVal);
338           }
339         }
340       }
341
342       if (mf != null)
343       {
344         String variants = mf.findProteinVariants(feature);
345         if (!variants.isEmpty())
346         {
347           sb.append(" ").append(variants);
348         }
349       }
350     }
351     return appendText(sb0, sb, maxlength);
352   }
353
354   /**
355    * Appends sb to sb0, and returns false, unless maxlength is not zero and
356    * appending would make the result longer than or equal to maxlength, in which
357    * case the append is not done and returns true
358    * 
359    * @param sb0
360    * @param sb
361    * @param maxlength
362    * @return
363    */
364   private static boolean appendText(StringBuilder sb0, StringBuilder sb,
365           int maxlength)
366   {
367     if (maxlength == 0 || sb0.length() + sb.length() < maxlength)
368     {
369       sb0.append(sb);
370       return false;
371     }
372     return true;
373   }
374
375   /**
376    * Answers true if score should be shown, else false. Score is shown if it is
377    * not NaN, and the feature type has a non-trivial min-max score range
378    */
379   boolean showScore(SequenceFeature feature, FeatureRendererModel fr)
380   {
381     if (Float.isNaN(feature.getScore()))
382     {
383       return false;
384     }
385     if (fr == null)
386     {
387       return true;
388     }
389     float[][] minMax = fr.getMinMax().get(feature.getType());
390
391     /*
392      * minMax[0] is the [min, max] score range for positional features
393      */
394     if (minMax == null || minMax[0] == null || minMax[0][0] == minMax[0][1])
395     {
396       return false;
397     }
398     return true;
399   }
400
401   /**
402    * Format and appends any hyperlinks for the sequence feature to the string
403    * buffer
404    * 
405    * @param sb
406    * @param feature
407    */
408   void appendLinks(final StringBuffer sb, SequenceFeature feature)
409   {
410     if (feature.links != null)
411     {
412       if (linkImageURL != null)
413       {
414         sb.append(" <img src=\"" + linkImageURL + "\">");
415       }
416       else
417       {
418         for (String urlstring : feature.links)
419         {
420           try
421           {
422             for (List<String> urllink : createLinksFrom(null, urlstring))
423             {
424               sb.append("<br/> <a href=\"" + urllink.get(3) + "\" target=\""
425                       + urllink.get(0) + "\">"
426                       + (urllink.get(0).toLowerCase(Locale.ROOT).equals(
427                               urllink.get(1).toLowerCase(Locale.ROOT))
428                                       ? urllink.get(0)
429                                       : (urllink.get(0) + ":"
430                                               + urllink.get(1)))
431                       + "</a><br/>");
432             }
433           } catch (Exception x)
434           {
435             jalview.bin.Console.errPrintln(
436                     "problem when creating links from " + urlstring);
437             x.printStackTrace();
438           }
439         }
440       }
441
442     }
443   }
444
445   /**
446    * 
447    * @param seq
448    * @param link
449    * @return Collection< List<String> > { List<String> { link target, link
450    *         label, dynamic component inserted (if any), url }}
451    */
452   Collection<List<String>> createLinksFrom(SequenceI seq, String link)
453   {
454     Map<String, List<String>> urlSets = new LinkedHashMap<>();
455     UrlLink urlLink = new UrlLink(link);
456     if (!urlLink.isValid())
457     {
458       jalview.bin.Console.errPrintln(urlLink.getInvalidMessage());
459       return null;
460     }
461
462     urlLink.createLinksFromSeq(seq, urlSets);
463
464     return urlSets.values();
465   }
466
467   public void createSequenceAnnotationReport(final StringBuilder tip,
468           SequenceI sequence, boolean showDbRefs, boolean showNpFeats,
469           FeatureRendererModel fr)
470   {
471     createSequenceAnnotationReport(tip, sequence, showDbRefs, showNpFeats,
472             fr, false);
473   }
474
475   /**
476    * Builds an html formatted report of sequence details and appends it to the
477    * provided buffer.
478    * 
479    * @param sb
480    *          buffer to append report to
481    * @param sequence
482    *          the sequence the report is for
483    * @param showDbRefs
484    *          whether to include database references for the sequence
485    * @param showNpFeats
486    *          whether to include non-positional sequence features
487    * @param fr
488    * @param summary
489    * @return
490    */
491   int createSequenceAnnotationReport(final StringBuilder sb,
492           SequenceI sequence, boolean showDbRefs, boolean showNpFeats,
493           FeatureRendererModel fr, boolean summary)
494   {
495     String tmp;
496     sb.append("<i>");
497
498     int maxWidth = 0;
499     if (sequence.getDescription() != null)
500     {
501       tmp = sequence.getDescription();
502       sb.append(tmp);
503       maxWidth = Math.max(maxWidth, tmp.length());
504     }
505     sb.append("\n");
506     SequenceI ds = sequence;
507     while (ds.getDatasetSequence() != null)
508     {
509       ds = ds.getDatasetSequence();
510     }
511
512     if (showDbRefs)
513     {
514       maxWidth = Math.max(maxWidth, appendDbRefs(sb, ds, summary));
515     }
516     sb.append("\n");
517
518     /*
519      * add non-positional features if wanted
520      */
521     if (showNpFeats)
522     {
523       for (SequenceFeature sf : sequence.getFeatures()
524               .getNonPositionalFeatures())
525       {
526         int sz = -sb.length();
527         appendFeature(sb, 0, fr, sf, null, 0);
528         sz += sb.length();
529         maxWidth = Math.max(maxWidth, sz);
530       }
531     }
532     sb.append("</i>");
533     return maxWidth;
534   }
535
536   /**
537    * A helper method that appends any DBRefs, returning the maximum line length
538    * added
539    * 
540    * @param sb
541    * @param ds
542    * @param summary
543    * @return
544    */
545   protected int appendDbRefs(final StringBuilder sb, SequenceI ds,
546           boolean summary)
547   {
548     List<DBRefEntry> dbrefs, dbrefset = ds.getDBRefs();
549
550     if (dbrefset == null)
551     {
552       return 0;
553     }
554
555     // PATCH for JAL-3980 defensive copy
556
557     dbrefs = new ArrayList<DBRefEntry>();
558
559     dbrefs.addAll(dbrefset);
560
561     // note this sorts the refs held on the sequence!
562     dbrefs.sort(comparator);
563     boolean ellipsis = false;
564     String source = null;
565     String lastSource = null;
566     int countForSource = 0;
567     int sourceCount = 0;
568     boolean moreSources = false;
569     int maxLineLength = 0;
570     int lineLength = 0;
571
572     for (DBRefEntry ref : dbrefs)
573     {
574       source = ref.getSource();
575       if (source == null)
576       {
577         // shouldn't happen
578         continue;
579       }
580       boolean sourceChanged = !source.equals(lastSource);
581       if (sourceChanged)
582       {
583         lineLength = 0;
584         countForSource = 0;
585         sourceCount++;
586       }
587       if (sourceCount > MAX_SOURCES && summary)
588       {
589         ellipsis = true;
590         moreSources = true;
591         break;
592       }
593       lastSource = source;
594       countForSource++;
595       if (countForSource == 1 || !summary)
596       {
597         sb.append("<br/>\n");
598       }
599       if (countForSource <= MAX_REFS_PER_SOURCE || !summary)
600       {
601         String accessionId = ref.getAccessionId();
602         lineLength += accessionId.length() + 1;
603         if (countForSource > 1 && summary)
604         {
605           sb.append(",\n ").append(accessionId);
606           lineLength++;
607         }
608         else
609         {
610           sb.append(source).append(" ").append(accessionId);
611           lineLength += source.length();
612         }
613         maxLineLength = Math.max(maxLineLength, lineLength);
614       }
615       if (countForSource == MAX_REFS_PER_SOURCE && summary)
616       {
617         sb.append(COMMA).append(ELLIPSIS);
618         ellipsis = true;
619       }
620     }
621     if (moreSources)
622     {
623       sb.append("<br/>\n").append(source).append(COMMA).append(ELLIPSIS);
624     }
625     if (ellipsis)
626     {
627       sb.append("<br/>\n(");
628       sb.append(MessageManager.getString("label.output_seq_details"));
629       sb.append(")");
630     }
631
632     return maxLineLength;
633   }
634
635   public void createTooltipAnnotationReport(final StringBuilder tip,
636           SequenceI sequence, boolean showDbRefs, boolean showNpFeats,
637           FeatureRendererModel fr)
638   {
639     int maxWidth = createSequenceAnnotationReport(tip, sequence, showDbRefs,
640             showNpFeats, fr, true);
641
642     if (maxWidth > 60)
643     {
644       // ? not sure this serves any useful purpose
645       // tip.insert(0, "<table width=350 border=0><tr><td>");
646       // tip.append("</td></tr></table>");
647     }
648   }
649 }