Эх сурвалжийг харах

refactor tracks and added array of positions

Thomas 3 долоо хоног өмнө
parent
commit
f31bbb6a04
7 өөрчлөгдсөн 527 нэмэгдсэн , 241 устгасан
  1. 60 35
      Cargo.lock
  2. 188 206
      src/lib.rs
  3. 67 0
      src/tracks/bam.rs
  4. 41 0
      src/tracks/bed.rs
  5. 73 0
      src/tracks/genes.rs
  6. 43 0
      src/tracks/mod.rs
  7. 55 0
      src/tracks/variants.rs

+ 60 - 35
Cargo.lock

@@ -1,18 +1,18 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
-name = "adler"
-version = "1.0.2"
+name = "adler2"
+version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
 [[package]]
 name = "anyhow"
-version = "1.0.86"
+version = "1.0.100"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
 
 [[package]]
 name = "base64"
@@ -22,24 +22,24 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
 
 [[package]]
 name = "cfg-if"
-version = "1.0.0"
+version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
 
 [[package]]
 name = "crc32fast"
-version = "1.4.2"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
 dependencies = [
  "cfg-if",
 ]
 
 [[package]]
 name = "flate2"
-version = "1.0.30"
+version = "1.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
+checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
 dependencies = [
  "crc32fast",
  "miniz_oxide",
@@ -47,17 +47,24 @@ dependencies = [
 
 [[package]]
 name = "itoa"
-version = "1.0.11"
+version = "1.0.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
 
 [[package]]
 name = "miniz_oxide"
-version = "0.7.4"
+version = "0.8.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
 dependencies = [
- "adler",
+ "adler2",
+ "simd-adler32",
 ]
 
 [[package]]
@@ -74,42 +81,46 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.86"
+version = "1.0.105"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
 dependencies = [
  "unicode-ident",
 ]
 
 [[package]]
 name = "quote"
-version = "1.0.36"
+version = "1.0.43"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
 dependencies = [
  "proc-macro2",
 ]
 
 [[package]]
-name = "ryu"
-version = "1.0.18"
+name = "serde"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
 
 [[package]]
-name = "serde"
-version = "1.0.204"
+name = "serde_core"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.204"
+version = "1.0.228"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -118,15 +129,23 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.120"
+version = "1.0.148"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
+checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
 dependencies = [
  "itoa",
- "ryu",
+ "memchr",
  "serde",
+ "serde_core",
+ "zmij",
 ]
 
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
 [[package]]
 name = "smart-default"
 version = "0.7.1"
@@ -140,9 +159,9 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "2.0.71"
+version = "2.0.113"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462"
+checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -151,6 +170,12 @@ dependencies = [
 
 [[package]]
 name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "zmij"
 version = "1.0.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"

+ 188 - 206
src/lib.rs

@@ -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"),

+ 67 - 0
src/tracks/bam.rs

@@ -0,0 +1,67 @@
+use serde::Serialize;
+use smart_default::SmartDefault;
+
+/// BAM alignment track configuration for igv.js.
+#[derive(Debug, Serialize, SmartDefault)]
+pub struct BamTrack {
+    /// igv.js track type (for BAM alignments typically `"alignment"`).
+    #[serde(rename = "type")]
+    #[default = "alignment"]
+    pub igv_type: String,
+
+    /// Track order in the IGV UI.
+    #[default = 0]
+    pub order: i16,
+
+    /// BAM URL.
+    #[default = ""]
+    pub url: String,
+
+    /// Display filename (often same as name).
+    #[default = ""]
+    pub filename: String,
+
+    /// BAM index URL (typically `.bai`).
+    #[serde(rename = "indexURL")]
+    #[default = ""]
+    pub index_url: String,
+
+    /// Track name shown in IGV.
+    #[default = ""]
+    pub name: String,
+
+    /// File format (defaults to `"bam"`).
+    #[default = "bam"]
+    pub format: String,
+
+    /// Optional coloring strategy (e.g. by BAM tag)
+    #[serde(rename = "colorBy")]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub color_by: Option<ColorBy>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct ColorBy {
+    pub tag: String,
+}
+
+impl BamTrack {
+    /// Create a BAM track with its `.bai` index inferred as `{url}.bai`.
+    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()
+        }
+    }
+    pub fn color_by_tag(mut self, tag: &str) -> Self {
+        self.color_by = Some(ColorBy {
+            tag: tag.to_string(),
+        });
+        self
+    }
+}
+
+

+ 41 - 0
src/tracks/bed.rs

@@ -0,0 +1,41 @@
+use serde::Serialize;
+use smart_default::SmartDefault;
+
+/// BED annotation track configuration for igv.js.
+#[derive(Debug, Serialize, SmartDefault)]
+pub struct BedTrack {
+    /// igv.js track type (for BED annotations typically `"annotation"`).
+    #[default = "annotation"]
+    #[serde(rename = "type")]
+    pub igv_type: String,
+
+    /// Format (defaults to `"bed"`).
+    #[default = "bed"]
+    pub format: String,
+
+    /// Display mode (e.g. `"EXPANDED"`).
+    #[default = "EXPANDED"]
+    #[serde(rename = "displayMode")]
+    pub display_mode: String,
+
+    /// Track order.
+    #[default = 0]
+    pub order: i16,
+
+    /// Track name shown in IGV.
+    pub name: String,
+
+    /// BED URL.
+    pub url: String,
+}
+
+impl BedTrack {
+    /// Create a BED track.
+    pub fn new(name: &str, url: &str) -> Self {
+        Self {
+            name: name.to_string(),
+            url: url.to_string(),
+            ..Default::default()
+        }
+    }
+}

+ 73 - 0
src/tracks/genes.rs

@@ -0,0 +1,73 @@
+use serde::Serialize;
+use smart_default::SmartDefault;
+
+/// Gene annotation track configuration (GFF3) for igv.js.
+#[derive(Debug, Serialize, SmartDefault)]
+pub struct GenesTrack {
+    /// Track id (often used by igv.js to identify track class/instance).
+    #[default = "RefSeq Liftoff v5.1"]
+    pub id: String,
+
+    /// Display name.
+    #[default = "Genes"]
+    pub name: String,
+
+    /// Format (defaults to `"gff3"`).
+    #[default = "gff3"]
+    pub format: String,
+
+    /// GFF3 URL.
+    #[default = ""]
+    pub url: String,
+
+    /// Tabix index URL (typically `.tbi`).
+    #[default = ""]
+    #[serde(rename = "indexURL")]
+    pub index_url: String,
+
+    /// Display mode (e.g. `"EXPANDED"`).
+    #[default = "EXPANDED"]
+    #[serde(rename = "displayMode")]
+    pub display_mode: String,
+
+    /// Track height in pixels.
+    #[default = 100]
+    pub height: u64,
+
+    /// Visibility window.
+    #[default = "-1"]
+    #[serde(rename = "visibilityWindow")]
+    pub visibility_window: String,
+
+    /// Whether this track is searchable.
+    #[default = true]
+    pub searchable: bool,
+
+    /// Whether the track supports whole genome view.
+    #[default = false]
+    #[serde(rename = "supportsWholeGenome")]
+    pub supports_whole_genome: bool,
+
+    /// Track order.
+    #[default = 0]
+    pub order: i16,
+
+    /// igv.js track type (for annotations typically `"annotation"`).
+    #[default = "annotation"]
+    #[serde(rename = "type")]
+    pub igv_type: String,
+
+    /// Color (CSS 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()
+        }
+    }
+}

