Thomas пре 4 месеци
родитељ
комит
f7b4b87830
5 измењених фајлова са 864 додато и 120 уклоњено
  1. 249 3
      Cargo.lock
  2. 2 0
      Cargo.toml
  3. 479 84
      src/circ.rs
  4. 1 0
      src/cytoband.rs
  5. 133 33
      src/lib.rs

+ 249 - 3
Cargo.lock

@@ -8,6 +8,65 @@ version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
 
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "anyhow"
 version = "1.0.96"
@@ -65,6 +124,12 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
 [[package]]
 name = "core_maths"
 version = "0.1.1"
@@ -89,6 +154,29 @@ version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
 
+[[package]]
+name = "env_filter"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
 [[package]]
 name = "flate2"
 version = "1.1.0"
@@ -168,12 +256,42 @@ version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
 
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
 [[package]]
 name = "itoa"
 version = "1.0.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
 
+[[package]]
+name = "jiff"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
+dependencies = [
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "kurbo"
 version = "0.11.1"
@@ -198,9 +316,15 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
 
 [[package]]
 name = "log"
-version = "0.4.26"
+version = "0.4.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
 
 [[package]]
 name = "memmap2"
@@ -226,11 +350,19 @@ version = "1.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
 
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
 [[package]]
 name = "pandora_lib_graph"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "env_logger",
+ "log",
  "ureq",
  "usvg",
 ]
@@ -247,6 +379,68 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
 
