package com.github.lindenb.jvarkit.tools.burden;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import javax.imageio.ImageIO;
import com.beust.jcommander.JCommander;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.ParametersDelegate;
import com.github.lindenb.jvarkit.util.Pedigree;
import com.github.lindenb.jvarkit.util.jcommander.Launcher;
import com.github.lindenb.jvarkit.util.jcommander.Program;
import com.github.lindenb.jvarkit.util.jcommander.Launcher.UsageBuider;
import com.github.lindenb.jvarkit.util.log.Logger;
import com.github.lindenb.jvarkit.util.picard.SAMSequenceDictionaryProgress;
import com.github.lindenb.jvarkit.util.vcf.VCFUtils;
import com.github.lindenb.jvarkit.util.vcf.VcfIterator;
import htsjdk.samtools.util.CloserUtil;
import htsjdk.variant.variantcontext.Allele;
import htsjdk.variant.variantcontext.Genotype;
import htsjdk.variant.variantcontext.VariantContext;
import htsjdk.variant.vcf.VCFInfoHeaderLine;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.embed.swing.SwingFXUtils;
import javafx.geometry.Rectangle2D;
import javafx.geometry.Side;
import javafx.scene.Scene;
import javafx.scene.SnapshotParameters;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.ScatterChart;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Alert;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.Tooltip;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Screen;
import javafx.stage.Stage;
/**
BEGIN_DOC
### Example
```
java -jar dist/casectrljfx.jar --pedigree mutations.ped mutations.vcf
```
### See also:
* https://twitter.com/yokofakun/status/860495863633805312
## screenshot
![screenshot](https://pbs.twimg.com/media/C_EYa54W0AAopkl.jpg)
END_DOC
*/
@Program(
name="casectrljfx",
description="display jfx chart of case/control maf from a VCF and a pedigree",
keywords={"vcf","pedigree","case","control","visualization","jfx","chart","maf"}
)
public class CaseControlJfx extends Application {
private static final Logger LOG = Logger.build(CaseControlJfx.class).make();
private enum VariantPartitionType{
chromosome,
variantType,
autosomes,
qual,
vqslod,
typeFilter,
distance,
n_alts
}
private static interface VariantPartition
{
public List<XYChart.Series<Number,Number>> getSeries();
public void add(VariantContext vc,Pedigree ped, XYChart.Data<Number,Number> data);
}
private static class VariantTypePartition implements VariantPartition
{
final List<XYChart.Series<Number,Number>> series = new ArrayList<>( VariantContext.Type.values().length);
VariantTypePartition()
{
for(final VariantContext.Type t:VariantContext.Type.values()) {
final XYChart.Series<Number,Number> serie = new XYChart.Series<>();
serie.setName(t.name());
this.series.add(serie);
}
}
@Override
public List<XYChart.Series<Number,Number>> getSeries()
{
return this.series;
}
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
final VariantContext.Type t= vc.getType();
this.series.get(t.ordinal()).getData().add(data);
}
}
private static abstract class MapPartition implements VariantPartition
{
final Map<String,XYChart.Series<Number,Number>> series = new TreeMap<>();
MapPartition()
{
}
protected XYChart.Series<Number,Number> get(String key) {
XYChart.Series<Number,Number> S = this.series.get(key);
if(S==null)
{
S = new XYChart.Series<>();
S.setName(key);
this.series.put(key,S);
}
return S;
}
@Override
public List<XYChart.Series<Number,Number>> getSeries()
{
return new ArrayList<>(this.series.values());
}
}
private static class ChromosomePartition extends MapPartition
{
ChromosomePartition()
{
}
protected String normalizeContig(final String str) {
return str;
}
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
final String str = normalizeContig(vc.getContig());
if(str==null || str.isEmpty()) return;
this.get(str).getData().add(data);
}
}
private static class SexualContigPartition extends ChromosomePartition
{
protected String normalizeContig(final String str) {
if( str.equalsIgnoreCase("chrX") || str.equalsIgnoreCase("X") ||
str.equalsIgnoreCase("chrY") || str.equalsIgnoreCase("Y")
) return str;
return "Autosome";
}
}
private static class QualPartition extends MapPartition
{
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
final String str ;
if(vc.hasLog10PError())
{
int qual=(int)vc.getPhredScaledQual();
if(qual <10) str="<10";
else if(qual <20) str="<20";
else if(qual <30) str="<30";
else if(qual <40) str="<40";
else if(qual <50) str="<50";
else if(qual <100) str="<100";
else str=">=100";
}
else
{
str="N/A";
}
this.get(str).getData().add(data);
}
}
private static class VQSLODPartition extends MapPartition
{
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
if(!vc.hasAttribute("VQSLOD")) return;
double vqslod=vc.getAttributeAsDouble("VQSLOD",Double.NaN);
if(Double.isNaN(vqslod)) return;
final int WINDOW=5;
int norm=WINDOW*(int)(vqslod/((double)WINDOW));
String str=String.valueOf(norm);
this.get(str).getData().add(data);
}
}
private static class NAltsPartition extends MapPartition
{
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
this.get(String.valueOf(vc.getAlternateAlleles().size())).getData().add(data);
}
}
private static class DisanceToDiagonalPartiton extends MapPartition
{
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
double x1= data.getXValue().doubleValue();
double y1= data.getYValue().doubleValue();
// use 'mirror' point on diagonal
double x2 = y1;
double y2 = x1;
//distance to diagonal is half distance between (x1,y1) and (x2,y2)
double distance= Math.sqrt( Math.pow((x1-x2),2)+ Math.pow(y1-y2,2))/2.0;
final double WINDOW=10.0;
double norm= Math.abs(((int)(distance*WINDOW))/(double)WINDOW);
this.get(String.valueOf(norm)).getData().add(data);
}
}
private static class TypeAndFilterPartiton extends MapPartition
{
@Override
public void add(VariantContext vc,Pedigree ped,XYChart.Data<Number,Number> data){
if(vc==null) return;
final String str=vc.getType().name()+(vc.isFiltered()?" FILTERED":" PASS");
this.get(str).getData().add(data);
}
}
private enum SelectSamples { all,males,females};
@ParametersDelegate
private Launcher.UsageBuider usageBuider=null;
@Parameter(description = "Files")
List<String> args = new ArrayList<>();
@Parameter(names={"-p","--ped","--pedigree"},description="Pedigree File. If not defined, try to use the pedigree inserted in the VCF header.")
File pedigreeFile = null;
@Parameter(names={"-partition","--partition"},description="partition type. How series are built. For example 'variantType' will produces some series for INDEL, SNP, etc... ")
VariantPartitionType partitionType=VariantPartitionType.variantType;
@Parameter(names={"-nchr","--nocallishomref"},description="treat no call as HomRef")
boolean no_call_is_homref=false;
@Parameter(names={"-filter","--filter"},description="Ignore FILTERed variants")
boolean ignore_ctx_filtered=false;
@Parameter(names={"-gtfilter","--genotypefilter"},description="Ignore FILTERed Genotypes")
boolean ignore_gt_filtered=false;
@Parameter(names={"--legendside"},description="Legend side")
Side legendSide = Side.RIGHT;
@Parameter(names={"--tooltip"},description="add mouse Tooltip the point (requires more memory)")
boolean add_tooltip = false;
@Parameter(names={"--limit"},description="Limit to 'N' variants. negative==no limit; All point are loaded in memory. The more variants you have, the more your need memory")
int limit_to_N_variants = -1;
@Parameter(names={"--sex"},description="Select/Filter samples on their gender.")
SelectSamples selectSamples=SelectSamples.all;
@Parameter(names={"--title"},description="Default title for the graph")
String userTitle=null;
@Parameter(names={"--opacity"},description="Point opaciy [0-1]")
double dataOpacity=0.4;
@Parameter(names={"-o","--out"},description="Save the image in a file and then exit.")
File outputFile=null;
@Parameter(names={"-mafTag","--mafTag"},description="Do not calculate MAF for controls, but use this tag to get Controls' MAF")
String controlTag =null;
public CaseControlJfx()
{
this.usageBuider = new UsageBuider(this.getClass());
}
@Override
public void start(final Stage primaryStage) throws Exception {
final VariantPartition partition;
Pedigree pedigree = null;
VcfIterator in = null;
try {
final JCommander jCommander = new JCommander(this);
final List<String> unammed=this.getParameters().getUnnamed();
jCommander.parse(unammed.toArray(new String[unammed.size()]));
if(this.usageBuider.shouldPrintUsage())
{
this.usageBuider.usage(jCommander);
Platform.exit();
return;
}
switch(this.partitionType)
{
case variantType: partition = new VariantTypePartition(); break;
case chromosome: partition = new ChromosomePartition(); break;
case autosomes: partition = new SexualContigPartition(); break;
case qual : partition = new QualPartition();break;
case vqslod : partition = new VQSLODPartition();break;
case typeFilter : partition = new TypeAndFilterPartiton();break;
case distance : partition = new DisanceToDiagonalPartiton(); break;
case n_alts : partition = new NAltsPartition(); break;
default: throw new IllegalStateException(this.partitionType.name());
}
if(this.args.isEmpty())
{
in = VCFUtils.createVcfIteratorStdin();
primaryStage.setTitle(CaseControlJfx.class.getSimpleName());
}
else if(this.args.size()==1)
{
in = VCFUtils.createVcfIterator(this.args.get(0));
primaryStage.setTitle(this.args.get(0));
}
else
{
LOG.error("Illegal Number of arguments: " + this.args);
Platform.exit();
return;
}
if(this.pedigreeFile!=null)
{
pedigree = Pedigree.newParser().parse(this.pedigreeFile);
}
else
{
pedigree = Pedigree.newParser().parse(in.getHeader());
}
if(this.controlTag!=null)
{
final VCFInfoHeaderLine infoHeaderLine=in.getHeader().getInfoHeaderLine(this.controlTag);
if(infoHeaderLine==null) {
LOG.error("No such attribute in the VCF header: "+this.controlTag);
Platform.exit();
return;
}
}
int count = 0;
final SAMSequenceDictionaryProgress progress = new SAMSequenceDictionaryProgress(in.getHeader());
while(in.hasNext() && (this.limit_to_N_variants<0 || count<this.limit_to_N_variants))
{
final VariantContext ctx=progress.watch(in.next());
if(this.ignore_ctx_filtered && ctx.isFiltered()) continue;
++count;
final List<Allele> alternates = ctx.getAlternateAlleles();
for(int alt_idx=0;alt_idx < alternates.size();++alt_idx) {
final Allele alt = alternates.get(alt_idx);
final Double mafs[]={null,null};
for(int i=0;i< 2;++i)
{
if(i==1 && this.controlTag!=null)
{
if(ctx.hasAttribute(this.controlTag)) {
try
{
final List<Double> dvals =ctx.getAttributeAsDoubleList(this.controlTag, Double.NaN);
if(alt_idx< dvals.size() && dvals.get(alt_idx)!=null) {
final double d= dvals.get(alt_idx);
if(!Double.isNaN(d) && d>=0 && d<=1.0) mafs[1]=d;
}
}
catch(NumberFormatException err)
{
}
}
}
else
{
final MafCalculator mafCalculator = new MafCalculator(alt, ctx.getContig());
mafCalculator.setNoCallIsHomRef(no_call_is_homref);
for(Pedigree.Person person: (i==0?pedigree.getAffected():pedigree.getUnaffected()))
{
if(selectSamples.equals(SelectSamples.males) && !person.isMale()) continue;
if(selectSamples.equals(SelectSamples.females) && !person.isFemale()) continue;
final Genotype genotype = ctx.getGenotype(person.getId());
if(genotype==null) continue;
if(ignore_gt_filtered && genotype.isFiltered()) continue;
mafCalculator.add(genotype, person.isMale());
}
if(!mafCalculator.isEmpty())
{
mafs[i]=mafCalculator.getMaf();
}
}
}
if(mafs[0]==null || mafs[1]==null) continue;
final XYChart.Data<Number,Number> data = new XYChart.Data<Number,Number>(mafs[0],mafs[1]);
if(this.add_tooltip && this.outputFile==null)
{
data.setExtraValue(ctx.getContig()+":"+ctx.getStart());
}
partition.add(ctx,pedigree,data);
}
}
progress.finish();
in.close();in=null;
}
catch(final Exception err)
{
LOG.error(err);
Platform.exit();
return;
}
finally {
CloserUtil.close(in);
}
final NumberAxis xAxis = new NumberAxis(0.0,1.0,0.1);
xAxis.setLabel("Cases");
final NumberAxis yAxis = new NumberAxis(0.0,1.0,0.1);
yAxis.setLabel("Controls"+(this.controlTag==null?"":"["+this.controlTag+"]"));
final ScatterChart<Number, Number> chart = new ScatterChart<>(xAxis,yAxis);
for(final XYChart.Series<Number,Number> series:partition.getSeries())
{
chart.getData().add(series);
}
String title="Case/Control";
if(!this.args.isEmpty())
{
title= this.args.get(0);
int slash=title.lastIndexOf("/");
if(slash!=-1) title=title.substring(slash+1);
if(title.endsWith(".vcf.gz")) title=title.substring(0, title.length()-7);
if(title.endsWith(".vcf")) title=title.substring(0, title.length()-4);
}
if(userTitle!=null) title=userTitle;
chart.setTitle(title);
chart.setAnimated(false);
chart.setLegendSide(this.legendSide);
final VBox root = new VBox();
MenuBar menuBar = new MenuBar();
Menu menu = new Menu("File");
MenuItem item=new MenuItem("Save image as...");
item.setOnAction(AE->{doMenuSave(chart);});
menu.getItems().add(item);
menu.getItems().add(new SeparatorMenuItem());
item=new MenuItem("Quit");
item.setOnAction(AE->{Platform.exit();});
menu.getItems().add(item);
menuBar.getMenus().add(menu);
root.getChildren().add(menuBar);
final BorderPane contentPane=new BorderPane();
contentPane.setCenter(chart);
root.getChildren().add(contentPane);
Rectangle2D screen=Screen.getPrimary().getVisualBounds();
double minw = Math.max(Math.min(screen.getWidth(),screen.getHeight())-50,50);
chart.setPrefSize(minw, minw);
final Scene scene= new Scene(root,minw,minw);
primaryStage.setScene(scene);
if(this.outputFile!=null)
{
primaryStage.setOnShown(WE->{
LOG.info("saving as "+this.outputFile);
try
{
saveImageAs(chart,this.outputFile);
}
catch(IOException err)
{
LOG.error(err);
System.exit(-1);
}
Platform.exit();
});
}
primaryStage.show();
if(this.outputFile==null)
{
//http://stackoverflow.com/questions/14117867
for(final XYChart.Series<Number,Number> series:partition.getSeries())
{
for (XYChart.Data<Number, Number> d : series.getData()) {
if(dataOpacity>=0 && dataOpacity<1.0)
{
d.getNode().setStyle(d.getNode().getStyle()+"-fx-opacity:0.3;");
}
if(this.add_tooltip) {
final Tooltip tooltip = new Tooltip();
tooltip.setText(
String.format("%s (%f / %f)",
String.valueOf(d.getExtraValue()),
d.getXValue().doubleValue(),
d.getYValue().doubleValue()));
Tooltip.install(d.getNode(), tooltip);
}
}
}
}
}
private void doMenuSave(final ScatterChart<Number, Number> chart)
{
final FileChooser fc = new FileChooser();
File file = fc.showSaveDialog(null);
if(file==null) return;
try {
saveImageAs(chart,file);
} catch (IOException e) {
LOG.error(e);
final Alert alert=new Alert(AlertType.ERROR,e.getMessage());
alert.showAndWait();
}
}
private void saveImageAs(final ScatterChart<Number, Number> chart,File file)
throws IOException
{
WritableImage image = chart.snapshot(new SnapshotParameters(), null);
String format="png";
if(file.getName().toLowerCase().endsWith(".jpg") ||file.getName().toLowerCase().endsWith(".jpeg") )
{
format="jpg";
}
ImageIO.write(SwingFXUtils.fromFXImage(image, null), format, file);
}
public static void main(String[] args) {
Application.launch(args);
}
}