|
|
@@ -1,3 +1,35 @@
|
|
|
+//! IGV session URL builder (igv.js compatible).
|
|
|
+//!
|
|
|
+//! This module builds an **igv.js session JSON**, compresses it with the same
|
|
|
+//! deflate + base64 + URL-safe substitutions used by igvteam utilities, and
|
|
|
+//! returns a link of the form:
|
|
|
+//!
|
|
|
+//! `"{base_url}?sessionURL=blob:{blob}"`
|
|
|
+//!
|
|
|
+//! ## Coordinate conventions
|
|
|
+//!
|
|
|
+//! The `locus` field in igv.js session JSON is a **string** in the human-facing
|
|
|
+//! form `chr:start-end`. In igv.js, the internal reference frame uses a 0-based
|
|
|
+//! `start`, but `getLocusString()` returns a `chr:start-end` representation
|
|
|
+//! where `start` is **1-based**. :contentReference[oaicite:1]{index=1}
|
|
|
+//!
|
|
|
+//! Therefore, the APIs in this module treat `position`, `start`, and `end` as
|
|
|
+//! **1-based coordinates** intended for locus strings.
|
|
|
+//!
|
|
|
+//! ## Example
|
|
|
+//!
|
|
|
+//! ```no_run
|
|
|
+//! # use pandora_lib_igv::{Session, ReferenceValues, Track, BamTrack};
|
|
|
+//! let url = Session::default()
|
|
|
+//! .with_reference(ReferenceValues::default())
|
|
|
+//! .with_locus_at(("chr1", 1_000_000), 1000).unwrap()
|
|
|
+//! .add_track(Track::Bam(BamTrack::new("tumor", "/data/tumor.bam"))).unwrap()
|
|
|
+//! .link("igv/").unwrap();
|
|
|
+//! println!("{url}");
|
|
|
+//! ```
|
|
|
+
|
|
|
+pub mod tracks;
|
|
|
+
|
|
|
use anyhow::{Context, Ok};
|
|
|
use base64::engine::general_purpose::STANDARD;
|
|
|
use base64::Engine;
|
|
|
@@ -6,10 +38,18 @@ use flate2::Compression;
|
|
|
|
|
|
use serde::Serialize;
|
|
|
use serde_json::{json, Value};
|
|
|
-use smart_default::SmartDefault;
|
|
|
use std::io::prelude::*;
|
|
|
|
|
|
-/// Compress string according to compressString from: https://github.com/igvteam/igv-utils/blob/master/src/bgzf.js#L125
|
|
|
+use crate::tracks::Track;
|
|
|
+
|
|
|
+/// Compress a session string using the igvteam "compressString" algorithm.
|
|
|
+///
|
|
|
+/// This matches igv-utils' behavior (deflate, base64, then URL-safe character
|
|
|
+/// substitutions) so that the resulting blob can be used with
|
|
|
+/// `?sessionURL=blob:{blob}`.
|
|
|
+///
|
|
|
+/// Reference implementation:
|
|
|
+/// <https://github.com/igvteam/igv-utils/blob/master/src/bgzf.js#L125>
|
|
|
fn compress_string(input: &str) -> anyhow::Result<String> {
|
|
|
let bytes = input.as_bytes();
|
|
|
|
|
|
@@ -30,23 +70,38 @@ fn compress_string(input: &str) -> anyhow::Result<String> {
|
|
|
.replace('=', "-"))
|
|
|
}
|
|
|
|
|
|
-// To add more track cf.: https://github.com/igvteam/igv.js/wiki/Tracks-2.0
|
|
|
-// https://igv.org/doc/igvjs/#tracks/Tracks/
|
|
|
+/// Reference genome definition used by igv.js sessions.
|
|
|
+///
|
|
|
+/// This is serialized into the session JSON under `"reference"`.
|
|
|
+///
|
|
|
+/// Notes:
|
|
|
+/// - `fastaURL` and `indexURL` must point to the reference FASTA and its `.fai`.
|
|
|
+/// - `cytobandURL` and `aliasURL` are optional but improve display/compatibility.
|
|
|
+///
|
|
|
+/// To add more track/fields, see:
|
|
|
+/// - <https://github.com/igvteam/igv.js/wiki/Tracks-2.0>
|
|
|
+/// - <https://igv.org/doc/igvjs/#tracks/Tracks/>
|
|
|
#[derive(Debug, Serialize)]
|
|
|
pub struct ReferenceValues {
|
|
|
+ /// Reference identifier (igv.js `"id"`).
|
|
|
pub id: String,
|
|
|
|
|
|
+ /// Human readable name (igv.js `"name"`).
|
|
|
pub name: String,
|
|
|
|
|
|
+ /// FASTA URL (igv.js `"fastaURL"`).
|
|
|
#[serde(rename = "fastaURL")]
|
|
|
pub fasta_url: String,
|
|
|
|
|
|
+ /// FASTA index URL (igv.js `"indexURL"`, typically `.fai`).
|
|
|
#[serde(rename = "indexURL")]
|
|
|
pub index_url: String,
|
|
|
|
|
|
+ /// Cytoband BED URL (igv.js `"cytobandURL"`).
|
|
|
#[serde(rename = "cytobandURL")]
|
|
|
pub cytoband_url: String,
|
|
|
|
|
|
+ /// Chromosome alias mapping URL (igv.js `"aliasURL"`).
|
|
|
#[serde(rename = "aliasURL")]
|
|
|
pub alias_url: String,
|
|
|
}
|
|
|
@@ -64,6 +119,10 @@ impl Default for ReferenceValues {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+/// igv.js session builder.
|
|
|
+///
|
|
|
+/// Internally holds a JSON `Value` mirroring igv.js session schema plus a list
|
|
|
+/// of tracks used to assign track order deterministically.
|
|
|
#[derive(Debug)]
|
|
|
pub struct Session {
|
|
|
value: Value,
|
|
|
@@ -88,6 +147,9 @@ impl Default for Session {
|
|
|
}
|
|
|
|
|
|
impl Session {
|
|
|
+ /// Set the session reference genome definition.
|
|
|
+ ///
|
|
|
+ /// This overwrites the `"reference"` object in the underlying session JSON.
|
|
|
pub fn with_reference(mut self, reference_values: ReferenceValues) -> Self {
|
|
|
if let Some(reference) = self.value.get_mut("reference") {
|
|
|
*reference = json!(reference_values);
|
|
|
@@ -95,7 +157,83 @@ impl Session {
|
|
|
self
|
|
|
}
|
|
|
|
|
|
- /// Add a locus
|
|
|
+ /// Set a single locus using a pre-formatted IGV locus string: `chr:start-end`.
|
|
|
+ pub fn with_locus_str(mut self, locus: &str) -> anyhow::Result<Self> {
|
|
|
+ *self
|
|
|
+ .value
|
|
|
+ .get_mut("locus")
|
|
|
+ .context("Can't access locus value")? = json!(locus);
|
|
|
+ Ok(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Set multiple loci (e.g. split view): `["chr1:1-1000", "chr2:200-400"]`.
|
|
|
+ pub fn with_loci<I, S>(mut self, loci: I) -> anyhow::Result<Self>
|
|
|
+ where
|
|
|
+ I: IntoIterator<Item = S>,
|
|
|
+ S: AsRef<str>,
|
|
|
+ {
|
|
|
+ let arr: Vec<Value> = loci
|
|
|
+ .into_iter()
|
|
|
+ .map(|s| Value::String(s.as_ref().to_string()))
|
|
|
+ .collect();
|
|
|
+ *self
|
|
|
+ .value
|
|
|
+ .get_mut("locus")
|
|
|
+ .context("Can't access locus value")? = Value::Array(arr);
|
|
|
+ Ok(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Set locus from 1-based inclusive coordinates.
|
|
|
+ pub fn with_region_1based(
|
|
|
+ mut self,
|
|
|
+ contig: &str,
|
|
|
+ start: u32,
|
|
|
+ end: u32,
|
|
|
+ ) -> anyhow::Result<Self> {
|
|
|
+ let start = start.max(1);
|
|
|
+ let end = end.max(start);
|
|
|
+ *self
|
|
|
+ .value
|
|
|
+ .get_mut("locus")
|
|
|
+ .context("Can't access locus value")? = json!(format!("{contig}:{start}-{end}"));
|
|
|
+ Ok(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Set locus from 0-based half-open coordinates (start inclusive, end exclusive),
|
|
|
+ /// converting to IGV locus string (1-based inclusive).
|
|
|
+ pub fn with_region_0based(
|
|
|
+ mut self,
|
|
|
+ contig: &str,
|
|
|
+ start0: u32,
|
|
|
+ end0: u32,
|
|
|
+ ) -> anyhow::Result<Self> {
|
|
|
+ let start1 = start0.saturating_add(1);
|
|
|
+ let end1 = end0.max(start0).saturating_add(0); // end0 exclusive -> inclusive end is end0
|
|
|
+ // For half-open [start0, end0), inclusive end is end0 (in 1-based)
|
|
|
+ *self
|
|
|
+ .value
|
|
|
+ .get_mut("locus")
|
|
|
+ .context("Can't access locus value")? = json!(format!("{contig}:{start1}-{end1}"));
|
|
|
+ Ok(self)
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn to_json_value(&self) -> &Value {
|
|
|
+ &self.value
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn to_json_string(&self) -> String {
|
|
|
+ self.value.to_string()
|
|
|
+ }
|
|
|
+
|
|
|
+ pub fn to_json_string_pretty(&self) -> anyhow::Result<String> {
|
|
|
+ Ok(serde_json::to_string_pretty(&self.value)?)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Set the locus for the session as a `chr:start-end` string.
|
|
|
+ ///
|
|
|
+ /// ## Coordinates
|
|
|
+ /// This API expects **1-based** coordinates to be used in the locus string.
|
|
|
+ /// See module-level documentation for details on igv.js conventions. :contentReference[oaicite:2]{index=2}
|
|
|
pub fn with_locus(mut self, from: (String, i32), to: i32) -> anyhow::Result<Self> {
|
|
|
*self
|
|
|
.value
|
|
|
@@ -104,28 +242,33 @@ impl Session {
|
|
|
Ok(self)
|
|
|
}
|
|
|
|
|
|
+ /// Center the view on a 1-based position with a symmetric window.
|
|
|
+ ///
|
|
|
+ /// Builds a locus string: `"{contig}:{start}-{end}"` where:
|
|
|
+ /// - `position` is 1-based
|
|
|
+ /// - `start = max(1, position - plus_minus)`
|
|
|
+ /// - `end = position + plus_minus`
|
|
|
pub fn with_locus_at(
|
|
|
mut self,
|
|
|
- (contig, position): (&str, u32),
|
|
|
+ contig: &str,
|
|
|
+ position: u32,
|
|
|
plus_minus: u32,
|
|
|
) -> anyhow::Result<Self> {
|
|
|
+ let start = position.saturating_sub(plus_minus).max(1);
|
|
|
+ let end = position.saturating_add(plus_minus);
|
|
|
+
|
|
|
*self
|
|
|
.value
|
|
|
.get_mut("locus")
|
|
|
- .context("Can't access locus value")? = json!(format!(
|
|
|
- "{}:{}-{}",
|
|
|
- contig,
|
|
|
- position.saturating_sub(plus_minus),
|
|
|
- position + plus_minus
|
|
|
- ));
|
|
|
- Ok(self)
|
|
|
- }
|
|
|
+ .context("Can't access locus value")? = json!(format!("{contig}:{start}-{end}"));
|
|
|
|
|
|
- pub fn link(&self, base_url: &str) -> anyhow::Result<String> {
|
|
|
- let blob = compress_string(&self.value.to_string())?;
|
|
|
- Ok(format!("{base_url}?sessionURL=blob:{blob}"))
|
|
|
+ Ok(self)
|
|
|
}
|
|
|
|
|
|
+ /// Append a track to the session.
|
|
|
+ ///
|
|
|
+ /// Tracks are ordered in insertion order. Each call recomputes the `"tracks"`
|
|
|
+ /// JSON array from the stored track list.
|
|
|
pub fn add_track(mut self, track: Track) -> anyhow::Result<Self> {
|
|
|
let mut track = track;
|
|
|
let pos = self.tracks.len() + 1;
|
|
|
@@ -140,212 +283,51 @@ impl Session {
|
|
|
.context("Can't access locus value")? = Value::Array(tv);
|
|
|
Ok(self)
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Debug, Serialize, SmartDefault)]
|
|
|
-pub struct BamTrack {
|
|
|
- #[serde(rename = "type")]
|
|
|
- #[default = "alignment"]
|
|
|
- pub igv_type: String,
|
|
|
-
|
|
|
- #[default = 0]
|
|
|
- pub order: i16,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- pub url: String,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- pub filename: String,
|
|
|
-
|
|
|
- #[serde(rename = "indexURL")]
|
|
|
- #[default = ""]
|
|
|
- pub index_url: String,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- pub name: String,
|
|
|
-
|
|
|
- #[default = "bam"]
|
|
|
- pub format: String,
|
|
|
-}
|
|
|
-
|
|
|
-impl BamTrack {
|
|
|
- pub fn new(name: &str, url: &str) -> Self {
|
|
|
- BamTrack {
|
|
|
- url: url.to_string(),
|
|
|
- filename: name.to_string(),
|
|
|
- index_url: format!("{url}.bai"),
|
|
|
- name: name.to_string(),
|
|
|
- ..Default::default()
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Debug)]
|
|
|
-pub enum Track {
|
|
|
- Bam(BamTrack),
|
|
|
- Genes(GenesTrack),
|
|
|
- Variants(VariantsTrack),
|
|
|
- Bed(BedTrack),
|
|
|
-}
|
|
|
|
|
|
-impl Track {
|
|
|
- pub fn to_json(&self) -> Value {
|
|
|
- match self {
|
|
|
- Track::Bam(bam) => json!(bam),
|
|
|
- Track::Genes(genes) => json!(genes),
|
|
|
- Track::Variants(variants) => json!(variants),
|
|
|
- Track::Bed(bed) => json!(bed),
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- pub fn order(&mut self, order: i16) {
|
|
|
- match self {
|
|
|
- Track::Bam(track) => track.order = order,
|
|
|
- Track::Genes(track) => track.order = order,
|
|
|
- Track::Variants(track) => track.order = order,
|
|
|
- Track::Bed(track) => track.order = order,
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Debug, Serialize, SmartDefault)]
|
|
|
-pub struct GenesTrack {
|
|
|
- #[default = "RefSeq Liftoff v5.1"]
|
|
|
- pub id: String,
|
|
|
-
|
|
|
- #[default = "Genes"]
|
|
|
- pub name: String,
|
|
|
-
|
|
|
- #[default = "gff3"]
|
|
|
- pub format: String,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- pub url: String,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- #[serde(rename = "indexURL")]
|
|
|
- pub index_url: String,
|
|
|
-
|
|
|
- #[default = "EXPANDED"]
|
|
|
- #[serde(rename = "displayMode")]
|
|
|
- pub display_mode: String,
|
|
|
-
|
|
|
- #[default = 100]
|
|
|
- pub height: u64,
|
|
|
-
|
|
|
- #[default = "-1"]
|
|
|
- #[serde(rename = "visibilityWindow")]
|
|
|
- pub visibility_window: String,
|
|
|
-
|
|
|
- #[default = true]
|
|
|
- pub searchable: bool,
|
|
|
-
|
|
|
- #[default = false]
|
|
|
- #[serde(rename = "supportsWholeGenome")]
|
|
|
- pub supports_whole_genome: bool,
|
|
|
-
|
|
|
- #[default = 0]
|
|
|
- pub order: i16,
|
|
|
-
|
|
|
- #[default = "annotation"]
|
|
|
- #[serde(rename = "type")]
|
|
|
- pub igv_type: String,
|
|
|
-
|
|
|
- #[default = "rgb(0, 0, 150)"]
|
|
|
- pub color: String,
|
|
|
-}
|
|
|
-
|
|
|
-impl GenesTrack {
|
|
|
- pub fn new(gff3_url: &str) -> Self {
|
|
|
- Self {
|
|
|
- url: gff3_url.to_string(),
|
|
|
- index_url: format!("{gff3_url}.tbi"),
|
|
|
- ..Default::default()
|
|
|
+ /// Add many tracks at once (preserves order).
|
|
|
+ pub fn add_tracks<I>(mut self, tracks: I) -> anyhow::Result<Self>
|
|
|
+ where
|
|
|
+ I: IntoIterator<Item = Track>,
|
|
|
+ {
|
|
|
+ for t in tracks {
|
|
|
+ self = self.add_track(t)?;
|
|
|
}
|
|
|
+ Ok(self)
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Serialize, SmartDefault, Debug)]
|
|
|
-pub struct VariantsTrack {
|
|
|
- #[default = "variant"]
|
|
|
- #[serde(rename = "type")]
|
|
|
- pub igv_type: String,
|
|
|
-
|
|
|
- #[default = "vcf"]
|
|
|
- pub format: String,
|
|
|
|
|
|
- pub url: String,
|
|
|
-
|
|
|
- #[serde(rename = "indexURL")]
|
|
|
- pub index_url: String,
|
|
|
-
|
|
|
- #[default = ""]
|
|
|
- pub filename: String,
|
|
|
-
|
|
|
- pub name: String,
|
|
|
-
|
|
|
- #[default = "#008cff"]
|
|
|
- pub color: String,
|
|
|
-
|
|
|
- #[serde(rename = "visibilityWindow")]
|
|
|
- #[default = 13513380]
|
|
|
- pub visibility_window: i32,
|
|
|
-
|
|
|
- #[default = 0]
|
|
|
- pub order: i16,
|
|
|
-}
|
|
|
-
|
|
|
-impl VariantsTrack {
|
|
|
- pub fn new(vcf_url: &str, name: &str) -> Self {
|
|
|
- Self {
|
|
|
- url: vcf_url.to_string(),
|
|
|
- index_url: format!("{vcf_url}.tbi"),
|
|
|
- filename: name.to_string(),
|
|
|
- name: name.to_string(),
|
|
|
- ..Default::default()
|
|
|
+ /// Explicitly re-assign order by current vector order (useful after filtering).
|
|
|
+ pub fn renumber_tracks(mut self) -> anyhow::Result<Self> {
|
|
|
+ for (i, t) in self.tracks.iter_mut().enumerate() {
|
|
|
+ t.order((i + 1) as i16);
|
|
|
}
|
|
|
+ let tv: Vec<Value> = self.tracks.iter().map(|t| t.to_json()).collect();
|
|
|
+ *self
|
|
|
+ .value
|
|
|
+ .get_mut("tracks")
|
|
|
+ .context("Can't access tracks value")? = Value::Array(tv);
|
|
|
+ Ok(self)
|
|
|
}
|
|
|
-}
|
|
|
-
|
|
|
-#[derive(Debug, Serialize, SmartDefault)]
|
|
|
-pub struct BedTrack {
|
|
|
- #[default = "annotation"]
|
|
|
- #[serde(rename = "type")]
|
|
|
- pub igv_type: String,
|
|
|
|
|
|
- #[default = "bed"]
|
|
|
- pub format: String,
|
|
|
-
|
|
|
- #[default = "EXPANDED"]
|
|
|
- #[serde(rename = "displayMode")]
|
|
|
- pub display_mode: String,
|
|
|
-
|
|
|
- #[default = 0]
|
|
|
- pub order: i16,
|
|
|
-
|
|
|
- pub name: String,
|
|
|
- pub url: String,
|
|
|
-}
|
|
|
-
|
|
|
-impl BedTrack {
|
|
|
- pub fn new(name: &str, url: &str) -> Self {
|
|
|
- Self {
|
|
|
- name: name.to_string(),
|
|
|
- url: url.to_string(),
|
|
|
- ..Default::default()
|
|
|
- }
|
|
|
+ /// Render the session as an IGV link using a compressed `blob:` sessionURL.
|
|
|
+ ///
|
|
|
+ /// `base_url` should be the route serving your igv.js page, e.g. `"igv/"`.
|
|
|
+ pub fn link(&self, base_url: &str) -> anyhow::Result<String> {
|
|
|
+ let blob = compress_string(&self.to_json_string())?;
|
|
|
+ Ok(format!("{base_url}?sessionURL=blob:{blob}"))
|
|
|
}
|
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
|
mod tests {
|
|
|
+ use crate::tracks::{bam::BamTrack, genes::GenesTrack};
|
|
|
+
|
|
|
use super::*;
|
|
|
|
|
|
#[test]
|
|
|
fn it_works() -> anyhow::Result<()> {
|
|
|
let sess = Session::default()
|
|
|
.with_reference(ReferenceValues::default())
|
|
|
- .with_locus_at(("chr1", 47_098_189), 33)?
|
|
|
+ .with_locus_at("chr1", 47_098_189, 33)?
|
|
|
.add_track(Track::Bam(BamTrack::new(
|
|
|
"VIEL diag",
|
|
|
"/data/longreads_basic_pipe/VIEL/diag/VIEL_diag_hs1.bam",
|
|
|
@@ -384,7 +366,7 @@ mod tests {
|
|
|
|
|
|
let sess = Session::default()
|
|
|
.with_reference(reference)
|
|
|
- .with_locus_at((alt_id, 100), 19)?
|
|
|
+ .with_locus_at(alt_id, 100, 19)?
|
|
|
.add_track(Track::Bam(BamTrack::new(
|
|
|
"On contig",
|
|
|
&format!("{alt_prefix}.bam"),
|