diff --git a/examples/web/StaticLinkExamples.html b/examples/web/StaticLinkExamples.html index 2a8ef314b3..025db83757 100644 --- a/examples/web/StaticLinkExamples.html +++ b/examples/web/StaticLinkExamples.html @@ -7,6 +7,12 @@

IGV desktop links

+

+ Load Cram (hg38) +

+

+ Goto MYC +

Load multiple files, new session, specify genome and locaus diff --git a/src/main/java/org/igv/batch/CommandListener.java b/src/main/java/org/igv/batch/CommandListener.java index b7a39cc53d..5f1165aeb8 100755 --- a/src/main/java/org/igv/batch/CommandListener.java +++ b/src/main/java/org/igv/batch/CommandListener.java @@ -39,6 +39,7 @@ public class CommandListener implements Runnable { private static final String NO_CACHE = "Cache-Control: no-cache, no-store"; private static final String ACCESS_CONTROL_ALLOW_ORIGIN = "Access-Control-Allow-Origin: *"; private static final String ACCESS_CONTROL_ALLOW_HEADERS = "Access-Control-Allow-Headers: access-control-allow-origin"; + private static final String ACCESS_CONTROL_PRIVATE_NETWORK = "Access-Control-Allow-Private-Network: true"; private int port = -1; @@ -299,6 +300,8 @@ private void sendHTTPResponse(PrintWriter out, String result, String contentType out.print(CRLF); out.print(ACCESS_CONTROL_ALLOW_ORIGIN); out.print(CRLF); + out.print(ACCESS_CONTROL_PRIVATE_NETWORK); + out.print(CRLF); if (result != null) { out.print("Content-Type: " + contentType); out.print(CRLF); @@ -328,7 +331,10 @@ private void sendHTTPOptionsResponse(PrintWriter out) { out.print(CRLF); out.print(ACCESS_CONTROL_ALLOW_HEADERS); out.print(CRLF); - out.println("Access-Control-Allow-Methods: HEAD, GET, OPTIONS"); + out.print(ACCESS_CONTROL_PRIVATE_NETWORK); + out.print(CRLF); + out.print("Access-Control-Allow-Methods: HEAD, GET, OPTIONS"); + out.print(CRLF); out.print(CRLF); out.close(); diff --git a/src/main/java/org/igv/bedpe/InteractionTrack.java b/src/main/java/org/igv/bedpe/InteractionTrack.java index 9a363fe714..4c8ed89450 100644 --- a/src/main/java/org/igv/bedpe/InteractionTrack.java +++ b/src/main/java/org/igv/bedpe/InteractionTrack.java @@ -3,7 +3,7 @@ import org.igv.Globals; import org.igv.event.IGVEvent; import org.igv.event.IGVEventObserver; -import org.igv.jbrowse.CircularViewUtilities; +import org.igv.circview.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.Constants; @@ -414,21 +414,18 @@ public List getPopupMenuItems(TrackClickEvent te) { items.add(TrackMenuUtils.getChangeFeatureWindow(Collections.singletonList(this))); - // Experimental JBrowse. - if (PreferencesManager.getPreferences().getAsBoolean(Constants.CIRC_VIEW_ENABLED) && - CircularViewUtilities.ping()) { - items.add(new JPopupMenu.Separator()); - JMenuItem circViewItem = new JMenuItem("Add Features to Circular View"); - circViewItem.addActionListener(e -> { - List frames = te.getFrame() != null ? - Collections.singletonList(te.getFrame()) : - FrameManager.getFrames(); - List visibleFeatures = getVisibleFeatures(frames); - CircularViewUtilities.sendBedpeToJBrowse(visibleFeatures, InteractionTrack.this.getName(), InteractionTrack.this.getColor()); - }); - items.add(circViewItem); - items.add(new JPopupMenu.Separator()); - } + // Circular view + items.add(new JPopupMenu.Separator()); + JMenuItem circViewItem = new JMenuItem("Add Features to Circular View"); + circViewItem.addActionListener(e -> { + List frames = te.getFrame() != null ? + Collections.singletonList(te.getFrame()) : + FrameManager.getFrames(); + List visibleFeatures = getVisibleFeatures(frames); + CircularViewUtilities.addBedPE(visibleFeatures, InteractionTrack.this.getName(), InteractionTrack.this.getColor()); + }); + items.add(circViewItem); + items.add(new JPopupMenu.Separator()); } return items; diff --git a/src/main/java/org/igv/circview/CircularViewUtilities.java b/src/main/java/org/igv/circview/CircularViewUtilities.java new file mode 100644 index 0000000000..6ce7913756 --- /dev/null +++ b/src/main/java/org/igv/circview/CircularViewUtilities.java @@ -0,0 +1,246 @@ +package org.igv.circview; + +import htsjdk.samtools.SAMTag; +import htsjdk.tribble.Feature; +import org.igv.bedpe.BedPE; +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chord; +import org.igv.circview.model.Chromosome; +import org.igv.circview.model.Mate; +import org.igv.circview.ui.CircularView; +import org.igv.circview.ui.CircularViewConfig; +import org.igv.circview.ui.CircularViewPanel; +import org.igv.circview.util.ChrColors; +import org.igv.circview.util.ColorUtils; +import org.igv.feature.genome.Genome; +import org.igv.feature.genome.GenomeManager; +import org.igv.sam.Alignment; +import org.igv.ui.IGV; +import org.igv.util.Downsampler; +import org.igv.variant.Variant; +import org.igv.variant.vcf.MateVariant; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import java.awt.Color; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Bridge between IGV and the in-process {@link CircularView} widget. + * + *

This replaces the JBrowse/Electron based {@code org.igv.jbrowse.CircularViewUtilities}, + * which spoke to an external app over a socket. The view is now a singleton Swing + * {@link JFrame} owned by this class; tracks add chords through the static + * {@code add*} methods, and the window is created and shown lazily on the first add. + * + *

Chord features are built from IGV model objects via the {@code Chord.from*} + * factory methods (the same conversions the old code used), then downsampled to + * {@link #MAX_CHORDS} to keep rendering responsive. + */ +public class CircularViewUtilities { + + /** + * Maximum number of chords to render in a single set. + */ + static int MAX_CHORDS = 10000; + + /** + * Flanking bases added on each side when navigating to a clicked chord's regions. + */ + private static final int CLICK_FLANKING = 2000; + + // Singleton view + window, lazily created on the EDT. + private static CircularView view; + private static CircularViewPanel panel; + private static JFrame frame; + + /** + * Id of the genome currently set as the assembly, to avoid needless resets. + */ + private static String currentGenomeId; + + private CircularViewUtilities() { + } + + // ---- Public API --------------------------------------------------------- + + /** + * True if the circular view window currently exists and is showing. + */ + public static boolean isOpen() { + return frame != null && frame.isVisible(); + } + + public static void addBedPE(List features, String trackName, Color color) { + List chords = new ArrayList<>(features.size()); + for (BedPE f : features) { + chords.add(Chord.fromBedPE(f)); + } + addChords(chords, trackName, color, 0.5f); + } + + public static void addAlignments(List alignments, String trackName, Color color) { + List chords = new ArrayList<>(); + for (Alignment a : alignments) { + if (a.isPaired() && a.getMate().isMapped()) { + chords.add(Chord.fromPEAlignment(a)); + } + if (a.getAttribute(SAMTag.SA.name()) != null) { + chords.addAll(Chord.fromSAString(a)); + } + } + addChords(chords, trackName, color, 0.1f); + } + + public static void addVariants(List variants, String trackName, Color color) { + List chords = new ArrayList<>(variants.size()); + for (Feature f : variants) { + if (f instanceof Variant) { + Variant v = f instanceof MateVariant ? ((MateVariant) f).mate : (Variant) f; + chords.add(Chord.fromVariant(v)); + } + } + addChords(chords, trackName, color, 0.5f); + } + + /** + * Add a set of chords to the view, opening (and creating) the window if needed. + * + * @param chords the chord features + * @param trackName name for this chord set / track row + * @param color base color; {@code alpha} is applied to it + * @param alpha opacity fraction in [0, 1] + */ + public static void addChords(List chords, String trackName, Color color, float alpha) { + Chord[] arr = chords.toArray(new Chord[0]); + if (arr.length > MAX_CHORDS) { + arr = new Downsampler().sample(arr, MAX_CHORDS); + } + final List sampled = new ArrayList<>(Arrays.asList(arr)); + final Color c = ColorUtils.setAlpha(color, alpha); + runOnEdt(() -> { + CircularView v = getInstance(); + ensureAssembly(v); + v.addChords(sampled, trackName, c); + open(); + }); + } + + /** + * Update the assembly to the given genome. No-op unless the view already + * exists; on a genome switch the previously added chords no longer apply, so + * this resets the view (matching {@link CircularView#setAssembly}). + */ + public static void changeGenome(Genome genome) { + if (view == null || genome == null) { + return; + } + runOnEdt(() -> { + view.setAssembly(toAssembly(genome)); + currentGenomeId = genome.getId(); + }); + } + + public static void clearChords() { + if (view != null) { + runOnEdt(view::clearChords); + } + } + + // ---- Internals ---------------------------------------------------------- + + /** + * Lazily create the singleton view and its window. Must run on the EDT. + */ + private static CircularView getInstance() { + if (view == null) { + CircularViewConfig config = new CircularViewConfig(); + config.onChordClick = CircularViewUtilities::onChordClick; + + view = new CircularView(config); + panel = new CircularViewPanel(view); + + frame = new JFrame("Circular View"); + frame.setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE); + frame.setContentPane(panel); + frame.pack(); + frame.setLocationRelativeTo(IGV.hasInstance() ? IGV.getInstance().getMainFrame() : null); + } + return view; + } + + /** + * Must run on the event thread + */ + public static void open() { + if (frame == null) { + getInstance(); + } + if (!frame.isVisible()) { + frame.setVisible(true); + } + frame.toFront(); + + } + + /** + * Set the assembly from the current genome if it differs from what's shown. + */ + private static void ensureAssembly(CircularView v) { + Genome genome = GenomeManager.getInstance().getCurrentGenome(); + if (genome != null && !genome.getId().equals(currentGenomeId)) { + v.setAssembly(toAssembly(genome)); + currentGenomeId = genome.getId(); + } + } + + /** + * Convert an IGV genome to a circular-view {@link Assembly}. Only the "long" + * (whole-genome) chromosomes are drawn; names are shortened so they match the + * shortened {@code refName}s on the chords. + */ + private static Assembly toAssembly(Genome genome) { + List chromosomes = new ArrayList<>(); + for (String chr : genome.getLongChromosomeNames()) { + org.igv.feature.Chromosome c = genome.getChromosome(chr); + String shortName = Chromosome.shortChrName(chr); + chromosomes.add(new Chromosome(shortName, c.getLength(), ChrColors.getChrColor(shortName))); + } + return new Assembly(genome.getDisplayName(), genome.getId(), chromosomes); + } + + /** + * Navigate IGV to a clicked chord's two regions (with flanking), shown + * side-by-side. Port of the onChordClick callback in circularView.js. + */ + private static void onChordClick(Chord feature) { + if (!IGV.hasInstance()) { + return; + } + Mate mate = feature.getMate(); + if (mate == null) { + return; + } + Genome genome = GenomeManager.getInstance().getCurrentGenome(); + String locus1 = locusString(genome, feature.getRefName(), feature.getStart(), feature.getEnd()); + String locus2 = locusString(genome, mate.getRefName(), mate.getStart(), mate.getEnd()); + IGV.getInstance().goToLocus(locus1 + " " + locus2); + } + + private static String locusString(Genome genome, String refName, long start, long end) { + String chr = (genome != null) ? genome.getCanonicalChrName(refName) : refName; + long s = Math.max(0, start - CLICK_FLANKING); + long e = end + CLICK_FLANKING; + return chr + ":" + s + "-" + e; + } + + private static void runOnEdt(Runnable r) { + if (SwingUtilities.isEventDispatchThread()) { + r.run(); + } else { + SwingUtilities.invokeLater(r); + } + } +} diff --git a/src/main/java/org/igv/circview/demo/CircularViewDemo.java b/src/main/java/org/igv/circview/demo/CircularViewDemo.java new file mode 100644 index 0000000000..10bb2c3a3c --- /dev/null +++ b/src/main/java/org/igv/circview/demo/CircularViewDemo.java @@ -0,0 +1,99 @@ +package org.igv.circview.demo; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chord; +import org.igv.circview.model.Chromosome; +import org.igv.circview.model.Mate; +import org.igv.circview.ui.CircularView; +import org.igv.circview.ui.CircularViewConfig; +import org.igv.circview.ui.CircularViewPanel; +import org.igv.circview.util.ChrColors; +import org.igv.circview.util.ColorUtils; + +import javax.swing.JFrame; +import javax.swing.SwingUtilities; +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * Standalone demo: shows the circular view for hg19 with a few sample chords. + * Clicking a chord prints its feature to the console. + */ +public final class CircularViewDemo { + + public static void main(String[] args) { + SwingUtilities.invokeLater(CircularViewDemo::createAndShow); + } + + private static void createAndShow() { + CircularViewConfig config = new CircularViewConfig(); + config.onChordClick = feature -> System.out.println("Chord clicked: " + feature); + + CircularView view = new CircularView(config); + view.setAssembly(hg19()); + view.addChords(sampleChords(), "Structural variants", + ColorUtils.parseColor("rgba(0, 0, 255, 0.35)")); + view.addChords(moreChords(), "Translocations", + ColorUtils.parseColor("rgba(220, 0, 0, 0.45)")); + + CircularViewPanel panel = new CircularViewPanel(view); + + JFrame frame = new JFrame("Circular View (Java) — hg19 demo"); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setContentPane(panel); + frame.pack(); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + } + + /** hg19 assembly, ported from test/hg19.js (names already short). */ + private static Assembly hg19() { + long[][] data = { + {1, 248956422}, {2, 242193529}, {3, 198295559}, {4, 190214555}, + {5, 181538259}, {6, 170805979}, {7, 159345973}, {8, 145138636}, + {9, 138394717}, {10, 133797422}, {11, 135086622}, {12, 133275309}, + {13, 114364328}, {14, 107043718}, {15, 101991189}, {16, 90338345}, + {17, 83257441}, {18, 80373285}, {19, 58617616}, {20, 64444167}, + {21, 46709983}, {22, 50818468}, + }; + List chromosomes = new ArrayList<>(); + for (long[] d : data) { + String name = String.valueOf(d[0]); + chromosomes.add(new Chromosome(name, d[1], ChrColors.getChrColor(name))); + } + chromosomes.add(new Chromosome("X", 156040895, ChrColors.getChrColor("X"))); + chromosomes.add(new Chromosome("Y", 57227415, ChrColors.getChrColor("Y"))); + return new Assembly("Human hg19", "hg19", chromosomes); + } + + /** A handful of intra- and inter-chromosomal chords. */ + private static List sampleChords() { + List chords = new ArrayList<>(); + chords.add(chord("1", 30_000_000, 30_200_000, "8", 95_000_000, 95_200_000)); + chords.add(chord("1", 130_000_000, 130_050_000, "1", 200_000_000, 200_050_000)); + chords.add(chord("3", 50_000_000, 50_100_000, "17", 40_000_000, 40_100_000)); + chords.add(chord("5", 12_000_000, 12_100_000, "12", 60_000_000, 60_100_000)); + chords.add(chord("X", 70_000_000, 70_300_000, "7", 100_000_000, 100_300_000)); + chords.add(chord("22", 20_000_000, 20_100_000, "9", 21_000_000, 21_100_000)); + chords.add(chord("2", 90_000_000, 90_200_000, "2", 180_000_000, 180_200_000)); + return chords; + } + + /** A second chord set, so the controls show more than one row. */ + private static List moreChords() { + List chords = new ArrayList<>(); + chords.add(chord("4", 60_000_000, 60_100_000, "11", 70_000_000, 70_100_000)); + chords.add(chord("6", 40_000_000, 40_100_000, "20", 30_000_000, 30_100_000)); + chords.add(chord("10", 50_000_000, 50_100_000, "13", 80_000_000, 80_100_000)); + chords.add(chord("X", 20_000_000, 20_100_000, "16", 10_000_000, 10_100_000)); + return chords; + } + + private static Chord chord(String ref, long start, long end, + String mateRef, long mateStart, long mateEnd) { + String id = ref + ":" + start + "-" + end + "_" + mateRef + ":" + mateStart + "-" + mateEnd; + Color color = null; // inherit the chord set color + return new Chord(id, ref, start, end, new Mate(mateRef, mateStart, mateEnd), color); + } +} diff --git a/src/main/java/org/igv/circview/model/Assembly.java b/src/main/java/org/igv/circview/model/Assembly.java new file mode 100644 index 0000000000..8cec60d481 --- /dev/null +++ b/src/main/java/org/igv/circview/model/Assembly.java @@ -0,0 +1,54 @@ +package org.igv.circview.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * A genome assembly: an ordered list of chromosomes drawn around the circle. + * + *

Mirrors the {name, id, chromosomes} input to setAssembly() in + * circularView.js. Chromosome names are shortened (leading "chr" stripped) and + * a color is assigned from {@link org.igv.circview.util.ChrColors} when absent. + */ +public final class Assembly { + + private final String name; + private final String id; + private final List chromosomes; + + public Assembly(String name, String id, List chromosomes) { + this.name = name; + this.id = id; + this.chromosomes = new ArrayList<>(chromosomes); + } + + public String getName() { + return name; + } + + public String getId() { + return id; + } + + public List getChromosomes() { + return chromosomes; + } + + /** Total length in base pairs across all chromosomes. */ + public long totalBp() { + long total = 0; + for (Chromosome c : chromosomes) { + total += c.getBpLength(); + } + return total; + } + + public Chromosome getChromosome(String name) { + for (Chromosome c : chromosomes) { + if (c.getName().equals(name)) { + return c; + } + } + return null; + } +} diff --git a/src/main/java/org/igv/jbrowse/Chord.java b/src/main/java/org/igv/circview/model/Chord.java similarity index 65% rename from src/main/java/org/igv/jbrowse/Chord.java rename to src/main/java/org/igv/circview/model/Chord.java index 105ec0925c..78b4a7174e 100644 --- a/src/main/java/org/igv/jbrowse/Chord.java +++ b/src/main/java/org/igv/circview/model/Chord.java @@ -1,27 +1,50 @@ -package org.igv.jbrowse; +package org.igv.circview.model; import htsjdk.samtools.SAMTag; import org.igv.Globals; import org.igv.bedpe.BedPE; -import org.igv.bedpe.BedPEFeature; import org.igv.sam.Alignment; import org.igv.sam.ReadMate; import org.igv.sam.SupplementaryAlignment; import org.igv.variant.Variant; +import java.awt.Color; import java.util.ArrayList; import java.util.List; import java.util.Map; -class Chord { - String uniqueId; - String color; - String refName; - int start; - int end; - Mate mate; +/** + * A chord (arc) connecting one genomic region to its {@link Mate} region. + * + *

Mirrors the chord feature objects passed to addChords() in circularView.js: + *

+ * {
+ *   uniqueId, color, refName, start, end,
+ *   mate: { refName, start, end }
+ * }
+ * 
+ * The per-feature {@code color} is optional; when null the owning + * {@link ChordSet}'s color is used. + */ +public final class Chord { + + private String uniqueId; + private String refName; + private long start; + private long end; + private Mate mate; + private Color color; + + public Chord() { + } - private Chord() { + public Chord(String uniqueId, String refName, long start, long end, Mate mate, Color color) { + this.uniqueId = uniqueId; + this.refName = refName; + this.start = start; + this.end = end; + this.mate = mate; + this.color = color; } public static Chord fromBedPE(BedPE f) { @@ -87,52 +110,39 @@ public static Chord fromVariant(Variant v) { return c; } - public String toJson() { - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("uniqueId", uniqueId)); - buf.append(","); - buf.append(JsonUtils.toJson("color", color)); - buf.append(","); - buf.append(JsonUtils.toJson("refName", refName)); - buf.append(","); - buf.append(JsonUtils.toJson("start", start)); - buf.append(","); - buf.append(JsonUtils.toJson("end", end)); - buf.append(","); - buf.append("\"mate\":"); - buf.append(mate.toJson()); - buf.append("}"); - return buf.toString(); - } - static String shortName(String chr) { return chr.startsWith("chr") ? chr.substring(3) : chr; } -} + + public String getUniqueId() { + return uniqueId; + } + + public String getRefName() { + return refName; + } + public long getStart() { + return start; + } -class Mate { - String refName; - int start; - int end; + public long getEnd() { + return end; + } - public Mate(String refName, int start, int end) { - this.refName = refName; - this.start = start; - this.end = end; + public Mate getMate() { + return mate; } - public String toJson() { - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("refName", refName)); - buf.append(","); - buf.append(JsonUtils.toJson("start", start)); - buf.append(","); - buf.append(JsonUtils.toJson("end", end)); - buf.append("}"); - return buf.toString(); + /** Optional per-feature color; may be null (fall back to the chord set color). */ + public Color getColor() { + return color; } -} + @Override + public String toString() { + return "ChordFeature{" + refName + ":" + start + "-" + end + + " -> " + mate.getRefName() + ":" + mate.getStart() + "-" + mate.getEnd() + + (uniqueId != null ? " id=" + uniqueId : "") + "}"; + } +} diff --git a/src/main/java/org/igv/circview/model/ChordCollection.java b/src/main/java/org/igv/circview/model/ChordCollection.java new file mode 100644 index 0000000000..c76c2916c0 --- /dev/null +++ b/src/main/java/org/igv/circview/model/ChordCollection.java @@ -0,0 +1,25 @@ +package org.igv.circview.model; + +import java.awt.Color; +import java.util.List; + +/** + * A named, colorable, hideable collection of chords. Implemented by both + * {@link ChordSet} (the flat view) and {@link Track} (the grouped view) so the + * controls can drive either without caring which is active. + */ +public interface ChordCollection { + + String getName(); + + Color getColor(); + + void setColor(Color color); + + boolean isVisible(); + + void setVisible(boolean visible); + + /** All chords in this collection. */ + List getChords(); +} diff --git a/src/main/java/org/igv/circview/model/ChordSet.java b/src/main/java/org/igv/circview/model/ChordSet.java new file mode 100644 index 0000000000..37e31acba2 --- /dev/null +++ b/src/main/java/org/igv/circview/model/ChordSet.java @@ -0,0 +1,79 @@ +package org.igv.circview.model; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * A named set of chords with a shared color and visibility, belonging to a track. + * + *

Mirrors the chordSet object built by addChords() in circularView.js: + * {name, trackName, chords, color, trackColor, visible, id}. + */ +public final class ChordSet implements ChordCollection { + + private final String name; + private final String trackName; + private final List chords; + private Color color; + private Color trackColor; + private boolean visible; + private final String id; + + public ChordSet(String name, String trackName, List chords, + Color color, Color trackColor) { + this.name = name; + this.trackName = trackName; + this.chords = new ArrayList<>(chords); + this.color = color; + this.trackColor = trackColor; + this.visible = true; + this.id = shortId(); + } + + public String getName() { + return name; + } + + public String getTrackName() { + return trackName; + } + + public List getChords() { + return chords; + } + + public Color getColor() { + return color; + } + + public void setColor(Color color) { + this.color = color; + } + + public Color getTrackColor() { + return trackColor; + } + + public void setTrackColor(Color trackColor) { + this.trackColor = trackColor; + } + + public boolean isVisible() { + return visible; + } + + public void setVisible(boolean visible) { + this.visible = visible; + } + + public String getId() { + return id; + } + + /** Short random id, the Java analogue of guid() in the JS source. */ + static String shortId() { + return UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/main/java/org/igv/circview/model/ChordSetManager.java b/src/main/java/org/igv/circview/model/ChordSetManager.java new file mode 100644 index 0000000000..0932c7b15e --- /dev/null +++ b/src/main/java/org/igv/circview/model/ChordSetManager.java @@ -0,0 +1,68 @@ +package org.igv.circview.model; + +import java.util.ArrayList; +import java.util.List; + +/** + * Maintains the set of chords as both a flat list of {@link ChordSet}s and a + * grouped list of {@link Track}s. The view chooses which list to render based on + * its "group by track" flag. + * + *

Direct port of chordSetManager.js. + */ +public final class ChordSetManager { + + private final List tracks = new ArrayList<>(); + private final List chordSets = new ArrayList<>(); + + public void addChordSet(ChordSet chordSet) { + // If a chord set with this name exists, replace it (same track, same region). + chordSets.removeIf(g -> g.getName().equals(chordSet.getName())); + chordSets.add(chordSet); + + Track track = null; + for (Track t : tracks) { + if (chordSet.getTrackName().equals(t.getName())) { + track = t; + break; + } + } + if (track != null) { + track.getChordSets().removeIf(cs -> cs.getName().equals(chordSet.getName())); + track.getChordSets().add(chordSet); + } else { + tracks.add(new Track(chordSet)); + } + } + + public void clearChords() { + tracks.clear(); + chordSets.clear(); + } + + public List getTracks() { + return tracks; + } + + public List getChordSets() { + return chordSets; + } + + public Track getTrack(String name) { + for (Track t : tracks) { + if (t.getName().equals(name)) { + return t; + } + } + return null; + } + + public ChordSet getChordSet(String name) { + for (ChordSet cs : chordSets) { + if (cs.getName().equals(name)) { + return cs; + } + } + return null; + } +} diff --git a/src/main/java/org/igv/circview/model/Chromosome.java b/src/main/java/org/igv/circview/model/Chromosome.java new file mode 100644 index 0000000000..42e664a6aa --- /dev/null +++ b/src/main/java/org/igv/circview/model/Chromosome.java @@ -0,0 +1,39 @@ +package org.igv.circview.model; + +import java.awt.Color; + +/** + * A single reference chromosome (or contig) in an {@link Assembly}. + * + *

Mirrors the {name, bpLength, color} chromosome objects in the JS assembly + * input (see hg19.js / setAssembly in circularView.js). + */ +public final class Chromosome { + + private final String name; + private final long bpLength; + private final Color color; + + public Chromosome(String name, long bpLength, Color color) { + this.name = name; + this.bpLength = bpLength; + this.color = color; + } + + public String getName() { + return name; + } + + public long getBpLength() { + return bpLength; + } + + public Color getColor() { + return color; + } + + /** Strip a leading "chr" prefix, matching shortChrName() in circularView.js. */ + public static String shortChrName(String chrName) { + return chrName.startsWith("chr") ? chrName.substring(3) : chrName; + } +} diff --git a/src/main/java/org/igv/circview/model/Mate.java b/src/main/java/org/igv/circview/model/Mate.java new file mode 100644 index 0000000000..3cfadc7bf4 --- /dev/null +++ b/src/main/java/org/igv/circview/model/Mate.java @@ -0,0 +1,30 @@ +package org.igv.circview.model; + +/** + * The far end of a chord: a genomic region {refName, start, end}. + * Mirrors the "mate" object on a chord feature in circularView.js. + */ +public final class Mate { + + private final String refName; + private final long start; + private final long end; + + public Mate(String refName, long start, long end) { + this.refName = refName; + this.start = start; + this.end = end; + } + + public String getRefName() { + return refName; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } +} diff --git a/src/main/java/org/igv/circview/model/Track.java b/src/main/java/org/igv/circview/model/Track.java new file mode 100644 index 0000000000..5b0c4ab312 --- /dev/null +++ b/src/main/java/org/igv/circview/model/Track.java @@ -0,0 +1,71 @@ +package org.igv.circview.model; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * A track groups one or more {@link ChordSet}s under a common name and color. + * Port of IGVTrack in chordSetManager.js. + */ +public final class Track implements ChordCollection { + + private final String name; + private Color color; + private boolean visible; + private final List chordSets; + private final String id; + + public Track(ChordSet chordSet) { + this.name = chordSet.getTrackName(); + this.color = chordSet.getTrackColor(); + this.visible = true; + this.chordSets = new ArrayList<>(); + this.chordSets.add(chordSet); + this.id = ChordSet.shortId(); + } + + public String getName() { + return name; + } + + public Color getColor() { + return color; + } + + public void setColor(Color color) { + this.color = color; + } + + public boolean isVisible() { + return visible; + } + + public void setVisible(boolean visible) { + this.visible = visible; + } + + public List getChordSets() { + return chordSets; + } + + public String getId() { + return id; + } + + /** + * All chords across this track's chord sets. + * Mirrors the {@code get chords()} accessor on IGVTrack. + */ + @Override + public List getChords() { + if (chordSets.size() == 1) { + return chordSets.get(0).getChords(); + } + List all = new ArrayList<>(); + for (ChordSet cs : chordSets) { + all.addAll(cs.getChords()); + } + return all; + } +} diff --git a/src/main/java/org/igv/circview/render/GenomeArcLayout.java b/src/main/java/org/igv/circview/render/GenomeArcLayout.java new file mode 100644 index 0000000000..3109da4381 --- /dev/null +++ b/src/main/java/org/igv/circview/render/GenomeArcLayout.java @@ -0,0 +1,121 @@ +package org.igv.circview.render; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chromosome; + +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Maps genomic coordinates to angles around the circle and angles to points. + * + *

Chromosomes are laid out clockwise starting at the top (12 o'clock), each + * spanning an angle proportional to its length, with a small uniform gap between + * neighbours. This class is pure geometry (no Swing) so it can be unit-tested. + * + *

Angle convention: an angle {@code a} maps to the point + * {@code (cx + r*cos(a), cy + r*sin(a))}. Because screen y grows downward, + * starting at {@code -PI/2} (top) and increasing the angle sweeps clockwise. + */ +public final class GenomeArcLayout { + + /** Angular extent of one chromosome's arc, in radians. */ + public static final class ChromosomeArc { + public final Chromosome chromosome; + public final double startAngle; + public final double endAngle; + + ChromosomeArc(Chromosome chromosome, double startAngle, double endAngle) { + this.chromosome = chromosome; + this.startAngle = startAngle; + this.endAngle = endAngle; + } + + public double span() { + return endAngle - startAngle; + } + } + + private static final double START_ANGLE = -Math.PI / 2.0; // top of circle + + private final double centerX; + private final double centerY; + private final List arcs = new ArrayList<>(); + private final Map arcByName = new HashMap<>(); + private final double gapAngle; + + /** + * @param assembly the genome whose chromosomes are placed around the circle + * @param centerX circle center x in pixels + * @param centerY circle center y in pixels + * @param gapFraction fraction of the full circle (0..1) reserved, in total, + * for the gaps between chromosomes + */ + public GenomeArcLayout(Assembly assembly, double centerX, double centerY, double gapFraction) { + this.centerX = centerX; + this.centerY = centerY; + + List chromosomes = assembly.getChromosomes(); + int n = chromosomes.size(); + long totalBp = assembly.totalBp(); + + double totalGap = (n > 0) ? gapFraction * 2.0 * Math.PI : 0.0; + this.gapAngle = (n > 0) ? totalGap / n : 0.0; + double available = 2.0 * Math.PI - totalGap; + + double angle = START_ANGLE; + for (Chromosome c : chromosomes) { + double span = (totalBp > 0) ? available * (c.getBpLength() / (double) totalBp) : 0.0; + ChromosomeArc arc = new ChromosomeArc(c, angle, angle + span); + arcs.add(arc); + arcByName.put(c.getName(), arc); + angle += span + gapAngle; + } + } + + public List getArcs() { + return arcs; + } + + public double getCenterX() { + return centerX; + } + + public double getCenterY() { + return centerY; + } + + /** Uniform gap angle between adjacent chromosomes, in radians. */ + public double getGapAngle() { + return gapAngle; + } + + /** + * Angle for a base position on a chromosome. Returns {@code NaN} if the + * chromosome is unknown. + */ + public double bpToAngle(String refName, long bp) { + ChromosomeArc arc = arcByName.get(refName); + if (arc == null) { + return Double.NaN; + } + long len = arc.chromosome.getBpLength(); + double frac = (len > 0) ? Math.max(0.0, Math.min(1.0, bp / (double) len)) : 0.0; + return arc.startAngle + frac * arc.span(); + } + + /** True if the layout has an arc for the given chromosome name. */ + public boolean hasChromosome(String refName) { + return arcByName.containsKey(refName); + } + + /** Point at the given angle and radius, relative to the circle center. */ + public Point2D.Double pointAt(double angle, double radius) { + return new Point2D.Double( + centerX + radius * Math.cos(angle), + centerY + radius * Math.sin(angle)); + } +} diff --git a/src/main/java/org/igv/circview/ui/ChordClickListener.java b/src/main/java/org/igv/circview/ui/ChordClickListener.java new file mode 100644 index 0000000000..905eb3926e --- /dev/null +++ b/src/main/java/org/igv/circview/ui/ChordClickListener.java @@ -0,0 +1,12 @@ +package org.igv.circview.ui; + +import org.igv.circview.model.Chord; + +/** + * Callback invoked when a chord is clicked. The Java analogue of the + * onChordClick config option in circularView.js. + */ +@FunctionalInterface +public interface ChordClickListener { + void onChordClick(Chord feature); +} diff --git a/src/main/java/org/igv/circview/ui/CircularView.java b/src/main/java/org/igv/circview/ui/CircularView.java new file mode 100644 index 0000000000..28414323ac --- /dev/null +++ b/src/main/java/org/igv/circview/ui/CircularView.java @@ -0,0 +1,478 @@ +package org.igv.circview.ui; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.ChordCollection; +import org.igv.circview.model.Chord; +import org.igv.circview.model.ChordSet; +import org.igv.circview.model.ChordSetManager; +import org.igv.circview.model.Mate; +import org.igv.circview.render.GenomeArcLayout; + +import javax.swing.JPanel; +import javax.swing.ToolTipManager; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; + +/** + * A Swing component that renders a genome as a circle and draws chords between + * interacting regions (a "Circos" view). + * + *

This is a clean Java2D reimplementation of the view that circularView.js + * delegates to JBrowse. It renders the chromosome ring and chords, hit-tests + * clicks (firing {@link CircularViewConfig#onChordClick}), and shows a hover + * tooltip for the chord under the cursor. The surrounding toolbar and control + * panel live in {@link CircularViewPanel}. + */ +public class CircularView extends JPanel { + + /** + * A chord's rendered geometry, retained for hit-testing. Stores both the + * filled ribbon (for "inside the ribbon" hits on wide chords) and the + * chord's centerline — the quadratic Bézier from region A's midpoint, + * through the circle center, to region B's midpoint — used to pick the + * nearest chord among overlapping ones. + */ + private static final class RenderedChord { + final Path2D shape; + final Rectangle2D bounds; + final Chord feature; + // Centerline (spine): quadratic Bézier (sx0,sy0) -ctrl(scx,scy)-> (sx2,sy2). + final double sx0, sy0, scx, scy, sx2, sy2; + + RenderedChord(Path2D shape, Chord feature, + double sx0, double sy0, double scx, double scy, double sx2, double sy2) { + this.shape = shape; + this.bounds = shape.getBounds2D(); + this.feature = feature; + this.sx0 = sx0; + this.sy0 = sy0; + this.scx = scx; + this.scy = scy; + this.sx2 = sx2; + this.sy2 = sy2; + } + + /** Squared distance from (px,py) to the centerline, sampled along the curve. */ + double centerlineDistanceSq(double px, double py) { + final int segments = 16; + double best = Double.MAX_VALUE; + double prevX = sx0, prevY = sy0; + for (int i = 1; i <= segments; i++) { + double t = i / (double) segments; + double mt = 1 - t; + double bx = mt * mt * sx0 + 2 * mt * t * scx + t * t * sx2; + double by = mt * mt * sy0 + 2 * mt * t * scy + t * t * sy2; + double d = segmentDistanceSq(px, py, prevX, prevY, bx, by); + if (d < best) { + best = d; + } + prevX = bx; + prevY = by; + } + return best; + } + } + + /** Slightly heavier stroke for the hovered chord so thin arcs stand out. */ + private static final BasicStroke HOVER_STROKE = new BasicStroke(1.75f); + + private final CircularViewConfig config; + private final ChordSetManager chordManager = new ChordSetManager(); + private Assembly assembly; + private boolean groupByTrack = false; + + private final List renderedChords = new ArrayList<>(); + + /** The chord currently under the cursor; drawn highlighted. */ + private Chord hoveredChord; + + /** Notified when the set of collections changes (added/cleared/regrouped). */ + private final List structureListeners = new ArrayList<>(); + + public CircularView(CircularViewConfig config) { + this.config = (config != null) ? config : new CircularViewConfig(); + setBackground(Color.WHITE); + setPreferredSize(new Dimension(this.config.width, this.config.height)); + MouseAdapter mouse = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + handleClick(e.getX(), e.getY()); + } + + @Override + public void mouseMoved(MouseEvent e) { + setHoveredChord(chordAt(e.getX(), e.getY())); + } + + @Override + public void mouseExited(MouseEvent e) { + setHoveredChord(null); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + // Enable per-position tooltips (see getToolTipText below). + ToolTipManager.sharedInstance().registerComponent(this); + } + + /** Update the highlighted chord, repainting only when it actually changes. */ + private void setHoveredChord(Chord feature) { + if (feature != hoveredChord) { + hoveredChord = feature; + repaint(); + } + } + + /** Tooltip text for the chord under the cursor, or null when over none. */ + @Override + public String getToolTipText(MouseEvent event) { + Chord feature = chordAt(event.getX(), event.getY()); + return (feature != null) ? feature.toString() : null; + } + + // ---- Public API (subset of the JS CircularView) ------------------------- + + /** Reset the view with a new genome. */ + public void setAssembly(Assembly assembly) { + this.assembly = assembly; + chordManager.clearChords(); + fireStructureChanged(); + repaint(); + } + + /** + * Append (or replace by name) a set of chords. + * + * @param chords the chord features + * @param name chord-set name; the track name is the substring before + * the first space, matching addChords() in circularView.js + * @param color chord-set color (used when a feature has no color) + * @param trackColor track color; defaults to {@code color} when null + */ + public void addChords(List chords, String name, Color color, Color trackColor) { + String setName = (name != null) ? name : "*"; + String trackName = setName.split(" ")[0]; + Color c = (color != null) ? color : Color.BLACK; + Color tc = (trackColor != null) ? trackColor : c; + chordManager.addChordSet(new ChordSet(setName, trackName, chords, c, tc)); + fireStructureChanged(); + repaint(); + } + + public void addChords(List chords, String name, Color color) { + addChords(chords, name, color, color); + } + + public void clearChords() { + chordManager.clearChords(); + fireStructureChanged(); + repaint(); + } + + public boolean isGroupByTrack() { + return groupByTrack; + } + + public void setGroupByTrack(boolean groupByTrack) { + this.groupByTrack = groupByTrack; + fireStructureChanged(); + repaint(); + } + + /** + * The collections currently driving the view: the flat chord sets, or the + * tracks when grouping by track. Mirrors getChordSet() in circularView.js, + * which switches on the groupByTrack flag. + */ + public List getActiveCollections() { + return groupByTrack ? chordManager.getTracks() : chordManager.getChordSets(); + } + + private ChordCollection activeByName(String name) { + for (ChordCollection c : getActiveCollections()) { + if (c.getName().equals(name)) { + return c; + } + } + return null; + } + + /** Set the color of the active collection (chord set or track) with this name. */ + public void setColor(String name, Color color) { + ChordCollection c = activeByName(name); + if (c != null) { + c.setColor(color); + repaint(); + } + } + + public void hideChordSet(String name) { + setVisible(name, false); + } + + public void showChordSet(String name) { + setVisible(name, true); + } + + public void setVisible(String name, boolean visible) { + ChordCollection c = activeByName(name); + if (c != null) { + c.setVisible(visible); + repaint(); + } + } + + public ChordSetManager getChordManager() { + return chordManager; + } + + public CircularViewConfig getConfig() { + return config; + } + + /** Register a listener fired when collections are added, cleared, or regrouped. */ + public void addStructureListener(Runnable listener) { + structureListeners.add(listener); + } + + private void fireStructureChanged() { + for (Runnable r : structureListeners) { + r.run(); + } + } + + // ---- Rendering ---------------------------------------------------------- + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + renderedChords.clear(); + if (assembly == null || assembly.getChromosomes().isEmpty()) { + return; + } + + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + + int w = getWidth(); + int h = getHeight(); + double size = Math.min(w, h); + double cx = w / 2.0; + double cy = h / 2.0; + + double outerRadius = size / 2.0 - config.margin; + if (outerRadius <= 0) { + return; + } + double ringThickness = outerRadius * config.ringThicknessFraction; + double ringInner = outerRadius - ringThickness; + double chordRadius = ringInner; + + GenomeArcLayout layout = new GenomeArcLayout(assembly, cx, cy, config.gapFraction); + + drawChords(g2, layout, chordRadius, cx, cy); + drawRing(g2, layout, ringInner, outerRadius); + if (config.showLabels) { + drawLabels(g2, layout, outerRadius); + } + } finally { + g2.dispose(); + } + } + + private void drawRing(Graphics2D g2, GenomeArcLayout layout, double ringInner, double ringOuter) { + for (GenomeArcLayout.ChromosomeArc arc : layout.getArcs()) { + Path2D band = ringBand(layout, arc.startAngle, arc.endAngle, ringInner, ringOuter); + g2.setColor(arc.chromosome.getColor()); + g2.fill(band); + } + } + + private void drawLabels(Graphics2D g2, GenomeArcLayout layout, double outerRadius) { + g2.setColor(Color.DARK_GRAY); + g2.setFont(getFont().deriveFont(Font.BOLD, 11f)); + FontMetrics fm = g2.getFontMetrics(); + double labelRadius = outerRadius + 14; + for (GenomeArcLayout.ChromosomeArc arc : layout.getArcs()) { + double mid = (arc.startAngle + arc.endAngle) / 2.0; + Point2D p = layout.pointAt(mid, labelRadius); + String name = arc.chromosome.getName(); + int tw = fm.stringWidth(name); + g2.drawString(name, (float) (p.getX() - tw / 2.0), + (float) (p.getY() + fm.getAscent() / 2.0 - 1)); + } + } + + private void drawChords(Graphics2D g2, GenomeArcLayout layout, double chordRadius, double cx, double cy) { + g2.setStroke(new BasicStroke(0.75f)); + RenderedChord hovered = null; + for (ChordCollection collection : getActiveCollections()) { + if (!collection.isVisible()) { + continue; + } + for (Chord chord : collection.getChords()) { + Mate mate = chord.getMate(); + if (mate == null + || !layout.hasChromosome(chord.getRefName()) + || !layout.hasChromosome(mate.getRefName())) { + continue; + } + double a0 = layout.bpToAngle(chord.getRefName(), chord.getStart()); + double a1 = layout.bpToAngle(chord.getRefName(), chord.getEnd()); + double b0 = layout.bpToAngle(mate.getRefName(), mate.getStart()); + double b1 = layout.bpToAngle(mate.getRefName(), mate.getEnd()); + + Path2D ribbon = chordRibbon(layout, a0, a1, b0, b1, chordRadius, cx, cy); + Color color = (chord.getColor() != null) ? chord.getColor() : collection.getColor(); + // Fill and outline both use the chord's alpha so the transparency + // slider is honored. (An opaque outline would mask the fill alpha, + // which matters because most chords are very thin ribbons.) + g2.setColor(color); + g2.fill(ribbon); + g2.draw(ribbon); + + Point2D aMid = layout.pointAt((a0 + a1) / 2.0, chordRadius); + Point2D bMid = layout.pointAt((b0 + b1) / 2.0, chordRadius); + RenderedChord rc = new RenderedChord(ribbon, chord, + aMid.getX(), aMid.getY(), cx, cy, bMid.getX(), bMid.getY()); + renderedChords.add(rc); + if (chord == hoveredChord) { + hovered = rc; + } + } + } + // Redraw the hovered chord opaque-black and on top, so it is unambiguous + // among overlapping arcs before the user clicks. + if (hovered != null) { + g2.setColor(Color.BLACK); + g2.setStroke(HOVER_STROKE); + g2.fill(hovered.shape); + g2.draw(hovered.shape); + } + } + + /** + * A ribbon connecting region [start,end] on one chromosome to [start,end] on + * its mate, with both cross-edges bowing toward the circle center. + */ + private Path2D chordRibbon(GenomeArcLayout layout, double a0, double a1, double b0, double b1, + double r, double cx, double cy) { + Point2D pA0 = layout.pointAt(a0, r); + Point2D pB1 = layout.pointAt(b1, r); + + Path2D path = new Path2D.Double(); + path.moveTo(pA0.getX(), pA0.getY()); + appendRingArc(path, layout, a0, a1, r); // along ring, region A + path.quadTo(cx, cy, pB1.getX(), pB1.getY()); // cross to region B (bow to center) + appendRingArc(path, layout, b1, b0, r); // along ring, region B + path.quadTo(cx, cy, pA0.getX(), pA0.getY()); // cross back to region A + path.closePath(); + return path; + } + + /** Append points along the ring from angle a to angle b (inclusive of b). */ + private void appendRingArc(Path2D path, GenomeArcLayout layout, double a, double b, double r) { + double sweep = b - a; + int steps = Math.max(1, (int) Math.ceil(Math.abs(sweep) / 0.05)); + for (int i = 1; i <= steps; i++) { + double t = a + sweep * (i / (double) steps); + Point2D p = layout.pointAt(t, r); + path.lineTo(p.getX(), p.getY()); + } + } + + private Path2D ringBand(GenomeArcLayout layout, double a, double b, double rInner, double rOuter) { + Path2D path = new Path2D.Double(); + Point2D start = layout.pointAt(a, rOuter); + path.moveTo(start.getX(), start.getY()); + appendRingArc(path, layout, a, b, rOuter); // outer edge a -> b + Point2D inner = layout.pointAt(b, rInner); + path.lineTo(inner.getX(), inner.getY()); + appendRingArc(path, layout, b, a, rInner); // inner edge b -> a + path.closePath(); + return path; + } + + // ---- Hit-testing -------------------------------------------------------- + + private void handleClick(int x, int y) { + Chord feature = chordAt(x, y); + if (feature != null && config.onChordClick != null) { + config.onChordClick.onChordClick(feature); + } + } + + /** Maximum distance, in pixels, from a chord's centerline to count as a hit. */ + private static final int HIT_TOLERANCE = 4; + + /** + * The chord under the point, or null. Among overlapping chords the one whose + * centerline passes nearest the point is chosen; a point inside a (wide) + * ribbon counts as distance zero. The match must lie within + * {@link #HIT_TOLERANCE} pixels. On exact ties the top-most (last drawn) + * chord wins. Linear in the number of chords, with a bounding-box prune. + * + *

Valid only after a paint has populated the rendered shapes. + */ + Chord chordAt(int x, int y) { + final double tol = HIT_TOLERANCE; + final double tolSq = tol * tol; + double bestSq = Double.MAX_VALUE; + Chord best = null; + for (RenderedChord rc : renderedChords) { + // Cheap reject: a point outside the ribbon's bounds (plus tolerance) + // can't be within tolerance of its centerline either. + Rectangle2D b = rc.bounds; + if (x < b.getMinX() - tol || x > b.getMaxX() + tol + || y < b.getMinY() - tol || y > b.getMaxY() + tol) { + continue; + } + double dSq = rc.shape.contains(x, y) ? 0.0 : rc.centerlineDistanceSq(x, y); + if (dSq <= bestSq) { // <= so a later (top-most) chord wins ties + bestSq = dSq; + best = rc.feature; + } + } + return (bestSq <= tolSq) ? best : null; + } + + /** Squared distance from point (px,py) to segment (x1,y1)-(x2,y2). */ + private static double segmentDistanceSq(double px, double py, + double x1, double y1, double x2, double y2) { + double dx = x2 - x1; + double dy = y2 - y1; + double lenSq = dx * dx + dy * dy; + double t = (lenSq == 0.0) ? 0.0 : ((px - x1) * dx + (py - y1) * dy) / lenSq; + t = Math.max(0.0, Math.min(1.0, t)); + double cx = x1 + t * dx; + double cy = y1 + t * dy; + double ex = px - cx; + double ey = py - cy; + return ex * ex + ey * ey; + } + + /** Number of chord shapes recorded by the last paint (test hook). */ + int renderedChordCount() { + return renderedChords.size(); + } + + /** Set the highlighted chord without repainting (test hook). */ + void setHoveredChordForTest(Chord feature) { + this.hoveredChord = feature; + } +} diff --git a/src/main/java/org/igv/circview/ui/CircularViewConfig.java b/src/main/java/org/igv/circview/ui/CircularViewConfig.java new file mode 100644 index 0000000000..1bf36614d0 --- /dev/null +++ b/src/main/java/org/igv/circview/ui/CircularViewConfig.java @@ -0,0 +1,28 @@ +package org.igv.circview.ui; + +/** + * Rendering and behavior options for a {@link CircularView}. Defaults give a + * reasonable Circos-style look; all fields may be overridden before the view is + * shown. + */ +public final class CircularViewConfig { + + /** Nominal view size in pixels (square). */ + public int width = 700; + public int height = 700; + + /** Fraction of the full circle reserved, in total, for gaps between chromosomes. */ + public double gapFraction = 0.015; + + /** Thickness of the chromosome ring as a fraction of the circle radius. */ + public double ringThicknessFraction = 0.03; + + /** Pixel margin between the circle and the panel edge (room for labels). */ + public int margin = 40; + + /** Whether to draw chromosome name labels outside the ring. */ + public boolean showLabels = true; + + /** Invoked when a chord is clicked. Defaults to printing the feature. */ + public ChordClickListener onChordClick = feature -> System.out.println(feature); +} diff --git a/src/main/java/org/igv/circview/ui/CircularViewPanel.java b/src/main/java/org/igv/circview/ui/CircularViewPanel.java new file mode 100644 index 0000000000..73718ab967 --- /dev/null +++ b/src/main/java/org/igv/circview/ui/CircularViewPanel.java @@ -0,0 +1,225 @@ +package org.igv.circview.ui; + +import org.igv.circview.model.ChordCollection; +import org.igv.circview.util.ColorUtils; + +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JColorChooser; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSlider; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; + +/** + * A composed Swing UI for the circular view: a toolbar, a collapsible control + * panel (one row per chord set or track), and the {@link CircularView} itself. + * + *

Port of createControls()/addToControlPanel() in circularView.js. The control + * panel rebuilds itself whenever the view's collections change (chords added, + * cleared, or regrouped), via {@link CircularView#addStructureListener}. + */ +public class CircularViewPanel extends JPanel { + + private static final double EXP5 = Math.exp(5); + private static final Color PANEL_BG = new Color(216, 230, 234); + + /** + * Max characters shown for a collection name. Longer names overflow the row's + * FlowLayout and wrap out of the (single-line) visible height, so they are + * truncated to their tail — the most meaningful part of a track name — with a + * leading ellipsis. The full name remains available in the row's tooltip. + */ + private static final int MAX_NAME_CHARS = 50; + + private final CircularView view; + private final CircularViewConfig config; + + private final JPanel controlPanel = new JPanel(); + private final JButton showControlsButton = new JButton(); + + public CircularViewPanel(CircularView view) { + super(new java.awt.BorderLayout()); + this.view = view; + this.config = view.getConfig(); + + controlPanel.setLayout(new BoxLayout(controlPanel, BoxLayout.Y_AXIS)); + controlPanel.setVisible(false); // hidden by default, matching the JS + controlPanel.setAlignmentX(LEFT_ALIGNMENT); + + JComponent toolbar = createToolbar(); + toolbar.setAlignmentX(LEFT_ALIGNMENT); + + JPanel north = new JPanel(); + north.setLayout(new BoxLayout(north, BoxLayout.Y_AXIS)); + north.add(toolbar); + north.add(controlPanel); + + add(north, java.awt.BorderLayout.NORTH); + add(view, java.awt.BorderLayout.CENTER); + + view.addStructureListener(this::rebuildControlPanel); + rebuildControlPanel(); + } + + /** Convenience: build the view from a config and wrap it. */ + public static CircularViewPanel create(CircularViewConfig config) { + return new CircularViewPanel(new CircularView(config)); + } + + public CircularView getView() { + return view; + } + + /** Show or hide the control panel (the "Show/Hide Controls" toggle). */ + public void setControlsVisible(boolean visible) { + controlPanel.setVisible(visible); + updateShowControlsText(); + revalidate(); + repaint(); + } + + public boolean isControlsVisible() { + return controlPanel.isVisible(); + } + + // ---- Toolbar ------------------------------------------------------------ + + private JComponent createToolbar() { + JPanel toolbar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + + updateShowControlsText(); + showControlsButton.addActionListener(e -> setControlsVisible(!controlPanel.isVisible())); + toolbar.add(showControlsButton); + + JButton clearAll = new JButton("Clear All"); + clearAll.addActionListener(e -> view.clearChords()); + toolbar.add(clearAll); + + return toolbar; + } + + private void updateShowControlsText() { + showControlsButton.setText(controlPanel.isVisible() ? "Hide Controls" : "Show Controls"); + } + + // ---- Control panel ------------------------------------------------------ + + /** Rows in the control panel: the group-by row plus one per collection. Test hook. */ + int controlPanelRowCount() { + return controlPanel.getComponentCount(); + } + + /** The control panel's row component at the given index. Test hook. */ + Component controlPanelRow(int index) { + return controlPanel.getComponent(index); + } + + private void rebuildControlPanel() { + controlPanel.removeAll(); + controlPanel.add(createGroupByRow()); + for (ChordCollection c : view.getActiveCollections()) { + controlPanel.add(createCollectionRow(c)); + } + controlPanel.revalidate(); + controlPanel.repaint(); + } + + private JComponent createGroupByRow() { + JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4)); + row.setBackground(PANEL_BG); + row.setAlignmentX(Component.LEFT_ALIGNMENT); + row.setMaximumSize(new Dimension(Integer.MAX_VALUE, row.getPreferredSize().height)); + + JCheckBox groupBy = new JCheckBox("Group by track", view.isGroupByTrack()); + groupBy.setBackground(PANEL_BG); + groupBy.addActionListener(e -> view.setGroupByTrack(groupBy.isSelected())); + row.add(groupBy); + return row; + } + + private JComponent createCollectionRow(ChordCollection collection) { + final String name = collection.getName(); + + JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 2)); + row.setAlignmentX(Component.LEFT_ALIGNMENT); + + // Hide / Show toggle + JButton hideShow = new JButton(collection.isVisible() ? "Hide" : "Show"); + hideShow.addActionListener(e -> { + boolean nowVisible = !collection.isVisible(); + view.setVisible(name, nowVisible); + hideShow.setText(nowVisible ? "Hide" : "Show"); + }); + row.add(hideShow); + + // Color swatch -> color chooser + JButton swatch = new JButton(); + swatch.setPreferredSize(new Dimension(28, 18)); + swatch.setBackground(opaque(collection.getColor())); + swatch.setOpaque(true); + swatch.setBorder(BorderFactory.createLineBorder(Color.GRAY)); + swatch.setToolTipText("Set arc color"); + row.add(swatch); + + // Alpha (transparency) slider + JSlider alpha = new JSlider(0, 1000, alphaToValue(ColorUtils.getAlpha(collection.getColor()))); + alpha.setPreferredSize(new Dimension(120, alpha.getPreferredSize().height)); + alpha.setToolTipText("Adjust transparency of arcs"); + alpha.addChangeListener(e -> { + float a = valueToAlpha(alpha.getValue()); + view.setColor(name, ColorUtils.setAlpha(collection.getColor(), a)); + }); + row.add(alpha); + + swatch.addActionListener(e -> { + Color chosen = JColorChooser.showDialog(this, "Arc color for " + name, + opaque(collection.getColor())); + if (chosen != null) { + float a = ColorUtils.getAlpha(collection.getColor()); + view.setColor(name, ColorUtils.setAlpha(chosen, a)); + swatch.setBackground(opaque(collection.getColor())); + } + }); + + JLabel label = new JLabel(displayName(name)); + label.setToolTipText(name); + row.add(label); + + row.setMaximumSize(new Dimension(Integer.MAX_VALUE, row.getPreferredSize().height)); + return row; + } + + /** The label text for a collection name: the tail (with a leading ellipsis) when too long. */ + private static String displayName(String name) { + if (name == null) { + return ""; + } + if (name.length() <= MAX_NAME_CHARS) { + return name; + } + return "…" + name.substring(name.length() - MAX_NAME_CHARS); + } + + // ---- Alpha <-> slider value mapping (ported from circularView.js) ------- + + private static float valueToAlpha(int value) { + return (float) (Math.exp(value / 200.0) / EXP5); + } + + private static int alphaToValue(float alpha) { + double a = Math.max(1e-6, alpha); + int v = (int) Math.round(200.0 * Math.log(a * EXP5)); + return Math.max(0, Math.min(1000, v)); + } + + private static Color opaque(Color c) { + return new Color(c.getRed(), c.getGreen(), c.getBlue()); + } +} diff --git a/src/main/java/org/igv/circview/util/ChrColors.java b/src/main/java/org/igv/circview/util/ChrColors.java new file mode 100644 index 0000000000..8da575754f --- /dev/null +++ b/src/main/java/org/igv/circview/util/ChrColors.java @@ -0,0 +1,110 @@ +package org.igv.circview.util; + +import java.awt.Color; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +/** + * Default per-chromosome colors. Port of chrColor.js. + * + *

Lookups try the bare name, then a "chr"-prefixed name, and otherwise assign + * (and cache) a random color, matching getChrColor() in the JS source. Two source + * entries (chrY, chrUn) were missing a closing paren in the original RGB strings; + * they are corrected here. + */ +public final class ChrColors { + + private static final Map CHR_COLOR_MAP = new HashMap<>(); + private static final Random RANDOM = new Random(); + + private ChrColors() { + } + + /** + * Color for a chromosome name. Returns a stable color for known names and a + * cached random color for unknown ones. + */ + public static synchronized Color getChrColor(String chr) { + Color c = CHR_COLOR_MAP.get(chr); + if (c != null) { + return c; + } + Color prefixed = CHR_COLOR_MAP.get("chr" + chr); + if (prefixed != null) { + CHR_COLOR_MAP.put(chr, prefixed); + return prefixed; + } + Color random = randomRGB(); + CHR_COLOR_MAP.put(chr, random); + return random; + } + + private static Color randomRGB() { + return new Color(RANDOM.nextInt(256), RANDOM.nextInt(256), RANDOM.nextInt(256)); + } + + static { + put("chrX", 204, 153, 0); + put("chrY", 153, 204, 0); // source bug: missing ')' in chrColor.js + put("chrUn", 50, 50, 50); + put("chr1", 80, 80, 255); + put("chrI", 139, 155, 187); + put("chr2", 206, 61, 50); + put("chrII", 206, 61, 50); + put("chr2a", 216, 71, 60); + put("chr2b", 226, 81, 70); + put("chr3", 116, 155, 88); + put("chrIII", 116, 155, 88); + put("chr4", 240, 230, 133); + put("chrIV", 240, 230, 133); + put("chr5", 70, 105, 131); + put("chr6", 186, 99, 56); + put("chr7", 93, 177, 221); + put("chr8", 128, 34, 104); + put("chr9", 107, 215, 107); + put("chr10", 213, 149, 167); + put("chr11", 146, 72, 34); + put("chr12", 131, 123, 141); + put("chr13", 199, 81, 39); + put("chr14", 213, 143, 92); + put("chr15", 122, 101, 165); + put("chr16", 228, 175, 105); + put("chr17", 59, 27, 83); + put("chr18", 205, 222, 183); + put("chr19", 97, 42, 121); + put("chr20", 174, 31, 99); + put("chr21", 231, 199, 111); + put("chr22", 90, 101, 94); + put("chr23", 204, 153, 0); + put("chr24", 153, 204, 0); + put("chr25", 51, 204, 0); + put("chr26", 0, 204, 51); + put("chr27", 0, 204, 153); + put("chr28", 0, 153, 204); + put("chr29", 10, 71, 255); + put("chr30", 71, 117, 255); + put("chr31", 255, 194, 10); + put("chr32", 255, 209, 71); + put("chr33", 153, 0, 51); + put("chr34", 153, 26, 0); + put("chr35", 153, 102, 0); + put("chr36", 128, 153, 0); + put("chr37", 51, 153, 0); + put("chr38", 0, 153, 26); + put("chr39", 0, 153, 102); + put("chr40", 0, 128, 153); + put("chr41", 0, 51, 153); + put("chr42", 26, 0, 153); + put("chr43", 102, 0, 153); + put("chr44", 153, 0, 128); + put("chr45", 214, 0, 71); + put("chr46", 255, 20, 99); + put("chr47", 0, 214, 143); + put("chr48", 20, 255, 177); + } + + private static void put(String name, int r, int g, int b) { + CHR_COLOR_MAP.put(name, new Color(r, g, b)); + } +} diff --git a/src/main/java/org/igv/circview/util/ColorUtils.java b/src/main/java/org/igv/circview/util/ColorUtils.java new file mode 100644 index 0000000000..08dad9e3d5 --- /dev/null +++ b/src/main/java/org/igv/circview/util/ColorUtils.java @@ -0,0 +1,98 @@ +package org.igv.circview.util; + +import java.awt.Color; + +/** + * Parsing and manipulation of CSS-style color strings used by the JS source + * ({@code rgb(...)}, {@code rgba(...)}, and {@code #hex}). + * + *

The alpha helpers mirror setAlpha()/getAlpha() in circularView.js, where + * alpha is a fraction in [0, 1]. + */ +public final class ColorUtils { + + private ColorUtils() { + } + + /** + * Parse a CSS color string into an AWT Color. + * Supports {@code rgb(r,g,b)}, {@code rgba(r,g,b,a)} (a in [0,1]), + * {@code #rgb}, and {@code #rrggbb}, plus a handful of common named colors. + * + * @throws IllegalArgumentException if the string cannot be parsed + */ + public static Color parseColor(String s) { + if (s == null) { + throw new IllegalArgumentException("color string is null"); + } + String c = s.trim(); + String lower = c.toLowerCase(); + + if (lower.startsWith("rgba(") || lower.startsWith("rgb(")) { + int open = c.indexOf('('); + int close = c.indexOf(')'); + String body = close > open ? c.substring(open + 1, close) : c.substring(open + 1); + String[] parts = body.split(","); + int r = clamp255((int) Math.round(parseNum(parts[0]))); + int g = clamp255((int) Math.round(parseNum(parts[1]))); + int b = clamp255((int) Math.round(parseNum(parts[2]))); + int a = 255; + if (parts.length >= 4) { + a = clamp255((int) Math.round(parseNum(parts[3]) * 255.0)); + } + return new Color(r, g, b, a); + } + + if (c.startsWith("#")) { + String hex = c.substring(1); + if (hex.length() == 3) { + int r = Integer.parseInt(hex.substring(0, 1), 16) * 17; + int g = Integer.parseInt(hex.substring(1, 2), 16) * 17; + int b = Integer.parseInt(hex.substring(2, 3), 16) * 17; + return new Color(r, g, b); + } else if (hex.length() == 6) { + int r = Integer.parseInt(hex.substring(0, 2), 16); + int g = Integer.parseInt(hex.substring(2, 4), 16); + int b = Integer.parseInt(hex.substring(4, 6), 16); + return new Color(r, g, b); + } + } + + switch (lower) { + case "black": + return Color.BLACK; + case "white": + return Color.WHITE; + case "red": + return Color.RED; + case "green": + return Color.GREEN; + case "blue": + return Color.BLUE; + case "gray": + case "grey": + return Color.GRAY; + default: + throw new IllegalArgumentException("Unrecognized color: " + s); + } + } + + /** Return a copy of {@code color} with the given alpha fraction in [0, 1]. */ + public static Color setAlpha(Color color, float alpha) { + int a = clamp255(Math.round(alpha * 255f)); + return new Color(color.getRed(), color.getGreen(), color.getBlue(), a); + } + + /** Alpha of {@code color} as a fraction in [0, 1]. */ + public static float getAlpha(Color color) { + return color.getAlpha() / 255f; + } + + private static double parseNum(String s) { + return Double.parseDouble(s.trim()); + } + + private static int clamp255(int v) { + return Math.max(0, Math.min(255, v)); + } +} diff --git a/src/main/java/org/igv/feature/genome/GenomeManager.java b/src/main/java/org/igv/feature/genome/GenomeManager.java index c30fb676eb..27c9d38bc5 100644 --- a/src/main/java/org/igv/feature/genome/GenomeManager.java +++ b/src/main/java/org/igv/feature/genome/GenomeManager.java @@ -17,7 +17,7 @@ import org.igv.feature.genome.load.GenomeConfig; import org.igv.feature.genome.load.GenomeLoader; import org.igv.feature.genome.load.TrackConfig; -import org.igv.jbrowse.CircularViewUtilities; +import org.igv.circview.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.Constants; @@ -203,9 +203,7 @@ public void setCurrentGenome(Genome newGenome) { PreferencesManager.getPreferences().setLastGenome(newGenome.getId()); } - if (PreferencesManager.getPreferences().getAsBoolean(Constants.CIRC_VIEW_ENABLED) && CircularViewUtilities.ping()) { - CircularViewUtilities.changeGenome(newGenome); - } + CircularViewUtilities.changeGenome(newGenome); IGVEventBus.getInstance().post(new GenomeChangeEvent(newGenome)); } diff --git a/src/main/java/org/igv/jbrowse/CircViewAssembly.java b/src/main/java/org/igv/jbrowse/CircViewAssembly.java deleted file mode 100644 index 50c5f35db4..0000000000 --- a/src/main/java/org/igv/jbrowse/CircViewAssembly.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.igv.jbrowse; - -class CircViewAssembly { - String id; - String name; - CircViewRegion [] chromosomes; - - public CircViewAssembly(String id, String name, CircViewRegion[] regions) { - this.id = id; - this.name = name; - this.chromosomes = regions; - } - - public String toJson() { - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("id", id)); - buf.append(","); - buf.append(JsonUtils.toJson("name", name)); - buf.append(",\"chromosomes\":"); - buf.append("["); - boolean first = true; - for(CircViewRegion c : this.chromosomes) { - if(!first) { - buf.append(","); - } - buf.append(c.toJson()); - first = false; - } - buf.append("]"); - buf.append("}"); - return buf.toString(); - } -} - -class CircViewRegion { - String name; - int bpLength; - String color; - public CircViewRegion(String name, int bpLength, String color) { - this.name = name; - this.bpLength = bpLength; - this.color = color; - } - - public String toJson() { - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("name", name)); - buf.append(","); - buf.append(JsonUtils.toJson("color", color)); - buf.append(","); - buf.append(JsonUtils.toJson("bpLength", bpLength)); - buf.append("}"); - return buf.toString(); - } -} diff --git a/src/main/java/org/igv/jbrowse/CircViewTrack.java b/src/main/java/org/igv/jbrowse/CircViewTrack.java deleted file mode 100644 index 0357229e21..0000000000 --- a/src/main/java/org/igv/jbrowse/CircViewTrack.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.igv.jbrowse; - -class CircViewTrack { - String name; - String color; - Chord[] chords; - - public CircViewTrack(Chord[] chords, String name, String color) { - this.name = name; - this.color = color; - this.chords = chords; - } - - public String toJson() { - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("name", name)); - buf.append(","); - buf.append(JsonUtils.toJson("color", color)); - buf.append(",\"chords\":"); - buf.append("["); - boolean first = true; - for(Chord c : chords) { - if(!first) { - buf.append(","); - } - buf.append(c.toJson()); - first = false; - } - buf.append("]"); - buf.append("}"); - return buf.toString(); - } -} diff --git a/src/main/java/org/igv/jbrowse/CircularViewUtilities.java b/src/main/java/org/igv/jbrowse/CircularViewUtilities.java deleted file mode 100644 index 1ac7d95b8c..0000000000 --- a/src/main/java/org/igv/jbrowse/CircularViewUtilities.java +++ /dev/null @@ -1,315 +0,0 @@ -package org.igv.jbrowse; - -import htsjdk.samtools.SAMTag; -import htsjdk.tribble.Feature; -import org.igv.bedpe.BedPE; -import org.igv.feature.Chromosome; -import org.igv.feature.genome.Genome; -import org.igv.feature.genome.GenomeManager; -import org.igv.sam.Alignment; -import org.igv.ui.color.ColorUtilities; -import org.igv.util.ChromosomeColors; -import org.igv.util.Downsampler; -import org.igv.variant.Variant; -import org.igv.variant.vcf.MateVariant; - -import java.awt.*; -import java.util.ArrayList; -import java.util.List; - -public class CircularViewUtilities { - - static int MAX_CHORDS = 10000; // Maximum number of chords to send to electron app - - public static boolean ping() { - try { - String response = CircviewSocketWriter.send("{\"message\": \"ping\"}", true); - return "OK".equals(response); - } catch (Exception e) { - return false; - } - } - - public static void sendBedpeToJBrowse(List features, String trackName, Color color) { - Chord[] chords = new Chord[features.size()]; - int index = 0; - for (BedPE f : features) { - chords[index++] = Chord.fromBedPE(f); - } - sendChordsToJBrowse(chords, trackName, color, "0.5"); - } - - public static void sendAlignmentsToJBrowse(List alignments, String trackName, Color color) { - - List chords = new ArrayList<>(); - for (Alignment a : alignments) { - if (a.isPaired() && a.getMate().isMapped()) { - chords.add(Chord.fromPEAlignment(a)); - } - if (a.getAttribute(SAMTag.SA.name()) != null) { - chords.addAll(Chord.fromSAString(a)); - } - } - Chord[] chordsArray = new Chord[chords.size()]; - chordsArray = chords.toArray(chordsArray); - sendChordsToJBrowse(chordsArray, trackName, color, "0.1"); - } - - - public static void sendVariantsToJBrowse(List variants, String trackName, Color color) { - - Chord[] chords = new Chord[variants.size()]; - int index = 0; - for (Feature f : variants) { - if (f instanceof Variant) { - Variant v = f instanceof MateVariant ? ((MateVariant) f).mate : (Variant) f; - chords[index++] = Chord.fromVariant(v); - } - } - sendChordsToJBrowse(chords, trackName, color, "0.5"); - } - - - public static void sendChordsToJBrowse(Chord[] chords, String trackName, Color color, String alpha) { - - // We can't know if an assembly has been set, or if it has its the correct one. - changeGenome(GenomeManager.getInstance().getCurrentGenome()); - - // Downsample chords if neccessary, otherwise risk crashes in electron app - if(chords.length > MAX_CHORDS) { - Downsampler ds = new Downsampler<>(); - chords = ds.sample(chords, MAX_CHORDS); - } - - String colorString = "rgba(" + color.getRed() + "," + color.getGreen() + "," + color.getBlue() + "," + alpha + ")"; - CircViewTrack t = new CircViewTrack(chords, trackName, colorString); - CircViewMessage message = new CircViewMessage("addChords", t); - - - String json = message.toJson(); - //System.out.println(json); - CircviewSocketWriter.send(json); - } - - public static void changeGenome(Genome genome) { - List wgChrNames = genome.getLongChromosomeNames(); - CircViewRegion[] regions = new CircViewRegion[wgChrNames.size()]; - int idx = 0; - for (String chr : wgChrNames) { - Chromosome c = genome.getChromosome(chr); - int length = c.getLength(); - Color color = ChromosomeColors.getColor(chr); - String colorString = "rgb(" + ColorUtilities.colorToString(color) + ")"; - regions[idx++] = new CircViewRegion(chr, length, colorString); - } - CircViewAssembly assm = new CircViewAssembly(genome.getId(), genome.getDisplayName(), regions); - CircViewMessage message = new CircViewMessage("setAssembly", assm); - - String json = message.toJson(); - //System.out.println(); - CircviewSocketWriter.send(json); - - } - - public static void clearAll() { - CircviewSocketWriter.send("{\"message\": \"clearChords\"}", true); - } -} - -class CircViewMessage { - String message; - CircViewAssembly assembly; - CircViewTrack track; - - public CircViewMessage(String message, CircViewAssembly data) { - this.message = message; - this.assembly = data; - } - - public CircViewMessage(String message, CircViewTrack data) { - this.message = message; - this.track = data; - } - - public String toJson() { - - StringBuffer buf = new StringBuffer(); - buf.append("{"); - buf.append(JsonUtils.toJson("message", message)); - buf.append(",\"data\":"); - if (this.assembly != null) { - buf.append(assembly.toJson()); - } else if (this.track != null) { - buf.append(track.toJson()); - } - buf.append("}"); - return buf.toString(); - } - -} - -/* - - -const MINIMUM_SV_LENGTH = 1000000; - - const circViewIsInstalled = () => CircularView.isInstalled(); - - const shortChrName = (chrName) => { - return chrName.startsWith("chr") ? chrName.substring(3) : chrName; - } - - const makePairedAlignmentChords = (alignments, color) => { - color = color || 'rgba(0, 0, 255, 0.02)' - const chords = []; - for (let a of alignments) { - const mate = a.mate; - if (mate && mate.chr && mate.position) { - chords.push({ - uniqueId: a.readName, - refName: shortChrName(a.chr), - start: a.start, - end: a.end, - mate: { - refName: shortChrName(mate.chr), - start: mate.position - 1, - end: mate.position, - }, - color: color - }); - } - } - return chords; - } - - const makeBedPEChords = (features, color) => { - - color = color || 'rgb(0,0,255)'; - - return features.map(v => { - - // If v is a whole-genome feature, get the true underlying variant. - const f = v._f || v; - - return { - uniqueId: `${f.chr1}:${f.start1}-${f.end1}_${f.chr2}:${f.start2}-${f.end2}`, - refName: shortChrName(f.chr1), - start: f.start1, - end: f.end1, - mate: { - refName: shortChrName(f.chr2), - start: f.start2, - end: f.end2, - }, - color: color, - igvtype: 'bedpe' - } - }) - } - - - const makeVCFChords = (features, color) => { - - color = color || 'rgb(0,0,255)'; - - const svFeatures = features.filter(v => { - const f = v._f || v; - const isLargeEnough = f.info.CHR2 && f.info.END && - (f.info.CHR2 !== f.chr || Math.abs(Number.parseInt(f.info.END) - f.pos) > MINIMUM_SV_LENGTH); - return isLargeEnough; - }); - return svFeatures.map(v => { - - // If v is a whole-genome feature, get the true underlying variant. - const f = v._f || v; - - const pos2 = Number.parseInt(f.info.END); - const start2 = pos2 - 100; - const end2 = pos2 + 100; - - return { - uniqueId: `${f.chr}:${f.start}-${f.end}_${f.info.CHR2}:${f.info.END}`, - refName: shortChrName(f.chr), - start: f.start, - end: f.end, - mate: { - refName: shortChrName(f.info.CHR2), - start: start2, - end: end2 - }, - color: color, - igvtype: 'vcf' - } - }) - } - - const makeCircViewChromosomes = (genome) => { - const regions = []; - const colors = []; - for (let chrName of genome.wgChromosomeNames) { - const chr = genome.getChromosome(chrName); - colors.push(getChrColor(chr.name)); - regions.push( - { - name: chr.name, - bpLength: chr.bpLength - } - ) - } - return regions; - } - - - function createCircularView(el, browser) { - - const circularView = new CircularView(el, { - - assembly: { - name: browser.genome.id, - id: browser.genome.id, - chromosomes: makeCircViewChromosomes(browser.genome) - }, - - onChordClick: (feature, chordTrack, pluginManager) => { - - const f1 = feature.data; - const f2 = f1.mate; - const flanking = 2000; - - const l1 = new Locus({chr: browser.genome.getChromosomeName(f1.refName), start: f1.start, end: f1.end}); - const l2 = new Locus({chr: browser.genome.getChromosomeName(f2.refName), start: f2.start, end: f2.end}); - - let loci; - if ("alignment" === f1.igvtype) { // append - loci = this.currentLoci().map(str => Locus.fromLocusString(str)); - for (let l of [l1, l2]) { - if (!loci.some(locus => { - return locus.contains(l) - })) { - // add flanking - l.start = Math.max(0, l.start - flanking); - l.end += flanking; - loci.push(l) - } - } - } else { - l1.start = Math.max(0, l1.start - flanking); - l1.end += flanking; - l2.start = Math.max(0, l2.start - flanking); - l2.end += flanking; - loci = [l1, l2]; - } - - const searchString = loci.map(l => l.getLocusString()).join(" "); - browser.search(searchString); - } - }); - browser.circularView = circularView; - circularView.hide(); - return circularView; - } - - export {circViewIsInstalled, makeBedPEChords, makePairedAlignmentChords, makeVCFChords, createCircularView} - - - */ \ No newline at end of file diff --git a/src/main/java/org/igv/jbrowse/CircviewSocketWriter.java b/src/main/java/org/igv/jbrowse/CircviewSocketWriter.java deleted file mode 100644 index 68141e746b..0000000000 --- a/src/main/java/org/igv/jbrowse/CircviewSocketWriter.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.igv.jbrowse; - -import org.igv.logging.*; -import org.igv.prefs.Constants; -import org.igv.prefs.PreferencesManager; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.PrintWriter; -import java.net.Socket; -import java.net.UnknownHostException; - -class CircviewSocketWriter { - - static Logger log = LogManager.getLogger(CircviewSocketWriter.class); - - static String send(String json) { - return send(json, false); - } - - static String send(String json, boolean suppressErrors) { - Socket socket = null; - PrintWriter out = null; - BufferedReader in = null; - try { - String host = PreferencesManager.getPreferences().get(Constants.CIRC_VIEW_HOST); - int port = PreferencesManager.getPreferences().getAsInt(Constants.CIRC_VIEW_PORT); - socket = new Socket(host, port); - out = new PrintWriter(socket.getOutputStream(), true); - in = new BufferedReader(new InputStreamReader(socket.getInputStream())); - - out.println(json); - out.flush(); - String response = in.readLine(); - return response; - } catch (UnknownHostException e) { - String err = "Unknown host exception: " + e.getMessage(); - if (!suppressErrors) { - log.error(e); - } - return err; - - } catch (IOException e) { - String message = "IO Exception: " + e.getMessage(); - if (!suppressErrors) { - log.error(message, e); - } - return message; - } finally { - try { - in.close(); - out.close(); - socket.close(); - } catch (IOException e) { - log.error(e); - } - } - } -} diff --git a/src/main/java/org/igv/jbrowse/JsonUtils.java b/src/main/java/org/igv/jbrowse/JsonUtils.java deleted file mode 100644 index 217ca1d278..0000000000 --- a/src/main/java/org/igv/jbrowse/JsonUtils.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.igv.jbrowse; - -class JsonUtils { - - public static String toJson(String name, String value) { - return "\"" + name + "\": \"" + value + "\""; - } - - public static String toJson(String name, int value) { - return "\"" + name + "\": " + value; - } -} diff --git a/src/main/java/org/igv/prefs/Constants.java b/src/main/java/org/igv/prefs/Constants.java index b361494c69..5861e9e84b 100644 --- a/src/main/java/org/igv/prefs/Constants.java +++ b/src/main/java/org/igv/prefs/Constants.java @@ -324,10 +324,6 @@ private Constants() { public static final String AWS_ENDPOINT_URL = "AWS_ENDPOINT_URL"; - // JBrowse circular view integration - public static final String CIRC_VIEW_ENABLED = "CIRC_VIEW_ENABLED"; - public static final String CIRC_VIEW_PORT = "CIRC_VIEW_PORT"; - public static final String CIRC_VIEW_HOST = "CIRC_VIEW_HOST"; // Misc URLS public static final String ENCODE_FILELIST_URL = "ENCODE_FILELIST_URL"; diff --git a/src/main/java/org/igv/prefs/IGVPreferences.java b/src/main/java/org/igv/prefs/IGVPreferences.java index 88ce762e75..1da43b13c4 100644 --- a/src/main/java/org/igv/prefs/IGVPreferences.java +++ b/src/main/java/org/igv/prefs/IGVPreferences.java @@ -270,7 +270,6 @@ public void putAll(Map updatedPrefs) { checkForAlignmentChanges(updatedPrefs); checkForCommandListenerChanges(updatedPrefs); checkForAttributePanelChanges(updatedPrefs); - checkForCircViewChanges(updatedPrefs); checkForGoogleMenuChange(updatedPrefs); checkForRestartChanges(updatedPrefs); IGVEventBus.getInstance().post(new PreferencesChangeEvent()); @@ -372,18 +371,6 @@ private void checkForAttributePanelChanges(Map updatedPreference } } - /** - * Enabling circ view requires port listener - */ - private void checkForCircViewChanges(Map updatedPreferenceMap) { - if (updatedPreferenceMap.containsKey(CIRC_VIEW_ENABLED) && - getAsBoolean(CIRC_VIEW_ENABLED) && - !getAsBoolean(PORT_ENABLED)) { - put(PORT_ENABLED, true); - CommandListener.start(getAsInt(PORT_NUMBER)); - } - } - public void remove(String key) { overrideKeys.remove(key); diff --git a/src/main/java/org/igv/sam/AlignmentTrack.java b/src/main/java/org/igv/sam/AlignmentTrack.java index c0919ac9dc..30664d3e4f 100644 --- a/src/main/java/org/igv/sam/AlignmentTrack.java +++ b/src/main/java/org/igv/sam/AlignmentTrack.java @@ -6,7 +6,7 @@ import org.igv.feature.FeatureUtils; import org.igv.feature.Range; import org.igv.feature.genome.Genome; -import org.igv.jbrowse.CircularViewUtilities; +import org.igv.circview.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.Constants; @@ -1091,7 +1091,7 @@ void sendPairsToCircularView(TrackClickEvent e) { } } Color chordColor = AlignmentTrack.this.getColor().equals(DEFAULT_ALIGNMENT_COLOR) ? Color.BLUE : AlignmentTrack.this.getColor(); - CircularViewUtilities.sendAlignmentsToJBrowse(inView, AlignmentTrack.this.getName(), chordColor); + CircularViewUtilities.addAlignments(inView, AlignmentTrack.this.getName(), chordColor); } } @@ -1115,7 +1115,7 @@ void sendSplitToCircularView(TrackClickEvent e) { } } Color chordColor = AlignmentTrack.this.getColor().equals(DEFAULT_ALIGNMENT_COLOR) ? Color.BLUE : AlignmentTrack.this.getColor(); - CircularViewUtilities.sendAlignmentsToJBrowse(inView, AlignmentTrack.this.getName(), chordColor); + CircularViewUtilities.addAlignments(inView, AlignmentTrack.this.getName(), chordColor); } } @@ -1886,20 +1886,20 @@ public void unmarshalJSON(org.json.JSONObject json) { } } if (json.has("shadeCenters")) { - shadeCenters = Boolean.parseBoolean(json.getString("shadeCenters")); + shadeCenters = json.getBoolean("shadeCenters"); } if (json.has("showAllBases")) { - showAllBases = Boolean.parseBoolean(json.getString("showAllBases")); + showAllBases = json.getBoolean("showAllBases"); } if (json.has("flagUnmappedPairs")) { - flagUnmappedPairs = Boolean.parseBoolean(json.getString("flagUnmappedPairs")); + flagUnmappedPairs = json.getBoolean("flagUnmappedPairs"); } if (json.has("minTLEN")) { - minInsertSize = Integer.parseInt(json.getString("minTLEN")); + minInsertSize = json.getInt("minTLEN"); } if (json.has("maxTLEN")) { - maxInsertSize = Integer.parseInt(json.getString("maxTLEN")); + maxInsertSize = json.getInt("maxTLEN"); } if (json.has("colorOption")) { // Convert deprecated options @@ -1934,13 +1934,13 @@ public void unmarshalJSON(org.json.JSONObject json) { duplicatesOption = CollUtils.valueOf(DuplicatesOption.class, json.getString("duplicatesOption"), null); } if (json.has("mappingQualityLow")) { - mappingQualityLow = Integer.parseInt(json.getString("mappingQualityLow")); + mappingQualityLow = json.getInt("mappingQualityLow"); } if (json.has("mappingQualityHigh")) { - mappingQualityHigh = Integer.parseInt(json.getString("mappingQualityHigh")); + mappingQualityHigh = json.getInt("mappingQualityHigh"); } if (json.has("viewPairs")) { - viewPairs = Boolean.parseBoolean(json.getString("viewPairs")); + viewPairs = json.getBoolean("viewPairs"); } if (json.has("colorByTag")) { colorByTag = json.getString("colorByTag"); @@ -1955,16 +1955,16 @@ public void unmarshalJSON(org.json.JSONObject json) { linkByTag = json.getString("linkByTag"); } if (json.has("linkedReads")) { - linkedReads = Boolean.parseBoolean(json.getString("linkedReads")); + linkedReads = json.getBoolean("linkedReads"); } if (json.has("quickConsensusMode")) { - quickConsensusMode = Boolean.parseBoolean(json.getString("quickConsensusMode")); + quickConsensusMode = json.getBoolean("quickConsensusMode"); } if (json.has("showMismatches")) { - showMismatches = Boolean.parseBoolean(json.getString("showMismatches")); + showMismatches = json.getBoolean("showMismatches"); } if (json.has("computeIsizes")) { - computeIsizes = Boolean.parseBoolean(json.getString("computeIsizes")); + computeIsizes = json.getBoolean("computeIsizes"); } if (json.has("minTLENPercentile")) { minInsertSizePercentile = Double.parseDouble(json.getString("minTLENPercentile")); @@ -1973,29 +1973,29 @@ public void unmarshalJSON(org.json.JSONObject json) { maxInsertSizePercentile = Double.parseDouble(json.getString("maxTLENPercentile")); } if (json.has("pairedArcView")) { - pairedArcView = Boolean.parseBoolean(json.getString("pairedArcView")); + pairedArcView = json.getBoolean("pairedArcView"); } if (json.has("flagZeroQualityAlignments")) { - flagZeroQualityAlignments = Boolean.parseBoolean(json.getString("flagZeroQualityAlignments")); + flagZeroQualityAlignments = json.getBoolean("flagZeroQualityAlignments"); } if (json.has("groupByPos")) { groupByPos = Range.fromString(json.getString("groupByPos")); } if (json.has("invertSorting")) { - invertSorting = Boolean.parseBoolean(json.getString("invertSorting")); + invertSorting = json.getBoolean("invertSorting"); } if (json.has("invertGroupSorting")) { - invertGroupSorting = Boolean.parseBoolean(json.getString("invertGroupSorting")); + invertGroupSorting = json.getBoolean("invertGroupSorting"); } if (json.has("hideSmallIndels")) { - hideSmallIndels = Boolean.parseBoolean(json.getString("hideSmallIndels")); + hideSmallIndels = json.getBoolean("hideSmallIndels"); } if (json.has("indexlSizeThreshold")) { - smallIndelThreshold = Integer.parseInt(json.getString("indexlSizeThreshold")); + smallIndelThreshold = json.getInt("indexlSizeThreshold"); } if (json.has("showInsertionMarkers")) { // TODO -- something with this - // showInsertionMarkers = Boolean.parseBoolean(json.getString("showInsertionMarkers")); + // showInsertionMarkers = json.getBoolean("showInsertionMarkers")); } if (json.has("basemodFilter")) { basemodFilter = BaseModficationFilter.fromString(json.getString("basemodFilter")); @@ -2004,7 +2004,7 @@ public void unmarshalJSON(org.json.JSONObject json) { basemodFilter = BaseModficationFilter.fromString(json.getString("basemodThreshold")); } if (json.has("minJunctionCoverage")) { - minJunctionCoverage = Integer.parseInt(json.getString("minJunctionCoverage")); + minJunctionCoverage = json.getInt("minJunctionCoverage"); } } diff --git a/src/main/java/org/igv/sam/AlignmentTrackMenuHelper.java b/src/main/java/org/igv/sam/AlignmentTrackMenuHelper.java index 85838a87c4..3ae8d7d288 100644 --- a/src/main/java/org/igv/sam/AlignmentTrackMenuHelper.java +++ b/src/main/java/org/igv/sam/AlignmentTrackMenuHelper.java @@ -6,7 +6,6 @@ import org.igv.event.IGVEventBus; import org.igv.feature.Range; import org.igv.feature.Strand; -import org.igv.jbrowse.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.IGVPreferences; @@ -97,20 +96,6 @@ private AlignmentTrackMenuHelper(AlignmentTrack alignmentTrack, final TrackClick items.add(new JSeparator()); - // Circular view items -- optional - if (CircularViewUtilities.ping()) { - addSeparator(); - JMenuItem item = new JMenuItem("Add Discordant Pairs to Circular View"); - item.setEnabled(alignmentTrack.getDataManager().isPairedEnd()); - add(item); - item.addActionListener(ae -> alignmentTrack.sendPairsToCircularView(e)); - - JMenuItem item2 = new JMenuItem("Add Split Reads to Circular View"); - add(item2); - item2.addActionListener(ae -> alignmentTrack.sendSplitToCircularView(e)); - } - - // Experiment type (RNA, THIRD GEN, OTHER) addExperimentTypeMenuItem(); @@ -186,6 +171,11 @@ private AlignmentTrackMenuHelper(AlignmentTrack alignmentTrack, final TrackClick showMateRegion(e, clickedAlignment); } addInsertSizeMenuItem(); + + // Circular view items + JMenuItem item = new JMenuItem("Add Discordant Pairs to Circular View"); + add(item); + item.addActionListener(ae -> alignmentTrack.sendPairsToCircularView(e)); } // Third gen (primarily) items @@ -1356,6 +1346,10 @@ void addThirdGenItems(Alignment clickedAlignment, final TrackClickEvent tce) { addShowChimericRegions(alignmentTrack, tce, clickedAlignment); addShowDiagram(tce, clickedAlignment); + JMenuItem item2 = new JMenuItem("Add split reads to circular view"); + add(item2); + item2.addActionListener(ae -> alignmentTrack.sendSplitToCircularView(tce)); + } void addSBXItems(Alignment clickedAlignment, final TrackClickEvent tce) { diff --git a/src/main/java/org/igv/ucsc/SearchAPI.java b/src/main/java/org/igv/ucsc/SearchAPI.java index 6d3f9b20bd..029ca8f17c 100644 --- a/src/main/java/org/igv/ucsc/SearchAPI.java +++ b/src/main/java/org/igv/ucsc/SearchAPI.java @@ -23,7 +23,7 @@ public class SearchAPI { public static List search(String searchTerm, String genome) throws IOException { - List positions = searchIGV(searchTerm, genome); //searchUCSC(searchTerm, genome); + List positions = searchUCSC(searchTerm, genome); //searchIGV(searchTerm, genome); // return mergeOverlaps(positions); } @@ -54,6 +54,9 @@ public static List reduceSearchResults(Map searchResults List> results = (List>) searchResults.get("positionMatches"); if (results != null) { for (Map result : results) { + if (searchTerm.indexOf("rs") == 0 && ((String) result.get("name")).indexOf("dbSnp") != 0) { + continue; + } List> matches = (List>) result.get("matches"); if (matches != null) { for (Map match : matches) { diff --git a/src/main/java/org/igv/ui/IGVMenuBar.java b/src/main/java/org/igv/ui/IGVMenuBar.java index 706aeefa47..52dcbf622b 100644 --- a/src/main/java/org/igv/ui/IGVMenuBar.java +++ b/src/main/java/org/igv/ui/IGVMenuBar.java @@ -5,6 +5,8 @@ import org.igv.aws.S3LoadDialog; import org.igv.batch.CommandExecutor; import org.igv.charts.ScatterPlotUtils; +import org.igv.circview.CircularViewUtilities; +import org.igv.circview.ui.CircularView; import org.igv.encode.EncodeTrackChooserFactory; import org.igv.feature.genome.ChromSizesUtils; import org.igv.feature.genome.Genome; @@ -555,6 +557,15 @@ public void actionPerformed(ActionEvent e) { }; menuItems.add(MenuAndToolbarUtils.createMenuItem(menuAction)); + menuItems.add(new JSeparator()); + JMenuItem circViewItem = new JMenuItem("Show circular view"); + circViewItem.addActionListener(e -> { + UIUtilities.invokeOnEventThread(() -> { + CircularViewUtilities.open(); + }); + }); + menuItems.add(circViewItem); + menuItems.add(new JSeparator()); menuItems.add(new HistoryMenu("Go to")); diff --git a/src/main/java/org/igv/variant/VariantTrack.java b/src/main/java/org/igv/variant/VariantTrack.java index db9a10b0e4..577fc9d95e 100644 --- a/src/main/java/org/igv/variant/VariantTrack.java +++ b/src/main/java/org/igv/variant/VariantTrack.java @@ -8,7 +8,7 @@ import org.igv.event.IGVEventObserver; import org.igv.feature.FeatureUtils; import org.igv.feature.PackedFeature; -import org.igv.jbrowse.CircularViewUtilities; +import org.igv.circview.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.IGVPreferences; @@ -319,9 +319,9 @@ protected void renderFeatureImpl(RenderContext context, PackedFeatures packedFea for (PackedFeatures.FeatureRow row : rows) { - List features = row.getFeatures(); - for (VCFVariant feature : features) { - VCFVariant variant = feature; + List features = row.getFeatures(); + for (Variant feature : features) { + Variant variant = feature; if (hideFiltered && variant.isFiltered()) { continue; @@ -1100,7 +1100,7 @@ void sendToCircularView(TrackClickEvent e) { if (svFeatures.isEmpty()) { MessageUtils.showMessage("No structural variants found."); } else { - CircularViewUtilities.sendVariantsToJBrowse(svFeatures, getName(), CIRC_VIEW_DEFAULT_COLOR); + CircularViewUtilities.addVariants(svFeatures, getName(), CIRC_VIEW_DEFAULT_COLOR); } } diff --git a/src/main/java/org/igv/variant/VariantTrackMenuHelper.java b/src/main/java/org/igv/variant/VariantTrackMenuHelper.java index d54c7baf6a..740db59756 100644 --- a/src/main/java/org/igv/variant/VariantTrackMenuHelper.java +++ b/src/main/java/org/igv/variant/VariantTrackMenuHelper.java @@ -1,6 +1,5 @@ package org.igv.variant; -import org.igv.jbrowse.CircularViewUtilities; import org.igv.logging.LogManager; import org.igv.logging.Logger; import org.igv.prefs.Constants; @@ -37,13 +36,6 @@ static List getMenuItems(final VariantTrack variantTrack, final Varia List items = new ArrayList<>(); - if (PreferencesManager.getPreferences().getAsBoolean(Constants.CIRC_VIEW_ENABLED) && CircularViewUtilities.ping()) { - items.add(new JPopupMenu.Separator()); - JMenuItem circItem = new JMenuItem("Add SVs to Circular View"); - circItem.addActionListener(e1 -> variantTrack.sendToCircularView(e)); - items.add(circItem); - items.add(new JPopupMenu.Separator()); - } items.add(TrackMenuUtils.getRowHeightItem(Collections.singletonList(variantTrack))); items.add(TrackMenuUtils.getMinimizeHeightItem(Collections.singletonList(variantTrack))); @@ -81,6 +73,10 @@ static List getMenuItems(final VariantTrack variantTrack, final Varia items.add(SampleMenuUtils.getFilterByAttributeItem(variantTrack)); } + items.add(new JPopupMenu.Separator()); + JMenuItem circItem = new JMenuItem("Add SVs to Circular View"); + circItem.addActionListener(e1 -> variantTrack.sendToCircularView(e)); + items.add(circItem); items.add(new JPopupMenu.Separator()); items.add(getHideFilteredItem(variantTrack)); diff --git a/src/main/java/org/igv/variant/vcf/MateVariant.java b/src/main/java/org/igv/variant/vcf/MateVariant.java index 668782c438..487f59e201 100644 --- a/src/main/java/org/igv/variant/vcf/MateVariant.java +++ b/src/main/java/org/igv/variant/vcf/MateVariant.java @@ -1,5 +1,6 @@ package org.igv.variant.vcf; +import org.igv.feature.PackedFeature; import org.igv.variant.Allele; import org.igv.variant.Genotype; import org.igv.variant.Variant; @@ -11,11 +12,12 @@ /** * Represents the mate of a structural variant, defined by CHR2 and END attributes. */ -public class MateVariant implements Variant { +public class MateVariant implements Variant, PackedFeature { private String chr; private int position; public Variant mate; + private int rowIndex; public MateVariant(String chr, int position, Variant mate) { this.chr = chr; @@ -144,4 +146,13 @@ public double getAlleleFraction() { return mate.getAlleleFraction(); } + @Override + public void setPackedRow(int rowIndex) { + this.rowIndex = rowIndex; + } + + @Override + public int getPackedRow() { + return rowIndex; + } } diff --git a/src/main/resources/preferences.tab b/src/main/resources/preferences.tab index b14e7ee230..fe0896ac0a 100644 --- a/src/main/resources/preferences.tab +++ b/src/main/resources/preferences.tab @@ -310,12 +310,6 @@ TOOLTIP.INITIAL_DELAY Tooltip inital delay (ms) integer 50 TOOLTIP.RESHOW_DELAY Tooltip reshow delay (ms) integer 50 TOOLTIP.DISMISS_DELAY Tooltip dismiss delay (ms) integer 60000 -## JBrowse Circular View Integration **EXPERIMENTAL FEATURE** -CIRC_VIEW_ENABLED Enable CircView boolean FALSE -CIRC_VIEW_HOST CircView host (usually localhost) string localhost -CIRC_VIEW_PORT CircView port integer 60152 - -## #Hidden diff --git a/src/test/java/org/igv/circview/model/ChordSetManagerTest.java b/src/test/java/org/igv/circview/model/ChordSetManagerTest.java new file mode 100644 index 0000000000..6983d1bcff --- /dev/null +++ b/src/test/java/org/igv/circview/model/ChordSetManagerTest.java @@ -0,0 +1,69 @@ +package org.igv.circview.model; + +import org.junit.Test; + +import java.awt.Color; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +public class ChordSetManagerTest { + + private static Chord chord(String ref) { + return new Chord(ref + "-id", ref, 1, 2, new Mate(ref, 3, 4), null); + } + + private static ChordSet set(String name, String trackName, Chord... chords) { + return new ChordSet(name, trackName, List.of(chords), Color.BLACK, Color.BLACK); + } + + @Test + public void replacesChordSetWithSameName() { + ChordSetManager m = new ChordSetManager(); + m.addChordSet(set("A", "trackA", chord("1"))); + m.addChordSet(set("A", "trackA", chord("2"), chord("3"))); + + assertEquals(1, m.getChordSets().size()); + assertEquals(2, m.getChordSet("A").getChords().size()); + } + + @Test + public void groupsChordSetsByTrack() { + ChordSetManager m = new ChordSetManager(); + // Track name is derived externally; here both sets share "trackA". + m.addChordSet(set("region1", "trackA", chord("1"))); + m.addChordSet(set("region2", "trackA", chord("2"))); + + assertEquals(2, m.getChordSets().size()); + assertEquals(1, m.getTracks().size()); + + Track track = m.getTrack("trackA"); + assertNotNull(track); + assertEquals(2, track.getChordSets().size()); + // Track.chords() concatenates across its chord sets. + assertEquals(2, track.getChords().size()); + } + + @Test + public void separateTracksStaySeparate() { + ChordSetManager m = new ChordSetManager(); + m.addChordSet(set("region1", "trackA", chord("1"))); + m.addChordSet(set("region2", "trackB", chord("2"))); + + assertEquals(2, m.getTracks().size()); + assertEquals(1, m.getTrack("trackA").getChords().size()); + assertEquals(1, m.getTrack("trackB").getChords().size()); + } + + @Test + public void clearChordsEmptiesEverything() { + ChordSetManager m = new ChordSetManager(); + m.addChordSet(set("A", "trackA", chord("1"))); + m.clearChords(); + assertEquals(0, m.getChordSets().size()); + assertEquals(0, m.getTracks().size()); + assertNull(m.getChordSet("A")); + } +} diff --git a/src/test/java/org/igv/circview/render/GenomeArcLayoutTest.java b/src/test/java/org/igv/circview/render/GenomeArcLayoutTest.java new file mode 100644 index 0000000000..d03bb9d384 --- /dev/null +++ b/src/test/java/org/igv/circview/render/GenomeArcLayoutTest.java @@ -0,0 +1,71 @@ +package org.igv.circview.render; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chromosome; +import org.junit.Test; + +import java.awt.Color; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class GenomeArcLayoutTest { + + private static Assembly threeChrAssembly() { + return new Assembly("test", "test", List.of( + new Chromosome("1", 100, Color.RED), + new Chromosome("2", 200, Color.GREEN), + new Chromosome("3", 300, Color.BLUE))); + } + + @Test + public void spansPlusGapsCoverFullCircle() { + double gapFraction = 0.02; + GenomeArcLayout layout = new GenomeArcLayout(threeChrAssembly(), 0, 0, gapFraction); + + double totalSpan = 0; + for (GenomeArcLayout.ChromosomeArc arc : layout.getArcs()) { + totalSpan += arc.span(); + } + double expectedAvailable = 2 * Math.PI * (1 - gapFraction); + assertEquals(expectedAvailable, totalSpan, 1e-9); + + double totalGaps = layout.getGapAngle() * layout.getArcs().size(); + assertEquals(2 * Math.PI, totalSpan + totalGaps, 1e-9); + } + + @Test + public void spansAreProportionalToLength() { + GenomeArcLayout layout = new GenomeArcLayout(threeChrAssembly(), 0, 0, 0.0); + List arcs = layout.getArcs(); + // lengths 100:200:300 -> spans 1:2:3 + assertEquals(arcs.get(0).span() * 2, arcs.get(1).span(), 1e-9); + assertEquals(arcs.get(0).span() * 3, arcs.get(2).span(), 1e-9); + } + + @Test + public void bpToAngleMatchesArcEndpoints() { + GenomeArcLayout layout = new GenomeArcLayout(threeChrAssembly(), 0, 0, 0.02); + GenomeArcLayout.ChromosomeArc arc2 = layout.getArcs().get(1); // chr "2", length 200 + + assertEquals(arc2.startAngle, layout.bpToAngle("2", 0), 1e-9); + assertEquals(arc2.endAngle, layout.bpToAngle("2", 200), 1e-9); + double mid = (arc2.startAngle + arc2.endAngle) / 2; + assertEquals(mid, layout.bpToAngle("2", 100), 1e-9); + } + + @Test + public void bpToAngleIsMonotonicWithinChromosome() { + GenomeArcLayout layout = new GenomeArcLayout(threeChrAssembly(), 0, 0, 0.02); + assertTrue(layout.bpToAngle("3", 10) < layout.bpToAngle("3", 250)); + } + + @Test + public void unknownChromosome() { + GenomeArcLayout layout = new GenomeArcLayout(threeChrAssembly(), 0, 0, 0.02); + assertFalse(layout.hasChromosome("ZZ")); + assertTrue(Double.isNaN(layout.bpToAngle("ZZ", 10))); + } +} diff --git a/src/test/java/org/igv/circview/ui/CircularViewPanelTest.java b/src/test/java/org/igv/circview/ui/CircularViewPanelTest.java new file mode 100644 index 0000000000..6dd8da1ec3 --- /dev/null +++ b/src/test/java/org/igv/circview/ui/CircularViewPanelTest.java @@ -0,0 +1,102 @@ +package org.igv.circview.ui; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chord; +import org.igv.circview.model.Chromosome; +import org.igv.circview.model.Mate; +import org.junit.Test; + +import javax.swing.JCheckBox; +import java.awt.Color; +import java.awt.Component; +import java.awt.Container; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the control panel wiring: it rebuilds in response to the view's + * structure changes (chords added/cleared, regrouped) and always carries the + * group-by checkbox row. + */ +public class CircularViewPanelTest { + + private static Assembly assembly() { + return new Assembly("g", "g", List.of( + new Chromosome("1", 1000, Color.RED), + new Chromosome("2", 1000, Color.BLUE))); + } + + private static List chords() { + return List.of(new Chord("x", "1", 1, 2, new Mate("2", 3, 4), null)); + } + + private static CircularViewPanel panelWithTwoSets() { + CircularView view = new CircularView(new CircularViewConfig()); + view.setAssembly(assembly()); + view.addChords(chords(), "Set A", Color.BLUE); + view.addChords(chords(), "Set B", Color.RED); + return new CircularViewPanel(view); + } + + @Test + public void firstRowIsGroupByCheckbox() { + CircularViewPanel panel = panelWithTwoSets(); + assertTrue("first control row should hold the group-by checkbox", + containsCheckbox((Container) panel.controlPanelRow(0))); + } + + @Test + public void oneRowPerCollectionPlusGroupByRow() { + CircularViewPanel panel = panelWithTwoSets(); + // group-by row + 2 chord sets + assertEquals(3, panel.controlPanelRowCount()); + } + + @Test + public void rebuildsWhenChordsAdded() { + CircularView view = new CircularView(new CircularViewConfig()); + view.setAssembly(assembly()); + CircularViewPanel panel = new CircularViewPanel(view); + assertEquals(1, panel.controlPanelRowCount()); // group-by row only + + view.addChords(chords(), "Set A", Color.BLUE); + assertEquals(2, panel.controlPanelRowCount()); + } + + @Test + public void rebuildsWhenCleared() { + CircularViewPanel panel = panelWithTwoSets(); + assertEquals(3, panel.controlPanelRowCount()); + panel.getView().clearChords(); + assertEquals(1, panel.controlPanelRowCount()); + } + + @Test + public void regroupingByTrackRebuilds() { + // Two chord sets sharing one track name ("Calls") collapse to a single + // track row when grouped. + CircularView view = new CircularView(new CircularViewConfig()); + view.setAssembly(assembly()); + view.addChords(chords(), "Calls region1", Color.BLUE); + view.addChords(chords(), "Calls region2", Color.RED); + CircularViewPanel panel = new CircularViewPanel(view); + assertEquals(3, panel.controlPanelRowCount()); // group-by + 2 sets + + view.setGroupByTrack(true); + assertEquals(2, panel.controlPanelRowCount()); // group-by + 1 track + } + + private static boolean containsCheckbox(Container c) { + for (Component child : c.getComponents()) { + if (child instanceof JCheckBox) { + return true; + } + if (child instanceof Container && containsCheckbox((Container) child)) { + return true; + } + } + return false; + } +} diff --git a/src/test/java/org/igv/circview/ui/CircularViewRenderTest.java b/src/test/java/org/igv/circview/ui/CircularViewRenderTest.java new file mode 100644 index 0000000000..f70c4dc305 --- /dev/null +++ b/src/test/java/org/igv/circview/ui/CircularViewRenderTest.java @@ -0,0 +1,202 @@ +package org.igv.circview.ui; + +import org.igv.circview.model.Assembly; +import org.igv.circview.model.Chord; +import org.igv.circview.model.Chromosome; +import org.igv.circview.model.Mate; +import org.igv.circview.render.GenomeArcLayout; +import org.junit.Test; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.event.MouseEvent; +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * Headless smoke test: paint into an image, then confirm chord shapes were + * recorded and that a point on a chord anchor hit-tests to that chord. + */ +public class CircularViewRenderTest { + + private static final int SIZE = 700; + + private static Assembly assembly() { + return new Assembly("test", "test", List.of( + new Chromosome("1", 1000, Color.RED), + new Chromosome("2", 1000, Color.GREEN), + new Chromosome("3", 1000, Color.BLUE))); + } + + private CircularView paintedView() { + CircularViewConfig config = new CircularViewConfig(); + config.width = SIZE; + config.height = SIZE; + CircularView view = new CircularView(config); + view.setSize(SIZE, SIZE); + view.setAssembly(assembly()); + view.addChords(List.of( + new Chord("c1", "1", 400, 600, new Mate("2", 400, 600), null)), + "set", Color.BLUE); + + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + view.paint(g); + g.dispose(); + return view; + } + + @Test + public void paintRecordsChordShapes() { + CircularView view = paintedView(); + assertEquals(1, view.renderedChordCount()); + } + + @Test + public void hitTestFindsChordAtAnchor() { + CircularView view = paintedView(); + + // Recompute the geometry the same way the view does to find an on-chord point. + double outer = SIZE / 2.0 - new CircularViewConfig().margin; + double ringThickness = outer * new CircularViewConfig().ringThicknessFraction; + double chordRadius = outer - ringThickness; + GenomeArcLayout layout = new GenomeArcLayout(assembly(), SIZE / 2.0, SIZE / 2.0, + new CircularViewConfig().gapFraction); + // Midpoint of region 1 (bp 500), pulled slightly inward so it lies inside the ribbon. + double mid = layout.bpToAngle("1", 500); + Point2D p = layout.pointAt(mid, chordRadius - 2); + + Chord hit = view.chordAt((int) Math.round(p.getX()), (int) Math.round(p.getY())); + assertNotNull("expected a chord under the anchor point", hit); + assertEquals("c1", hit.getUniqueId()); + } + + @Test + public void clickOutsideHitsNothing() { + CircularView view = paintedView(); + assertNull(view.chordAt(0, 0)); + } + + @Test + public void tooltipShowsFeatureToStringOverChord() { + CircularView view = paintedView(); + + double outer = SIZE / 2.0 - new CircularViewConfig().margin; + double chordRadius = outer - outer * new CircularViewConfig().ringThicknessFraction; + GenomeArcLayout layout = new GenomeArcLayout(assembly(), SIZE / 2.0, SIZE / 2.0, + new CircularViewConfig().gapFraction); + Point2D p = layout.pointAt(layout.bpToAngle("1", 500), chordRadius - 2); + + Chord feature = view.chordAt((int) Math.round(p.getX()), (int) Math.round(p.getY())); + assertNotNull(feature); + + MouseEvent over = new MouseEvent(view, MouseEvent.MOUSE_MOVED, 0L, 0, + (int) Math.round(p.getX()), (int) Math.round(p.getY()), 0, false); + assertEquals(feature.toString(), view.getToolTipText(over)); + } + + @Test + public void tooltipNullAwayFromChords() { + CircularView view = paintedView(); + MouseEvent off = new MouseEvent(view, MouseEvent.MOUSE_MOVED, 0L, 0, 0, 0, 0, false); + assertNull(view.getToolTipText(off)); + } + + @Test + public void hoveredChordIsDrawnBlack() { + CircularViewConfig config = new CircularViewConfig(); + config.width = SIZE; + config.height = SIZE; + CircularView view = new CircularView(config); + view.setSize(SIZE, SIZE); + view.setAssembly(assembly()); + Chord c = new Chord("c1", "1", 400, 600, new Mate("2", 400, 600), null); + view.addChords(List.of(c), "set", Color.BLUE); + + double outer = SIZE / 2.0 - config.margin; + double chordRadius = outer - outer * config.ringThicknessFraction; + GenomeArcLayout layout = new GenomeArcLayout(assembly(), SIZE / 2.0, SIZE / 2.0, + config.gapFraction); + // A point on the chord just inside the ring (below the chromosome band). + Point2D p = layout.pointAt(layout.bpToAngle("1", 500), chordRadius - 6); + int x = (int) Math.round(p.getX()); + int y = (int) Math.round(p.getY()); + + // Not hovered: the pixel shows the chord's (blue) color. + int normal = paintAndSample(view, x, y); + assertTrue("expected a blue-ish chord pixel, got #" + Integer.toHexString(normal), + blue(normal) > 120 && red(normal) < 120); + + // Hovered: the same pixel is drawn (near) black. + view.setHoveredChordForTest(c); + int hovered = paintAndSample(view, x, y); + assertTrue("expected a dark hovered pixel, got #" + Integer.toHexString(hovered), + red(hovered) < 60 && green(hovered) < 60 && blue(hovered) < 60); + } + + private static int paintAndSample(CircularView view, int x, int y) { + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, SIZE, SIZE); + view.paint(g); + g.dispose(); + return img.getRGB(x, y); + } + + private static int red(int rgb) { + return (rgb >> 16) & 0xff; + } + + private static int green(int rgb) { + return (rgb >> 8) & 0xff; + } + + private static int blue(int rgb) { + return rgb & 0xff; + } + + @Test + public void overlappingChordsResolveToNearestCenterline() { + // Two chords share a chr1 anchor but run to chr2 vs chr3. A point at the + // chr2 anchor must resolve to the chr1->chr2 chord, and the chr3 anchor + // to the chr1->chr3 chord, even though both pass through the center. + CircularViewConfig config = new CircularViewConfig(); + config.width = SIZE; + config.height = SIZE; + CircularView view = new CircularView(config); + view.setSize(SIZE, SIZE); + view.setAssembly(assembly()); + view.addChords(List.of( + new Chord("to2", "1", 490, 510, new Mate("2", 490, 510), null)), + "A", Color.BLUE); + view.addChords(List.of( + new Chord("to3", "1", 490, 510, new Mate("3", 490, 510), null)), + "B", Color.RED); + BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + view.paint(g); + g.dispose(); + + double outer = SIZE / 2.0 - config.margin; + double chordRadius = outer - outer * config.ringThicknessFraction; + GenomeArcLayout layout = new GenomeArcLayout(assembly(), SIZE / 2.0, SIZE / 2.0, + config.gapFraction); + + Point2D atChr2 = layout.pointAt(layout.bpToAngle("2", 500), chordRadius); + Chord hit2 = view.chordAt((int) Math.round(atChr2.getX()), (int) Math.round(atChr2.getY())); + assertNotNull(hit2); + assertEquals("to2", hit2.getUniqueId()); + + Point2D atChr3 = layout.pointAt(layout.bpToAngle("3", 500), chordRadius); + Chord hit3 = view.chordAt((int) Math.round(atChr3.getX()), (int) Math.round(atChr3.getY())); + assertNotNull(hit3); + assertEquals("to3", hit3.getUniqueId()); + } +} diff --git a/src/test/java/org/igv/circview/util/ColorUtilsTest.java b/src/test/java/org/igv/circview/util/ColorUtilsTest.java new file mode 100644 index 0000000000..6f726d8e8e --- /dev/null +++ b/src/test/java/org/igv/circview/util/ColorUtilsTest.java @@ -0,0 +1,55 @@ +package org.igv.circview.util; + +import org.junit.Test; + +import java.awt.Color; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class ColorUtilsTest { + + @Test + public void parsesRgb() { + Color c = ColorUtils.parseColor("rgb(80, 80, 255)"); + assertEquals(new Color(80, 80, 255), c); + assertEquals(255, c.getAlpha()); + } + + @Test + public void parsesRgbaWithFractionalAlpha() { + Color c = ColorUtils.parseColor("rgba(0, 0, 255, 0.5)"); + assertEquals(0, c.getRed()); + assertEquals(0, c.getGreen()); + assertEquals(255, c.getBlue()); + assertEquals(Math.round(0.5 * 255), c.getAlpha()); + } + + @Test + public void parsesHex() { + assertEquals(new Color(0xAA, 0xBB, 0xCC), ColorUtils.parseColor("#aabbcc")); + assertEquals(new Color(0xAA, 0xBB, 0xCC), ColorUtils.parseColor("#abc")); + } + + @Test + public void parsesNamedColor() { + assertEquals(Color.BLACK, ColorUtils.parseColor("black")); + } + + @Test + public void alphaRoundTrip() { + Color base = new Color(10, 20, 30); + Color faded = ColorUtils.setAlpha(base, 0.25f); + assertEquals(Math.round(0.25f * 255), faded.getAlpha()); + assertEquals(0.25f, ColorUtils.getAlpha(faded), 1f / 255f); + // RGB preserved + assertEquals(10, faded.getRed()); + assertEquals(20, faded.getGreen()); + assertEquals(30, faded.getBlue()); + } + + @Test + public void rejectsGarbage() { + assertThrows(IllegalArgumentException.class, () -> ColorUtils.parseColor("not-a-color")); + } +} diff --git a/test/data/sessions/giab_svs.xml b/test/data/sessions/giab_svs.xml new file mode 100644 index 0000000000..a807ed7b68 --- /dev/null +++ b/test/data/sessions/giab_svs.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/sessions/schatz_skbr3_pacbio_sampled.xml b/test/sessions/schatz_skbr3_pacbio_sampled.xml index e089698008..0602d67434 100644 --- a/test/sessions/schatz_skbr3_pacbio_sampled.xml +++ b/test/sessions/schatz_skbr3_pacbio_sampled.xml @@ -1,17 +1,17 @@ - + - - + + - +