+ 43 - 0
src/tracks/mod.rs

@@ -0,0 +1,43 @@
+use serde_json::{Value, json};
+
+use crate::tracks::{bam::BamTrack, bed::BedTrack, genes::GenesTrack, variants::VariantsTrack};
+
+pub mod bam;
+pub mod bed;
+pub mod genes;
+pub mod variants;
+
+/// Supported IGV track variants for session JSON.
+#[derive(Debug)]
+pub enum Track {
+    /// BAM alignment track.
+    Bam(BamTrack),
+    /// Gene annotation track (GFF3).
+    Genes(GenesTrack),
+    /// Variant track (VCF).
+    Variants(VariantsTrack),
+    /// BED annotation track.
+    Bed(BedTrack),
+}
+
+impl Track {
+    /// Serialize the track variant to a JSON object suitable for igv.js.
+    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),
+        }
+    }
+
+    /// Set the track display order.
+    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,
+        }
+    }
+}

+ 55 - 0
src/tracks/variants.rs

@@ -0,0 +1,55 @@
+use serde::Serialize;
+use smart_default::SmartDefault;
+
+/// Variant track configuration (VCF + tabix) for igv.js.
+#[derive(Serialize, SmartDefault, Debug)]
+pub struct VariantsTrack {
+    /// igv.js track type (for variants `"variant"`).
+    #[default = "variant"]
+    #[serde(rename = "type")]
+    pub igv_type: String,
+
+    /// Format (defaults to `"vcf"`).
+    #[default = "vcf"]
+    pub format: String,
+
+    /// VCF URL.
+    pub url: String,
+
+    /// Tabix index URL (typically `.tbi`).
+    #[serde(rename = "indexURL")]
+    pub index_url: String,
+
+    /// Display filename (often same as name).
+    #[default = ""]
+    pub filename: String,
+
+    /// Track name shown in IGV.
+    pub name: String,
+
+    /// Color (CSS string).
+    #[default = "#008cff"]
+    pub color: String,
+
+    /// Visibility window for variants.
+    #[serde(rename = "visibilityWindow")]
+    #[default = 13513380]
+    pub visibility_window: i32,
+
+    /// Track order.
+    #[default = 0]
+    pub order: i16,
+}
+
+impl VariantsTrack {
+    /// Create a VCF track with its Tabix index inferred as `{vcf_url}.tbi`.
+    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()
+        }
+    }
+}