|
|
@@ -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(())
|
|
|
}
|
|
|
}
|
|
|
-
|