+[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
+
 [[package]]
 name = "ring"
 version = "0.17.11"
@@ -258,7 +452,7 @@ dependencies = [
  "getrandom",
  "libc",
  "untrusted",
- "windows-sys",
+ "windows-sys 0.52.0",
 ]
 
 [[package]]
@@ -326,6 +520,26 @@ dependencies = [
  "unicode-script",
 ]
 
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "shlex"
 version = "1.3.0"
@@ -387,6 +601,17 @@ dependencies = [
  "siphasher",
 ]
 
+[[package]]
+name = "syn"
+version = "2.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
 [[package]]
 name = "tiny-skia-path"
 version = "0.11.4"
@@ -440,6 +665,12 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
 
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
 [[package]]
 name = "unicode-properties"
 version = "0.1.3"
@@ -527,6 +758,12 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
 
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
 [[package]]
 name = "version_check"
 version = "0.9.5"
@@ -557,6 +794,15 @@ dependencies = [
  "windows-targets",
 ]
 
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
 [[package]]
 name = "windows-targets"
 version = "0.52.6"

+ 2 - 0
Cargo.toml

@@ -5,6 +5,8 @@ edition = "2021"
 
 [dependencies]
 anyhow = "1.0.89"
+env_logger = "0.11.8"
+log = "0.4.27"
 # typst = "0.13.0"
 # typst-as-lib = { version = "0.14.1", features = ["packages", "edition2024"] }
 # typst-pdf = "0.13.0"

+ 479 - 84
src/circ.rs

@@ -1,10 +1,16 @@
-use std::io::Write;
+use std::{
+    collections::HashMap,
+    fs::File,
+    io::{BufRead, BufReader, Write},
+};
+
+use log::info;
 
 use crate::cytoband::Range;
 
 const CAP_MINOR_RATIO: f64 = 0.3; // 1/3 of thickness
 
-#[derive(Clone, Copy)]
+#[derive(Debug, Clone, Copy)]
 pub enum CapStyle {
     None,
     Start,
@@ -12,14 +18,13 @@ pub enum CapStyle {
     Both,
 }
 
-#[derive(PartialEq, Eq, Clone, Copy)]
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
 pub enum LabelMode {
     Radial,
     Perimeter,
 }
 
-
-/// A set of ranges that will be rendered consecutively on a track
+#[derive(Debug, Clone)]
 pub struct RangeSet {
     pub name: String,
     pub ranges: Vec<Range>,
@@ -28,42 +33,63 @@ pub struct RangeSet {
 }
 
 impl RangeSet {
-    /// Compute total data span for this set (could be split, but normally contiguous)
     pub fn span(&self) -> u32 {
         self.data_end - self.data_start
     }
 }
 
-/// Items that a track can render
+#[derive(Debug)]
+pub enum Attach {
+    /// For angle-based placement (e.g. annotation/overlay)
+    Angle {
+        start_angle: f64,
+        end_angle: Option<f64>,
+    }, // If end_angle is None, used for label only
+    /// For data-based placement (range set)
+    Data {
+        set_idx: usize,
+        start: u32,
+        end: Option<u32>,
+    }, // If end is None, used for label only
+}
+
+#[derive(Debug)]
 pub enum TrackItem {
-    /// Data-based arc: attached to a range set and a data interval within it
     Arc {
-        set_idx: usize,
-        start: u32, // relative to set.data_start
-        end: u32,
+        attach: Attach,
         ri: f64,
         ro: f64,
         category: String,
         cap_style: CapStyle,
     },
-    /// Label, either angle-based or data-based
     Label {
-        mode: LabelAttach,
-        text: String,
+        attach: Attach,
         r: f64,
         label_mode: LabelMode,
         font_size: f64,
         background: Option<String>,
         opacity: f64,
+        text: String,
+    },
+    Path {
+        start: Attach,
+        end: Attach,
+        ri: f64,
+        ro: f64,
+        color: String,
+        thickness: u32,
+    },
+    Cord {
+        start: Attach, // position for first endpoint (angle + data for radius)
+        end: Attach,   // position for second endpoint (angle + data for radius)
+        radius: f64,   // endpoint radius (where the cord connects to circle)
+        bulge: f64,    // curvature offset (distance from chord center for bulge)
+        color: String,
+        thickness: u32,
     },
 }
 
-/// How a label is attached: either angle-based, or data-based (set+pos)
-pub enum LabelAttach {
-    Angle(f64),
-    Data { set_idx: usize, pos: u32 },
-}
-
+#[derive(Debug)]
 pub struct Track {
     pub cx: f64,
     pub cy: f64,
@@ -87,7 +113,18 @@ impl Track {
         }
     }
 
-    /// Add a range set (e.g. a chromosome, block, or whatever)
+    pub fn new_from(&self) -> Track {
+        Track {
+            cx: self.cx,
+            cy: self.cy,
+            angle_start: self.angle_start,
+            angle_end: self.angle_end,
+            gap: self.gap,
+            range_sets: self.range_sets.clone(),
+            items: Vec::new(),
+        }
+    }
+
     pub fn add_range_set(&mut self, name: String, ranges: Vec<Range>) {
         let data_start = ranges.first().map(|r| r.start).unwrap_or(0);
         let data_end = ranges.last().map(|r| r.end).unwrap_or(0);
@@ -99,49 +136,119 @@ impl Track {
         });
     }
 
-    /// Helper: add all ranges of a given set as arcs with cap logic
-    pub fn add_arcs_for_set<F>(&mut self, set_idx: usize, ri: f64, ro: f64, cap_func: F)
-    where
-        F: Fn(usize, usize) -> CapStyle,
-    {
-        if let Some(set) = self.range_sets.get(set_idx) {
-            let n = set.ranges.len();
-            for (i, range) in set.ranges.iter().enumerate() {
-                self.items.push(TrackItem::Arc {
-                    set_idx,
-                    start: range.start,
-                    end: range.end,
-                    ri,
-                    ro,
-                    category: range.category.clone(),
-                    cap_style: cap_func(i, n),
-                });
-            }
-        }
+    /// Add an arc (data or angle based)
+    pub fn add_arc(
+        &mut self,
+        attach: Attach,
+        ri: f64,
+        ro: f64,
+        category: String,
+        cap_style: CapStyle,
+    ) {
+        self.items.push(TrackItem::Arc {
+            attach,
+            ri,
+            ro,
+            category,
+            cap_style,
+        });
     }
 
-    /// Add a label, attached either by absolute angle or by (set, pos)
+    /// Add a label (data or angle based)
     pub fn add_label(
         &mut self,
-        mode: LabelAttach,
-        text: String,
+        attach: Attach,
         r: f64,
         label_mode: LabelMode,
         font_size: f64,
         background: Option<String>,
         opacity: f64,
+        text: String,
     ) {
         self.items.push(TrackItem::Label {
-            mode,
-            text,
+            attach,
             r,
             label_mode,
             font_size,
             background,
             opacity,
+            text,
+        });
+    }
+
+    pub fn add_path(
+        &mut self,
+        start: Attach,
+        end: Attach,
+        ri: f64,
+        ro: f64,
+        color: String,
+        thickness: u32,
+    ) {
+        self.items.push(TrackItem::Path {
+            start,
+            end,
+            ri,
+            ro,
+            color,
+            thickness,
         });
     }
 
+    pub fn add_cord(
+        &mut self,
+        start: Attach,
+        end: Attach,
+        radius: f64,
+        bulge: f64,
+        color: String,
+        thickness: u32,
+    ) {
+        self.items.push(TrackItem::Cord {
+            start,
+            end,
+            color,
+            thickness,
+            radius,
+            bulge,
+        });
+    }
+
+    /// Helper: Add all ranges from a set as data-based arcs
+    pub fn add_arcs_for_set<F>(&mut self, set_idx: usize, ri: f64, ro: f64, cap_func: F)
+    where
+        F: Fn(usize, usize) -> CapStyle,
+    {
+        // Step 1: Get what we need *immutably* (range copies + category, caps)
+        let arcs: Vec<_> = if let Some(set) = self.range_sets.get(set_idx) {
+            let n = set.ranges.len();
+            set.ranges
+                .iter()
+                .enumerate()
+                .map(|(i, range)| {
+                    (
+                        Attach::Data {
+                            set_idx,
+                            start: range.start,
+                            end: Some(range.end),
+                        },
+                        ri,
+                        ro,
+                        range.category.clone(),
+                        cap_func(i, n),
+                    )
+                })
+                .collect()
+        } else {
+            return;
+        };
+
+        // Step 2: Actually mutate self by pushing items
+        for (attach, ri, ro, category, cap_style) in arcs {
+            self.add_arc(attach, ri, ro, category, cap_style);
+        }
+    }
+
     /// Compute angle range for a given set (i.e., its angular span on the track)
     pub fn set_angle_range(&self, set_idx: usize) -> Option<(f64, f64)> {
         let n_sets = self.range_sets.len();
@@ -187,6 +294,7 @@ pub fn classic_caps(i: usize, len: usize) -> CapStyle {
     }
 }
 
+#[derive(Debug)]
 pub struct Circos {
     pub tracks: Vec<Track>,
     pub svg_elements: Vec<String>,
@@ -216,6 +324,143 @@ impl Circos {
         }
     }
 
+    pub fn new_wg(
+        chromosomes_ri: f64,
+        cytobands: &str,
+        cx: f64,
+        cy: f64,
+        angle_start: f64,
+        angle_end: f64,
+        gap: f64,
+    ) -> anyhow::Result<Self> {
+        let width = 50.0;
+        let file = File::open(cytobands)?;
+        let reader = BufReader::new(file);
+
+        // Step 1: Parse into a map: chrom -> Vec<Range>
+        let mut chrom_bands: HashMap<String, Vec<Range>> = HashMap::new();
+
+        let mut n_lines = 0usize;
+        let mut n_skipped = 0usize;
+        let mut n_bands = 0usize;
+
+        for line in reader.lines() {
+            let line = line?;
+            n_lines += 1;
+            let parts: Vec<&str> = line.split('\t').collect();
+            if parts.len() != 5 {
+                n_skipped += 1;
+                continue;
+            }
+            let chrom = parts[0];
+            let start = match parts[1].parse::<u32>() {
+                Ok(s) => s,
+                Err(_) => {
+                    info!("Line {}: failed to parse start '{}'", n_lines, parts[1]);
+                    n_skipped += 1;
+                    continue;
+                }
+            };
+            let end = match parts[2].parse::<u32>() {
+                Ok(e) => e,
+                Err(_) => {
+                    info!("Line {}: failed to parse end '{}'", n_lines, parts[2]);
+                    n_skipped += 1;
+                    continue;
+                }
+            };
+            let category = parts[4].to_string();
+            chrom_bands
+                .entry(chrom.to_string())
+                .or_default()
+                .push(Range {
+                    start,
+                    end,
+                    category,
+                });
+            n_bands += 1;
+        }
+
+        // Step 2: Sort chromosome names naturally (1..22, X, Y, optional: MT)
+        fn chrom_key(name: &str) -> u8 {
+            match name.strip_prefix("chr").unwrap_or(name) {
+                "X" => 23,
+                "Y" => 24,
+                "MT" | "M" => 25,
+                n => n.parse::<u8>().unwrap_or(100), // Unknowns last
+            }
+        }
+
+        let mut chrom_names: Vec<String> = chrom_bands.keys().cloned().collect();
+        chrom_names.sort_by_key(|c| chrom_key(c));
+
+        // Step 3: Build the circos
+        let mut circos = Self::new();
+        let mut band_track = Track::new(cx, cy, angle_start, angle_end, gap);
+
+        for chrom in &chrom_names {
+            let bands = chrom_bands.get(chrom).unwrap();
+            band_track.add_range_set(chrom.clone(), bands.clone());
+            let set_idx = band_track.range_sets.len() - 1;
+            info!(
+                "Adding range_set '{}' with {} ranges (set_idx {})",
+                chrom,
+                bands.len(),
+                set_idx
+            );
+            band_track.add_arcs_for_set(
+                set_idx,
+                chromosomes_ri,
+                chromosomes_ri + width,
+                classic_caps,
+            );
+            let pos_end = bands.last().map(|l| l.end);
+            band_track.add_arc(
+                Attach::Data {
+                    set_idx,
+                    start: 0,
+                    end: pos_end,
+                },
+                chromosomes_ri + width + 5.0,
+                chromosomes_ri + width + 5.5,
+                "black".to_string(),
+                CapStyle::None,
+            );
+            if let Some(pos_end) = pos_end {
+                (0..pos_end).step_by(10_000_000).for_each(|p| {
+                    band_track.add_path(
+                        Attach::Data {
+                            set_idx,
+                            start: p,
+                            end: None,
+                        },
+                        Attach::Data {
+                            set_idx,
+                            start: p,
+                            end: None,
+                        },
+                        chromosomes_ri + width + 5.5,
+                        chromosomes_ri + width + 8.5,
+                        "black".to_string(),
+                        1,
+                    );
+                });
+            }
+        }
+
+        info!(
+            "Parsed {} lines, skipped {}, bands: {}, range_sets: {}, track_items: {}",
+            n_lines,
+            n_skipped,
+            n_bands,
+            band_track.range_sets.len(),
+            band_track.items.len()
+        );
+
+        circos.add_track(band_track);
+        Ok(circos)
+    }
+
     pub fn add_track(&mut self, track: Track) {
         self.tracks.push(track);
     }
@@ -232,47 +477,68 @@ impl Circos {
             for item in &track.items {
                 match item {
                     TrackItem::Arc {
-                        set_idx,
-                        start,
-                        end,
+                        attach,
                         ri,
                         ro,
                         category,
                         cap_style,
                     } => {
-                        // Map start/end to angles using track.set_angle_range()
-                        if let Some((a0, a1)) = track.set_angle_range(*set_idx) {
-                            let set = &track.range_sets[*set_idx];
-                            let frac0 = (*start - set.data_start) as f64 / (set.data_end - set.data_start) as f64;
-                            let frac1 = (*end - set.data_start) as f64 / (set.data_end - set.data_start) as f64;
-                            let angle0 = a0 + frac0 * (a1 - a0);
-                            let angle1 = a0 + frac1 * (a1 - a0);
-                            Self::draw_arc(
-                                &mut state,
-                                track.cx,
-                                track.cy,
-                                *ri,
-                                *ro,
-                                angle0,
-                                angle1,
-                                *cap_style,
-                                category,
-                            );
+                        match attach {
+                            Attach::Angle {
+                                start_angle,
+                                end_angle,
+                            } => {
+                                let a1 = end_angle.unwrap_or(*start_angle); // If no end, zero-length arc
+                                Self::draw_arc(
+                                    &mut state,
+                                    track.cx,
+                                    track.cy,
+                                    *ri,
+                                    *ro,
+                                    *start_angle,
+                                    a1,
+                                    *cap_style,
+                                    category,
+                                );
+                            }
+                            Attach::Data {
+                                set_idx,
+                                start,
+                                end,
+                            } => {
+                                if let Some((a0, a1)) = track.set_angle_range(*set_idx) {
+                                    let set = &track.range_sets[*set_idx];
+                                    let frac0 = (*start - set.data_start) as f64
+                                        / (set.data_end - set.data_start) as f64;
+                                    let frac1 = end
+                                        .map(|e| {
+                                            (e - set.data_start) as f64
+                                                / (set.data_end - set.data_start) as f64
+                                        })
+                                        .unwrap_or(frac0);
+                                    let angle0 = a0 + frac0 * (a1 - a0);
+                                    let angle1 = a0 + frac1 * (a1 - a0);
+                                    Self::draw_arc(
+                                        &mut state, track.cx, track.cy, *ri, *ro, angle0, angle1,
+                                        *cap_style, category,
+                                    );
+                                }
+                            }
                         }
                     }
                     TrackItem::Label {
-                        mode,
-                        text,
+                        attach,
                         r,
                         label_mode,
                         font_size,
                         background,
                         opacity,
+                        text,
                     } => {
-                        let angle = match mode {
-                            LabelAttach::Angle(a) => *a,
-                            LabelAttach::Data { set_idx, pos } => {
-                                track.coordinate_to_angle(*set_idx, *pos).unwrap_or(0.0)
+                        let angle = match attach {
+                            Attach::Angle { start_angle, .. } => *start_angle,
+                            Attach::Data { set_idx, start, .. } => {
+                                track.coordinate_to_angle(*set_idx, *start).unwrap_or(0.0)
                             }
                         };
                         Self::draw_label(
@@ -288,6 +554,88 @@ impl Circos {
                             *opacity,
                         );
                     }
+                    TrackItem::Path {
+                        start,
+                        end,
+                        ri,
+                        ro,
+                        color,
+                        thickness,
+                    } => {
+                        // Helper to get (angle, r) from Attach + desired radius
+                        let get_point = |attach: &Attach, radius: f64| -> Option<(f64, f64)> {
+                            match attach {
+                                Attach::Angle { start_angle, .. } => Some((*start_angle, radius)),
+                                Attach::Data { set_idx, start, .. } => {
+                                    let angle = track.coordinate_to_angle(*set_idx, *start)?;
+                                    Some((angle, radius))
+                                }
+                            }
+                        };
+                        // Try as full path
+                        if let (Some((a0, r0)), Some((a1, r1))) =
+                            (get_point(start, *ri), get_point(end, *ro))
+                        {
+                            let x0 = track.cx + r0 * a0.cos();
+                            let y0 = track.cy + r0 * a0.sin();
+                            let x1 = track.cx + r1 * a1.cos();
+                            let y1 = track.cy + r1 * a1.sin();
+                            // Update bounding box...
+                            for &(x, y) in &[(x0, y0), (x1, y1)] {
+                                state.min_x = state.min_x.min(x);
+                                state.max_x = state.max_x.max(x);
+                                state.min_y = state.min_y.min(y);
+                                state.max_y = state.max_y.max(y);
+                            }
+                            state.svg_elements.push(format!(
+            r#"<line x1="{:.2}" y1="{:.2}" x2="{:.2}" y2="{:.2}" stroke="{}" stroke-width="{}"/>"#,
+            x0, y0, x1, y1, color, thickness
+        ));
+                        }
+                    }
+                    TrackItem::Cord {
+                        start,
+                        end,
+                        radius,
+                        bulge,
+                        color,
+                        thickness,
+                    } => {
+                        let get_angle = |attach: &Attach| -> Option<f64> {
+                            match attach {
+                                Attach::Angle { start_angle, .. } => Some(*start_angle),
+                                Attach::Data { set_idx, start, .. } => {
+                                    track.coordinate_to_angle(*set_idx, *start)
+                                }
+                            }
+                        };
+                        if let (Some(angle0), Some(angle1)) = (get_angle(start), get_angle(end)) {
+                            // Endpoints
+                            let x0 = track.cx + radius * angle0.cos();
+                            let y0 = track.cy + radius * angle0.sin();
+                            let x1 = track.cx + radius * angle1.cos();
+                            let y1 = track.cy + radius * angle1.sin();
+
+                            // Control point: midpoint, bulged out by `bulge`
+                            let mid_angle = 0.5 * (angle0 + angle1);
+                            let ctrl_r = radius + bulge;
+                            let cx_ctrl = track.cx + ctrl_r * mid_angle.cos();
+                            let cy_ctrl = track.cy + ctrl_r * mid_angle.sin();
+
+                            // Bounding box
+                            for &(x, y) in &[(x0, y0), (x1, y1), (cx_ctrl, cy_ctrl)] {
+                                state.min_x = state.min_x.min(x);
+                                state.max_x = state.max_x.max(x);
+                                state.min_y = state.min_y.min(y);
+                                state.max_y = state.max_y.max(y);
+                            }
+
+                            state.svg_elements.push(format!(
+            r#"<path d="M {:.2} {:.2} Q {:.2} {:.2} {:.2} {:.2}" stroke="{}" stroke-width="{}" fill="none"/>"#,
+            x0, y0, cx_ctrl, cy_ctrl, x1, y1, color, thickness
+        ));
+                        }
+                    }
                 }
             }
         }
@@ -407,22 +755,70 @@ impl Circos {
             rotate
         };
 
-        // background box (optional)
+        // Calculate text bounding box (approximate)
+        let width = text.chars().count() as f64 * font_size * 0.6;
+        let height = font_size * 1.2;
+        let rect_x = x - width / 2.0;
+        let rect_y = y - height / 2.0;
+
+        // Expand the viewBox to include the label's bounding rectangle
+        // (This works even if rotated, since we take the box that would contain the rotated rectangle.)
+        let (corners_x, corners_y) = if rot % 360.0 == 0.0 {
+            // No rotation
+            (vec![rect_x, rect_x + width], vec![rect_y, rect_y + height])
+        } else {
+            // With rotation: compute all 4 corners after rotation and update bounding box
+            let rad = rot.to_radians();
+            let cos_theta = rad.cos();
+            let sin_theta = rad.sin();
+            let cxr = x;
+            let cyr = y;
+            let corners = [
+                (rect_x, rect_y),
+                (rect_x + width, rect_y),
+                (rect_x, rect_y + height),
+                (rect_x + width, rect_y + height),
+            ];
+            let rotated: Vec<(f64, f64)> = corners
+                .iter()
+                .map(|&(px, py)| {
+                    let dx = px - cxr;
+                    let dy = py - cyr;
+                    (
+                        cxr + cos_theta * dx - sin_theta * dy,
+                        cyr + sin_theta * dx + cos_theta * dy,
+                    )
+                })
+                .collect();
+            (
+                rotated.iter().map(|(x, _)| *x).collect::<Vec<_>>(),
+                rotated.iter().map(|(_, y)| *y).collect::<Vec<_>>(),
+            )
+        };
+
+        let min_x = corners_x.iter().cloned().fold(f64::INFINITY, f64::min);
+        let max_x = corners_x.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+        let min_y = corners_y.iter().cloned().fold(f64::INFINITY, f64::min);
+        let max_y = corners_y.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
+
+        state.min_x = state.min_x.min(min_x);
+        state.max_x = state.max_x.max(max_x);
+        state.min_y = state.min_y.min(min_y);
+        state.max_y = state.max_y.max(max_y);
+
+        // Draw background rectangle if needed (before text)
         if let Some(bgcolor) = background {
-            let width = text.chars().count() as f64 * font_size * 0.6;
-            let height = font_size * 1.2;
-            let rect_x = x - width / 2.0;
-            let rect_y = y - height / 2.0;
             state.svg_elements.push(format!(
-                r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" opacity="{:.2}" rx="2" transform="rotate({:.2},{:.2},{:.2})"/>"#,
-                rect_x, rect_y, width, height, bgcolor, opacity, rot, x, y
-            ));
+            r#"<rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" opacity="{:.2}" rx="3" transform="rotate({:.2},{:.2},{:.2})"/>"#,
+            rect_x, rect_y, width, height, bgcolor, opacity, rot, x, y
+        ));
         }
 
+        // Draw text
         state.svg_elements.push(format!(
-            r#"<text x="{:.2}" y="{:.2}" text-anchor="{}" dominant-baseline="middle" font-size="{:.1}" transform="rotate({:.2},{:.2},{:.2})">{}</text>"#,
-            x, y, anchor, font_size, rot, x, y, text
-        ));
+        r#"<text x="{:.2}" y="{:.2}" text-anchor="{}" dominant-baseline="middle" font-size="{:.1}" transform="rotate({:.2},{:.2},{:.2})">{}</text>"#,
+        x, y, anchor, font_size, rot, x, y, text
+    ));
     }
 
     pub fn save_to_file(&mut self, path: &str, margin: f64) -> std::io::Result<()> {
@@ -451,4 +847,3 @@ impl Circos {
         Ok(())
     }
 }
-

+ 1 - 0
src/cytoband.rs

@@ -1,6 +1,7 @@
 use std::fs::File;
 use std::io::{BufRead, BufReader, Write};
 
+#[derive(Debug, Clone)]
 pub struct Range {
     pub start: u32,
     pub end: u32,

+ 133 - 33
src/lib.rs

@@ -15,6 +15,12 @@ mod tests {
 
     use super::*;
 
+    pub fn test_init() {
+        let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
+            .is_test(true)
+            .try_init();
+    }
+
     #[test]
     fn it_works() {
         let additional_rects = vec![
@@ -138,66 +144,160 @@ mod tests {
         let gap = 0.012 * std::f64::consts::PI;
 
         let mut band_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
-
-        // Add several range sets (e.g., chromosomes)
+        let mut annot_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
         for c in 1..=22 {
             let name = format!("chr{c}");
             let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", &name)?;
-            band_track.add_range_set(name, ranges);
-            // For each set, add its arcs
+
+            band_track.add_range_set(name.clone(), ranges.clone());
+            annot_track.add_range_set(name, ranges);
+
             let set_idx = band_track.range_sets.len() - 1;
             band_track.add_arcs_for_set(set_idx, r * 0.955555, r, circ::classic_caps);
         }
 
-        // Add a label at chr2 position 40_000_000
+        let r2 = r + 10.0;
+        let r3 = r + 50.0;
+
+        for c in 0..22 {
+            let name = format!("chr{}", c + 1);
+
+            annot_track.add_label(
+                circ::Attach::Data {
+                    set_idx: c,
+                    start: 0,
+                    end: None,
+                },
+                // circ::Attach::Angle {
+                //     start_angle: 50f64.to_radians(),
+                //     end_angle: None,
+                // },
+                r3 + 20.0,
+                circ::LabelMode::Perimeter,
+                28.0,
+                Some("white".to_string()),
+                0.85,
+                name,
+            );
+            annot_track.add_arc(
+                circ::Attach::Data {
+                    set_idx: c,
+                    start: 0,
+                    end: Some(100_000),
+                },
+                r2 + 1.0,
+                r2 + 20.0,
+                "gpos50".to_string(),
+                circ::CapStyle::None,
+            );
+        }
+        // Add a label at chr2, pos 40Mb
         band_track.add_label(
-            circ::LabelAttach::Data {
+            circ::Attach::Data {
                 set_idx: 1,
-                pos: 40_000_000,
+                start: 40000000,
+                end: None,
             },
-            "Hello".to_string(),
             r + 40.0,
             circ::LabelMode::Perimeter,
             22.0,
             Some("white".to_string()),
             0.9,
+            "Hello".to_string(),
         );
 
-        // Add an annotation track with absolute-angle items
-        let r2 = r + 50.0;
-        let mut annot_track = circ::Track::new(cx, cy, angle_start, angle_end, 0.0);
-        annot_track.items.push(circ::TrackItem::Arc {
-            set_idx: 0, // dummy, not used for angle-based
-            start: 0,
-            end: 0,
-            ri: r2 * 0.955555,
-            ro: r2,
-            category: "gpos50".to_string(),
-            cap_style: circ::CapStyle::None,
-        });
-        annot_track.add_label(
-            circ::LabelAttach::Data {
-                set_idx: 4,
-                pos: 40_000_000,
+        // Add an annotation arc and label by angle
+        annot_track.add_arc(
+            circ::Attach::Angle {
+                start_angle: 25f64.to_radians(),
+                end_angle: Some(60f64.to_radians()),
             },
-
-            // circ::LabelAttach::Angle(50f64.to_radians()),
-            "AngleLabel".to_string(),
-            r2 + 20.0,
-            circ::LabelMode::Perimeter,
-            28.0,
-            Some("white".to_string()),
-            0.85,
+            (r3 * 0.955555) + 5.0,
+            r3 - 10.0,
+            "gpos50".to_string(),
+            circ::CapStyle::None,
+        );
+        annot_track.add_path(
+            circ::Attach::Data {
+                set_idx: 1,
+                start: 40000000,
+                end: None,
+            },
+            circ::Attach::Angle {
+                start_angle: 25f64.to_radians(),
+                end_angle: None,
+            },
+            r + 40.0,
+            r + 70.0,
+            "red".to_string(),
+            2,
         );
 
         circos.add_track(band_track);
         circos.add_track(annot_track);
 
-        circos.save_to_file("/data/bent.svg", 10.0)?;
+        circos.save_to_file("/data/benti.svg", 100.0)?;
 
         Ok(())
     }
 
+    #[test]
+    fn wg() -> anyhow::Result<()> {
+        test_init();
+        let r = 1050.0;
+        let cx = 0.0;
+        let cy = 0.0;
+        let angle_start = -std::f64::consts::FRAC_PI_2;
+        let angle_end = angle_start + 2.0 * std::f64::consts::PI;
+        let gap = 0.012 * std::f64::consts::PI;
+
+        let mut c = circ::Circos::new_wg(
+            r,
+            "/data/ref/hs1/cytoBandMapped.bed",
+            cx,
+            cy,
+            angle_start,
+            angle_end,
+            gap,
+        )?;
+        if let Some(t) = c.tracks.first_mut() {
+            t.add_cord(
+                circ::Attach::Data {
+                    set_idx: 0,
+                    start: 55000000,
+                    end: None,
+                },
+                circ::Attach::Data {
+                    set_idx: 3,
+                    start: 1000000,
+                    end: None,
+                },
+                950.0,
+                -580.0,
+
+                "red".to_string(),
+                200,
+            );
+            t.add_cord(
+                circ::Attach::Data {
+                    set_idx: 0,
+                    start: 55000000,
+                    end: None,
+                },
+                circ::Attach::Data {
+                    set_idx: 1,
+                    start: 1000000,
+                    end: None,
+                },
+                950.0,
+                80.0,
+                "red".to_string(),
+                2,
+            );
+        }
+        c.save_to_file("/data/benti.svg", 100.0)?;
+        Ok(())
+    }
     #[test]
     fn pdf() {
         compile_typst_report("LEVASSEUR").unwrap();