Browse Source

lollipops

Thomas 2 months ago
parent
commit
2c62f086a7
10 changed files with 2719 additions and 956 deletions
  1. 0 858
      <
  2. 3 2
      Cargo.lock
  3. 1 0
      Cargo.toml
  4. 138 0
      notch_gff.gff3
  5. 114 26
      src/circ.rs
  6. 185 0
      src/gene_model.rs
  7. 429 70
      src/lib.rs
  8. 1321 0
      src/loliplot.rs
  9. 111 0
      src/loliplot_parser.rs
  10. 417 0
      src/mutation.rs

+ 0 - 858
<

@@ -1,858 +0,0 @@
-#let cr_colors = (
-  dark_grey: rgb("#333333"),
-  beige: rgb("#fdf0d5"),
-  light_grey: rgb("#eeeeee"),
-  dark_red: rgb("#780000"),
-  red: rgb("#c1121f"),
-  blue: rgb("#669bbc"),
-  dark_blue: rgb("#003049"),
-  green: rgb("#29bf12"),
-)
-
-#import "@preview/fletcher:0.5.1" as fletcher: diagram, node, edge
-#import "@preview/metro:0.3.0": *
-#import "@preview/cetz:0.2.2"
-#import "@preview/badgery:0.1.1": *
-#import "@preview/cmarker:0.1.1"
-
-#set page(
-  paper: "a4",
-  footer: locate(loc => [
-    #set text(10pt)
-    #let today = datetime.today()
-    #if loc.page() != 1 {
-      align(right, counter(page).display("1 / 1", both: true))
-    }
-    #align(center, [Dr. Thomas Steimlé --- #today.display("[day] [month repr:long] [year]")])
-  ]),
-)
-
-#show heading: set text(font: "Futura")
-#show heading.where(level: 1): it => [
-  #set align(center)
-  #set text(fill: cr_colors.dark_blue)
-  #it.body
-  #v(18pt)
-]
-
-#show image: set text(font: "FreeSans")
-
-#set text(size: 16pt, fill: cr_colors.dark_blue)
-
-#let contigs = (
-  "chr1",
-  "chr2",
-  "chr3",
-  "chr4",
-  "chr5",
-  "chr6",
-  "chr7",
-  "chr8",
-  "chr9",
-  "chr10",
-  "chr11",
-  "chr12",
-  "chr13",
-  "chr14",
-  "chr15",
-  "chr16",
-  "chr17",
-  "chr18",
-  "chr19",
-  "chr20",
-  "chr21",
-  "chr22",
-  "chrX",
-  "chrY",
-)
-
-#let parseCustomDate(dateString) = {
-  let parts = dateString.split("T")
-  let datePart = parts.at(0).replace("-", "/")
-  let timePart = parts.at(1).split(":")
-  let hour = timePart.at(0)
-  let minute = timePart.at(1)
-
-  return datePart + " " + hour + "h" + minute
-}
-
-#let formatString(input) = {
-  let words = input.split("_")
-  let capitalizedWords = words.map(word => {
-    if word.len() > 0 {
-      upper(word.first()) + word.slice(1)
-    } else {
-      word
-    }
-  })
-  capitalizedWords.join(" ")
-}
-#let si-fmt(val, precision: 1, sep: "\u{202F}", binary: false) = {
-  let factor = if binary {
-    1024
-  } else {
-    1000
-  }
-  let gt1_suffixes = ("k", "M", "G", "T", "P", "E", "Z", "Y")
-  let lt1_suffixes = ("m", "μ", "n", "p", "f", "a", "z", "y")
-  let scale = ""
-  let unit = ""
-
-  if type(val) == content {
-    if val.has("text") {
-      val = val.text
-    } else if val.has("children") {
-      val = val.children.map(content => content.text).join()
-    } else {
-      panic(val.children.map(content => content.text).join())
-    }
-  }
-  // if val contains a unit, split it off
-  if type(val) == str {
-    unit = val.find(regex("(\D+)$"))
-    val = float(val.split(unit).at(0))
-  }
-
-  if calc.abs(val) > 1 {
-    for suffix in gt1_suffixes {
-      if calc.abs(val) < factor {
-        break
-      }
-      val /= factor
-      scale += " " + suffix
-    }
-  } else if val != 0 and calc.abs(val) < 1 {
-    for suffix in lt1_suffixes {
-      if calc.abs(val) > 1 {
-        break
-      }
-      val *= factor
-      scale += " " + suffix
-    }
-  }
-
-  let formatted = str(calc.round(val, digits: precision))
-
-  formatted + sep + scale.split().at(-1, default: "") + unit
-}
-
-#let reportCoverage(prefix) = {
-  image(prefix + "_global.svg", width: 100%)
-  for contig in contigs {
-    heading(level: 4, contig)
-    let path = prefix + "_" + contig
-    image(path + "_chromosome.svg")
-    let data = json(path + "_stats.json")
-    grid(
-      columns: (1fr, 2fr),
-      gutter: 3pt,
-      align(left + horizon)[
-        #set text(size: 12pt)
-        #table(
-          stroke: none, columns: (auto, 1fr), gutter: 3pt, [Mean], [#calc.round(
-              data.mean,
-              digits: 2,
-            )], [Standard dev.], [#calc.round(
-              data.std_dev,
-              digits: 2,
-            )], ..data.breaks_values.map(r => (
-            [#r.at(0)],
-            [#calc.round(r.at(1) * 100, digits: 1)%],
-          )).flatten(),
-        )
-      ],
-      align(right, image(path + "_distrib.svg", width: 100%)),
-    )
-
-    parbreak()
-  }
-}
-
-#let reportBam(path) = {
-  let data = json(path)
-  table(
-    gutter: 3pt, stroke: none, columns: (auto, 1fr), ..for (key, value) in (
-      data
-    ) {
-      if key != "cramino" and key != "composition" and key != "path" and key != "modified" {
-        ([ #formatString(key) ], [ #value ])
-      } else if key == "modified" {
-        ([ Modified Date (UTC) ], [ #parseCustomDate(value) ])
-      } else if key == "composition" {
-        (
-          [ Run(s) ],
-          [
-            #for (i, v) in value.enumerate() {
-              if i > 0 [ \ ]
-              [#v.at(0).slice(0, 5): #calc.round(v.at(1), digits: 0)%]
-            }
-          ],
-        )
-      } else if key == "cramino" {
-        for (k, v) in value {
-          if k == "normalized_read_count_per_chromosome" { } else if k != "path" and k != "checksum" and k != "creation_time" and k != "file_name" {
-            let k = formatString(k)
-            let v = if type(v) == "integer" {
-              si-fmt(v)
-            } else {
-              v
-            }
-            ([ #k ], [ #v ])
-          } else {
-            ()
-          }
-        }.flatten()
-      } else {
-        ()
-      }
-    }.flatten(),
-  )
-}
-
-#let formatedReadCount(path) = {
-  let data = json(path)
-  let data = data.cramino.normalized_read_count_per_chromosome
-  let res = ()
-  for contig in contigs {
-    res.push(data.at(contig))
-  }
-  res.push(data.at("chrM"))
-  return res
-}
-
-#let printReadCount(diag_path, mrd_path) = {
-  let index = 14
-  let c = contigs
-  c.push("chrM")
-  let diag = formatedReadCount(diag_path)
-  let mrd = formatedReadCount(mrd_path)
-  c.insert(0, "")
-  diag.insert(0, "diag")
-  mrd.insert(0, "mrd")
-  let arrays1 = (c.slice(0, index), diag.slice(0, index), mrd.slice(0, index))
-  table(
-    columns: arrays1.at(0).len(), ..arrays1
-      .map(arr => arr.map(item => [#item]))
-      .flatten(),
-  )
-
-  let arrays2 = (c.slice(index), diag.slice(index), mrd.slice(index))
-  arrays2.at(0).insert(0, "")
-  arrays2.at(1).insert(0, "diag")
-  arrays2.at(2).insert(0, "mrd")
-
-  table(
-    columns: arrays2.at(0).len(), ..arrays2
-      .map(arr => arr.map(item => [#item]))
-      .flatten(),
-  )
-}
-
-#let variantsFlow(path) = {
-  import fletcher.shapes: diamond, parallelogram, chevron
-  let data = json(path)
-  set text(8pt)
-  diagram(
-    spacing: (8pt, 25pt),
-    node-fill: gradient.radial(
-      cr_colors.light_grey,
-      cr_colors.blue,
-      radius: 300%,
-    ),
-    node-stroke: cr_colors.dark_blue + 1pt,
-    edge-stroke: 1pt,
-    mark-scale: 70%,
-    node-inset: 8pt,
-    node(
-      (0.2, 0),
-      [Variants MRD: #num(data.vcf_stats.n_tumoral_init)],
-      corner-radius: 2pt,
-      extrude: (0, 3),
-      name: <input_mrd>,
-    ),
-    node(
-      (1.8, 0),
-      [Variants Diag: #num(data.vcf_stats.n_constit_init)],
-      corner-radius: 2pt,
-      extrude: (0, 3),
-      name: <input_diag>,
-    ),
-    node(
-      (1, 1),
-      align(center)[Variant in MRD ?],
-      shape: diamond,
-      name: <is_in_mrd>,
-    ),
-    edge(<input_mrd>, "s", <is_in_mrd>, "-|>"),
-    edge(<input_diag>, "s", <is_in_mrd>, "-|>"),
-    edge(<is_in_mrd>, <is_low_mrd>, "-|>", [Yes], label-pos: 0.8),
-    node(
-      (0.25, 2),
-      [MRD variant depth \ < 4 ?],
-      shape: diamond,
-      name: <is_low_mrd>,
-    ),
-    edge(<is_low_mrd>, <low_mrd>, "-|>"),
-    node(
-      (0, 3),
-      [Low MRD depth: #num(data.vcf_stats.n_low_mrd_depth)],
-      shape: parallelogram,
-      name: <low_mrd>,
-    ),
-    edge(<is_in_mrd>, <next>, "-|>", [No], label-pos: 0.8),
-    node(
-      (1.85, 2),
-      [To BAM filters: #num(data.bam_stats.n_lasting)],
-      shape: chevron,
-      extrude: (-3, 0),
-      name: <next>,
-      stroke: cr_colors.green,
-    ),
-    edge(<is_low_mrd>, <homo>, "-|>"),
-    node((1.5, 3), [VAF = 100% ?], shape: diamond, name: <homo>),
-    edge(<homo>, <constit>, "-|>", [Yes], label-pos: 0.5, bend: -80deg),
-    edge(<homo>, <chi>, "-|>", [No], label-pos: 0.6),
-    node(
-      (1.5, 4),
-      [$#sym.chi^2$ VAF MRD vs Diag ?],
-      shape: diamond,
-      name: <chi>,
-    ),
-    edge(<chi>, <constit>, "-|>", label-pos: 0.8),
-    node(
-      (1, 5),
-      [Constit: #num(data.vcf_stats.n_constit)],
-      shape: parallelogram,
-      name: <constit>,
-    ),
-    edge(<chi>, <loh>, "-|>", [p < 0.01], label-pos: 0.8),
-    node(
-      (2, 5),
-      [LOH: #num(data.vcf_stats.n_loh)],
-      shape: parallelogram,
-      name: <loh>,
-    ),
-  )
-}
-
-#let bamFilter(path) = {
-  import fletcher.shapes: diamond, parallelogram, hexagon
-  let data = json(path)
-  set text(8pt)
-
-  diagram(
-    spacing: (8pt, 25pt),
-    node-fill: gradient.radial(
-      cr_colors.light_grey,
-      cr_colors.blue,
-      radius: 300%,
-    ),
-    node-inset: 8pt,
-    node-stroke: cr_colors.dark_blue + 1pt,
-    mark-scale: 70%,
-    edge-stroke: 1pt,
-    node(
-      (0.75, 0),
-      [Variants not in MRD VCF: #num(data.bam_stats.n_lasting)],
-      corner-radius: 2pt,
-      extrude: (0, 3),
-      name: <input_mrd>,
-    ),
-    edge(<input_mrd>, <depth>, "-|>"),
-    node((0.75, 1), [MRD alignement depth ?], shape: diamond, name: <depth>),
-    edge(<depth>, <low_depth>, "-|>", [< 4]),
-    node(
-      (0, 2),
-      [Low MRD depth: #num(data.bam_stats.n_low_mrd_depth)],
-      shape: parallelogram,
-      name: <low_depth>,
-    ),
-    edge(<depth>, <seen>, "-|>"),
-    node(
-      (0.75, 3),
-      [Alt. base seen in MRD pileup ?],
-      shape: diamond,
-      name: <seen>,
-    ),
-    edge(<seen>, <constit>, "-|>", [Yes]),
-    node(
-      (0, 4),
-      [Constit: #num(data.bam_stats.n_constit)],
-      shape: parallelogram,
-      name: <constit>,
-    ),
-    edge(<seen>, <is_div>, "-|>", [No]),
-    node(
-      (1.1, 4),
-      [Sequence #sym.plus.minus 20nt \ diversity ?],
-      shape: diamond,
-      name: <is_div>,
-    ),
-    edge(<is_div>, <low_div>, "-|>", [entropy < 1.8]),
-    node(
-      (0.25, 5),
-      [Low diversity, artefact: #num(data.bam_stats.n_low_diversity)],
-      shape: parallelogram,
-      name: <low_div>,
-    ),
-    edge(<is_div>, <somatic>, "-|>"),
-    node(
-      (1.75, 5),
-      [Somatic: #num(data.bam_stats.n_somatic)],
-      shape: hexagon,
-      extrude: (-3, 0),
-      name: <somatic>,
-      stroke: cr_colors.green,
-    ),
-  )
-}
-
-#let barCallers(path) = {
-  import cetz.draw: *
-  import cetz.chart
-
-  let json_data = json(path).variants_stats
-  let data = json_data.find(item => item.name == "callers_cat")
-  let chart_data = data.counts.pairs().sorted(key: x => -x.at(1))
-
-  set text(11pt)
-  cetz.canvas(
-    length: 80%,
-    {
-      set-style(axes: (
-        bottom: (tick: (label: (angle: 45deg, anchor: "north-east"))),
-      ))
-      chart.columnchart(
-        chart_data,
-        size: (1, 0.5),
-      )
-    },
-  )
-}
-#let truncate(text, max-length) = {
-  if text.len() <= max-length {
-    text
-  } else {
-    text.slice(0, max-length - 3) + "..."
-  }
-}
-
-// // #let add_newlines(text, n) = {
-// //   let result = ""
-// //   let chars = text.clusters()
-// //   for (i, char) in chars.enumerate() {
-// //     result += char
-// //     if calc.rem((i + 1), n == 0 and i < chars.len() - 1 {
-// //       result += "\n"
-// //     }
-// //   }
-// //   result
-// // }
-//
-// #let break_long_words(text, max_length: 20, hyphen: "") = {
-//   let words = text.split(" ")
-//   let result = ()
-//
-//   for word in words {
-//     if word.len() <= max_length {
-//       result.push(word)
-//     } else {
-//       let segments = ()
-//       let current_segment = ""
-//       for char in word.clusters() {
-//         if current_segment.len() + 1 > max_length {
-//           segments.push(current_segment + hyphen)
-//           current_segment = ""
-//         }
-//         current_segment += char
-//       }
-//       if current_segment != "" {
-//         segments.push(current_segment)
-//       }
-//       result += segments
-//     }
-//   }
-//
-//   result.join(" ")
-// }
-//
-#let format_sequence(text, max_length: 40, hyphen: [#linebreak()]) = {
-  let words = text.split(" ")
-  let result = ()
-  // result.push("\n")
-
-  for word in words {
-    if word.len() <= max_length {
-      result.push(word)
-    } else {
-      let segments = ()
-      let current_segment = ""
-      for char in word.clusters() {
-        if current_segment.len() + 1 > max_length {
-          segments.push(current_segment + hyphen)
-          current_segment = ""
-        }
-        current_segment += char
-      }
-      if current_segment != "" {
-        segments.push(current_segment)
-      }
-      result += segments
-    }
-  }
-  result.push("")
-  let sequence = result.join(" ")
-
-  box(width: 100%, par(leading: 0.2em, sequence))
-}
-
-#let dna(sequence, line_length: 60) = {
-  let formatted = sequence.clusters().map(c => {
-    if c == "A" {
-      text(fill: red)[A]
-    } else if c == "T" {
-      text(fill: green)[T]
-    } else if c == "C" {
-      text(fill: blue)[C]
-    } else if c == "G" {
-      text(fill: orange)[G]
-    } else {
-      c
-    }
-  })
-
-  let lines = formatted.chunks(line_length).map(line => line.join())
-  let n_lines = lines.len()
-
-  let lines = lines.join("\n")
-
-  if n_lines > 1 {
-    parbreak()
-  }
-  align(
-    left,
-    box(
-      fill: luma(240),
-      inset: (x: 0.5em, y: 0.5em),
-      radius: 4pt,
-      align(
-        left,
-        text(
-          font: "Fira Code",
-          size: 10pt,
-          lines,
-        ),
-      ),
-    ),
-  )
-  if n_lines > 1 {
-    parbreak()
-  }
-}
-
-#let to-string(content) = {
-  if content.has("text") {
-    content.text
-  } else if content.has("children") {
-    content.children.map(to-string).join("")
-  } else if content.has("body") {
-    to-string(content.body)
-  } else if content == [ ] {
-    " "
-  }
-}
-
-#let format-number(num) = {
-  let s = str(num).split("").filter(item => item != "")
-  let result = ""
-  let len = s.len()
-  for (i, char) in s.enumerate() {
-    result += char
-    if (i < len - 1 and calc.rem((len - i - 1), 3) == 0) {
-      result += ","
-    }
-  }
-  result
-}
-
-#let format_json(json_data) = {
-  let format_value(value) = {
-    if value == none {
-      ""
-    } else if type(value) == "string" {
-      if value != "." {
-        value.replace(";", ", ").replace("=", ": ")
-      } else {
-        ""
-      }
-    } else if type(value) == "array" {
-      let items = value.map(v => format_value(v))
-      "[" + items.join(", ") + "]"
-    } else if type(value) == "dictionary" {
-      "{" + format_json(value) + "}"
-    } else {
-      str(value)
-    }
-  }
-
-  if type(json_data) == "dictionary" {
-    let result = ()
-    for (key, value) in json_data {
-      let formatted_value = format_value(value)
-      if formatted_value != "" {
-        if key == "svinsseq" {
-          formatted_value = dna(formatted_value)
-        }
-        result.push(upper(key) + ": " + formatted_value)
-      }
-    }
-    result.join(", ")
-  } else {
-    format_value(json_data)
-  }
-}
-
-#let card(d) = {
-  set text(12pt)
-  let position_fmt = format-number(d.position)
-  let title_bg_color = rgb("#f9fafb00")
-
-  let grid_content = ()
-
-  let callers_data = json.decode(d.callers_data)
-
-  // Title
-  let alt = d.alternative
-
-  // TODO: add that in pandora_lib_variants
-  if d.callers == "Nanomonsv" and alt == "<INS>" {
-    alt = d.reference + callers_data.at(0).info.Nanomonsv.svinsseq
-  }
-
-  let title = d.contig + ":" + position_fmt + " " + d.reference + sym
-    .quote
-    .angle
-    .r
-    .single + truncate(alt, 30)
-
-  grid_content.push(
-    grid.cell(
-      fill: cr_colors.light_grey,
-      align: center,
-      block(width: 100%, title),
-    ),
-  )
-
-  // Consequences
-  if d.consequence != none {
-    let consequences = d.consequence.replace(",", ", ").replace(
-      "_",
-      " ",
-    ) + " " + emph(strong(d.gene))
-    grid_content.push(
-      grid.cell(fill: cr_colors.light_grey, align: center, consequences),
-    )
-  }
-
-  // hgvs_c
-  if d.hgvs_c != none {
-    grid_content.push(
-      grid.cell(fill: rgb("#fef08a"), align: center, truncate(d.hgvs_c, 50)),
-    )
-  }
-
-  // hgvs_c
-  if d.hgvs_p != none {
-    grid_content.push(
-      grid.cell(fill: rgb("#fecaca"), align: center, truncate(d.hgvs_p, 50)),
-    )
-  }
-
-  // Content
-  let content = ()
-  content.push(
-    badge-red("VAF: " + str(calc.round(d.m_vaf * 100, digits: 2)) + "%"),
-  )
-  // content.push(" ")
-
-  if d.cosmic_n != none {
-    content.push(badge-red("Cosmic: " + str(d.cosmic_n)))
-  }
-
-  if d.gnomad_af != none {
-    content.push(badge-blue("GnomAD: " + str(d.gnomad_af)))
-  }
-
-  let callers_contents = ()
-  for caller_data in callers_data {
-    let caller = ""
-    for (k, v) in caller_data.format {
-      caller = k
-    }
-    callers_contents.push(underline(caller) + ":")
-    if caller_data.qual != none {
-      callers_contents.push([
-        Qual: #caller_data.qual,
-      ])
-    }
-
-    callers_contents.push([
-      #(
-        format_json(caller_data.format.at(caller)),
-        format_json(caller_data.info.at(caller)),
-      ).filter(v => v != "").join(", ")
-    ])
-  }
-
-  content.push(
-    grid(
-      columns: 1,
-      inset: 0.5em,
-      ..callers_contents
-    ),
-  )
-
-  grid_content.push(grid.cell(fill: white, content.join(" ")))
-
-
-  block(
-    breakable: false,
-    width: 100%,
-    grid(
-      columns: 1,
-      inset: 0.5em,
-      stroke: cr_colors.dark_grey,
-      ..grid_content
-    ),
-  )
-}
-
-#let variants(path, interpretation: "PATHO") = {
-  let data = json(path)
-  let patho = data.filter(d => d.interpretation == interpretation)
-  for var in patho {
-    card(var)
-  }
-}
-
-#set heading(numbering: (..numbers) => {
-  if numbers.pos().len() >= 2 and numbers.pos().len() <= 3 {
-    numbering("1.1", ..numbers.pos().slice(1))
-  }
-})
-
-#heading(level: 1, outlined: false)[Whole Genome Sequencing Report]
-
-#outline(title: "Table of Contents", depth: 3)
-#pagebreak()
-== Identity
-#sys.inputs.id
-
-== Alignement
-#grid(
-  columns: (1fr, 1fr),
-  gutter: 3pt,
-  [
-    ==== Diagnostic sample
-    #set text(size: 11pt)
-    #reportBam(sys.inputs.base + "/diag/" + sys.inputs.id + "_diag_hs1_info.json")
-  ],
-  [
-    ==== MRD sample
-    #set text(size: 11pt)
-    #reportBam(sys.inputs.base + "/mrd/" + sys.inputs.id + "_mrd_hs1_info.json")
-    #set footnote(numbering: n => {
-      " "
-    })
-    #footnote[Values computed by #link("https://github.com/wdecoster/cramino")[cramino] v0.14.5
-    ]
-  ],
-)
-
-#pagebreak()
-=== Normalized read count by chromosome
-#[
-  #set text(size: 10pt)
-  #printReadCount(
-    sys.inputs.base + "/diag/" + sys.inputs.id + "_diag_hs1_info.json",
-    sys.inputs.base + "/mrd/" + sys.inputs.id + "_mrd_hs1_info.json",
-  )
-]
-
-=== Coverage by chromosome
-==== Proportion at given depth by chromosome
-#reportCoverage(sys.inputs.base + "/diag/report/data/scan/" + sys.inputs.id)
-#set footnote(numbering: n => {
-  " "
-})
-#footnote[Values computed by Pandora development version]
-
-== Variants
-=== Variants calling
-#pagebreak()
-==== VCF filters
-#pad(
-  top: 0.8cm,
-  align(
-    center,
-    scale(
-      x: 100%,
-      y: 100%,
-      reflow: true,
-      variantsFlow(sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_variants_stats.json"),
-    ),
-  ),
-)
-==== BAM filters
-#pad(
-  top: 0.8cm,
-  align(
-    center,
-    scale(
-      x: 100%,
-      y: 100%,
-      reflow: true,
-      bamFilter(sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_variants_stats.json"),
-    ),
-  ),
-)
-#pagebreak()
-
-=== Somatic variants
-==== Callers
-#v(0.5cm)
-#image(sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_barcharts_callers.svg")
-==== Consequences (VEP)
-#v(0.5cm)
-#image(sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_barcharts_consequences.svg")
-==== NCBI features
-#v(0.5cm)
-#image(sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_barcharts_ncbi.svg")
-#pagebreak()
-
-=== Selected Variants
-==== Pathogenics
-#variants(
-  sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_annot_variants.json",
-  interpretation: "PATHO",
-)
-
-==== Likely Pathogenics
-#variants(
-  sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_annot_variants.json",
-  interpretation: "PROBPATHO",
-)
-
-==== Variants of uncertain significance
-#variants(
-  sys.inputs.base + "/diag/report/data/" + sys.inputs.id + "_annot_variants.json",
-  interpretation: "US",
-)
-
-#pagebreak()
-== Conclusion
-#v(0.5cm)
-
-#cmarker.render(
-  read(sys.inputs.base + "/diag/report/" + sys.inputs.id + "_conclusion.md"),
-)

+ 3 - 2
Cargo.lock

@@ -200,9 +200,9 @@ dependencies = [
 
 [[package]]
 name = "flate2"
-version = "1.1.0"
+version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc"
+checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
 dependencies = [
  "crc32fast",
  "miniz_oxide",
@@ -384,6 +384,7 @@ dependencies = [
  "anyhow",
  "csv",
  "env_logger",
+ "flate2",
  "log",
  "ureq",
  "usvg",

+ 1 - 0
Cargo.toml

@@ -7,6 +7,7 @@ edition = "2021"
 anyhow = "1.0.89"
 csv = "1.3.1"
 env_logger = "0.11.8"
+flate2 = "1.1.2"
 log = "0.4.27"
 # typst = "0.13.0"
 # typst-as-lib = { version = "0.14.1", features = ["packages", "edition2024"] }

+ 138 - 0
notch_gff.gff3

@@ -0,0 +1,138 @@
+chr9	Liftoff	gene	148723532	148777907	.	-	.	ID=NOTCH1;gene_name=NOTCH1;db_xref=MIM:190198;description=notch receptor 1;gbkey=Gene;gene=NOTCH1;gene_biotype=protein_coding;gene_synonym=TAN1;coverage=1.0;sequence_ID=0.999;valid_ORFs=2;extra_copy_number=0;copy_num_ID=NOTCH1_0
+chr9	Liftoff	transcript	148723532	148775916	.	-	.	ID=XM_011518717.3;Parent=NOTCH1;db_xref=GeneID:4851;gbkey=mRNA;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;matches_ref_protein=True;valid_ORF=True;extra_copy_number=0
+chr9	Liftoff	exon	148723532	148726657	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=31;extra_copy_number=0
+chr9	Liftoff	exon	148727998	148728095	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=30;extra_copy_number=0
+chr9	Liftoff	exon	148728211	148728358	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=29;extra_copy_number=0
+chr9	Liftoff	exon	148729651	148729946	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=28;extra_copy_number=0
+chr9	Liftoff	exon	148730848	148731013	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=27;extra_copy_number=0
+chr9	Liftoff	exon	148731101	148731188	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=26;extra_copy_number=0
+chr9	Liftoff	exon	148731372	148731588	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=25;extra_copy_number=0
+chr9	Liftoff	exon	148732282	148732430	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=24;extra_copy_number=0
+chr9	Liftoff	exon	148733773	148734204	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=23;extra_copy_number=0
+chr9	Liftoff	exon	148734410	148734981	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=22;extra_copy_number=0
+chr9	Liftoff	exon	148735627	148735739	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=21;extra_copy_number=0
+chr9	Liftoff	exon	148735816	148736073	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=20;extra_copy_number=0
+chr9	Liftoff	exon	148736405	148736537	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=19;extra_copy_number=0
+chr9	Liftoff	exon	148737055	148737239	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=18;extra_copy_number=0
+chr9	Liftoff	exon	148737332	148737485	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=17;extra_copy_number=0
+chr9	Liftoff	exon	148737970	148738171	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=16;extra_copy_number=0
+chr9	Liftoff	exon	148738833	148739061	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=15;extra_copy_number=0
+chr9	Liftoff	exon	148739752	148739904	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=14;extra_copy_number=0
+chr9	Liftoff	exon	148740251	148740370	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=13;extra_copy_number=0
+chr9	Liftoff	exon	148742120	148742233	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=12;extra_copy_number=0
+chr9	Liftoff	exon	148742491	148742636	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=11;extra_copy_number=0
+chr9	Liftoff	exon	148743609	148743801	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=10;extra_copy_number=0
+chr9	Liftoff	exon	148744389	148744499	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=9;extra_copy_number=0
+chr9	Liftoff	exon	148744582	148744815	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=8;extra_copy_number=0
+chr9	Liftoff	exon	148745072	148745185	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=7;extra_copy_number=0
+chr9	Liftoff	exon	148746363	148746476	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=6;extra_copy_number=0
+chr9	Liftoff	exon	148746843	148747028	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=5;extra_copy_number=0
+chr9	Liftoff	exon	148747228	148747383	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=4;extra_copy_number=0
+chr9	Liftoff	exon	148747682	148747915	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=3;extra_copy_number=0
+chr9	Liftoff	exon	148748534	148748656	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=2;extra_copy_number=0
+chr9	Liftoff	exon	148775883	148775916	.	-	.	Parent=XM_011518717.3;db_xref=GeneID:4851;gene=NOTCH1;model_evidence=Supporting evidence includes similarity to: 6 mRNAs%2C 62 ESTs%2C 2 Proteins%2C 1556 long SRA reads%2C and 100%25 coverage of the annotated genomic feature by RNAseq alignments%2C including 13 samples with support for all annotated introns;product=notch receptor 1%2C transcript variant X1;transcript_biotype=mRNA;exon_number=1;extra_copy_number=0
+chr9	Liftoff	CDS	148725170	148726657	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=31;extra_copy_number=0
+chr9	Liftoff	CDS	148727998	148728095	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=30;extra_copy_number=0
+chr9	Liftoff	CDS	148728211	148728358	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=29;extra_copy_number=0
+chr9	Liftoff	CDS	148729651	148729946	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=28;extra_copy_number=0
+chr9	Liftoff	CDS	148730848	148731013	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=27;extra_copy_number=0
+chr9	Liftoff	CDS	148731101	148731188	.	-	1	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=26;extra_copy_number=0
+chr9	Liftoff	CDS	148731372	148731588	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=25;extra_copy_number=0
+chr9	Liftoff	CDS	148732282	148732430	.	-	1	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=24;extra_copy_number=0
+chr9	Liftoff	CDS	148733773	148734204	.	-	1	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=23;extra_copy_number=0
+chr9	Liftoff	CDS	148734410	148734981	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=22;extra_copy_number=0
+chr9	Liftoff	CDS	148735627	148735739	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=21;extra_copy_number=0
+chr9	Liftoff	CDS	148735816	148736073	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=20;extra_copy_number=0
+chr9	Liftoff	CDS	148736405	148736537	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=19;extra_copy_number=0
+chr9	Liftoff	CDS	148737055	148737239	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=18;extra_copy_number=0
+chr9	Liftoff	CDS	148737332	148737485	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=17;extra_copy_number=0
+chr9	Liftoff	CDS	148737970	148738171	.	-	1	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=16;extra_copy_number=0
+chr9	Liftoff	CDS	148738833	148739061	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=15;extra_copy_number=0
+chr9	Liftoff	CDS	148739752	148739904	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=14;extra_copy_number=0
+chr9	Liftoff	CDS	148740251	148740370	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=13;extra_copy_number=0
+chr9	Liftoff	CDS	148742120	148742233	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=12;extra_copy_number=0
+chr9	Liftoff	CDS	148742491	148742636	.	-	1	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=11;extra_copy_number=0
+chr9	Liftoff	CDS	148743609	148743801	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=10;extra_copy_number=0
+chr9	Liftoff	CDS	148744389	148744499	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=9;extra_copy_number=0
+chr9	Liftoff	CDS	148744582	148744815	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=8;extra_copy_number=0
+chr9	Liftoff	CDS	148745072	148745185	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=7;extra_copy_number=0
+chr9	Liftoff	CDS	148746363	148746476	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=6;extra_copy_number=0
+chr9	Liftoff	CDS	148746843	148747028	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=5;extra_copy_number=0
+chr9	Liftoff	CDS	148747228	148747383	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=4;extra_copy_number=0
+chr9	Liftoff	CDS	148747682	148747915	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=3;extra_copy_number=0
+chr9	Liftoff	CDS	148748534	148748656	.	-	2	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=2;extra_copy_number=0
+chr9	Liftoff	CDS	148775883	148775901	.	-	0	Parent=XM_011518717.3;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 isoform X1;protein_id=XP_011517019.2;exon_number=1;extra_copy_number=0
+chr9	Liftoff	transcript	148723532	148777907	.	-	.	ID=NM_017617.5;Parent=NOTCH1;db_xref=GeneID:4851;gbkey=mRNA;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;matches_ref_protein=True;valid_ORF=True;extra_copy_number=0
+chr9	Liftoff	exon	148723532	148726657	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=34;extra_copy_number=0
+chr9	Liftoff	exon	148727998	148728095	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=33;extra_copy_number=0
+chr9	Liftoff	exon	148728211	148728358	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=32;extra_copy_number=0
+chr9	Liftoff	exon	148729651	148729946	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=31;extra_copy_number=0
+chr9	Liftoff	exon	148730848	148731013	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=30;extra_copy_number=0
+chr9	Liftoff	exon	148731101	148731188	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=29;extra_copy_number=0
+chr9	Liftoff	exon	148731372	148731588	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=28;extra_copy_number=0
+chr9	Liftoff	exon	148732282	148732430	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=27;extra_copy_number=0
+chr9	Liftoff	exon	148733773	148734204	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=26;extra_copy_number=0
+chr9	Liftoff	exon	148734410	148734981	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=25;extra_copy_number=0
+chr9	Liftoff	exon	148735627	148735739	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=24;extra_copy_number=0
+chr9	Liftoff	exon	148735816	148736073	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=23;extra_copy_number=0
+chr9	Liftoff	exon	148736405	148736537	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=22;extra_copy_number=0
+chr9	Liftoff	exon	148737055	148737239	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=21;extra_copy_number=0
+chr9	Liftoff	exon	148737332	148737485	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=20;extra_copy_number=0
+chr9	Liftoff	exon	148737970	148738171	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=19;extra_copy_number=0
+chr9	Liftoff	exon	148738833	148739061	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=18;extra_copy_number=0
+chr9	Liftoff	exon	148739752	148739904	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=17;extra_copy_number=0
+chr9	Liftoff	exon	148740251	148740370	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=16;extra_copy_number=0
+chr9	Liftoff	exon	148742120	148742233	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=15;extra_copy_number=0
+chr9	Liftoff	exon	148742491	148742636	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=14;extra_copy_number=0
+chr9	Liftoff	exon	148743609	148743801	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=13;extra_copy_number=0
+chr9	Liftoff	exon	148744389	148744499	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=12;extra_copy_number=0
+chr9	Liftoff	exon	148744582	148744815	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=11;extra_copy_number=0
+chr9	Liftoff	exon	148745072	148745185	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=10;extra_copy_number=0
+chr9	Liftoff	exon	148746363	148746476	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=9;extra_copy_number=0
+chr9	Liftoff	exon	148746843	148747028	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=8;extra_copy_number=0
+chr9	Liftoff	exon	148747228	148747383	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=7;extra_copy_number=0
+chr9	Liftoff	exon	148747682	148747915	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=6;extra_copy_number=0
+chr9	Liftoff	exon	148748534	148748656	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=5;extra_copy_number=0
+chr9	Liftoff	exon	148751940	148752278	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=4;extra_copy_number=0
+chr9	Liftoff	exon	148752807	148753069	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=3;extra_copy_number=0
+chr9	Liftoff	exon	148775883	148775961	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=2;extra_copy_number=0
+chr9	Liftoff	exon	148777585	148777907	.	-	.	Parent=NM_017617.5;db_xref=GeneID:4851;gene=NOTCH1;product=notch receptor 1;tag=MANE Select;transcript_biotype=mRNA;exon_number=1;extra_copy_number=0
+chr9	Liftoff	CDS	148725170	148726657	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=34;extra_copy_number=0
+chr9	Liftoff	CDS	148727998	148728095	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=33;extra_copy_number=0
+chr9	Liftoff	CDS	148728211	148728358	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=32;extra_copy_number=0
+chr9	Liftoff	CDS	148729651	148729946	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=31;extra_copy_number=0
+chr9	Liftoff	CDS	148730848	148731013	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=30;extra_copy_number=0
+chr9	Liftoff	CDS	148731101	148731188	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=29;extra_copy_number=0
+chr9	Liftoff	CDS	148731372	148731588	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=28;extra_copy_number=0
+chr9	Liftoff	CDS	148732282	148732430	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=27;extra_copy_number=0
+chr9	Liftoff	CDS	148733773	148734204	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=26;extra_copy_number=0
+chr9	Liftoff	CDS	148734410	148734981	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=25;extra_copy_number=0
+chr9	Liftoff	CDS	148735627	148735739	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=24;extra_copy_number=0
+chr9	Liftoff	CDS	148735816	148736073	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=23;extra_copy_number=0
+chr9	Liftoff	CDS	148736405	148736537	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=22;extra_copy_number=0
+chr9	Liftoff	CDS	148737055	148737239	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=21;extra_copy_number=0
+chr9	Liftoff	CDS	148737332	148737485	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=20;extra_copy_number=0
+chr9	Liftoff	CDS	148737970	148738171	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=19;extra_copy_number=0
+chr9	Liftoff	CDS	148738833	148739061	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=18;extra_copy_number=0
+chr9	Liftoff	CDS	148739752	148739904	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=17;extra_copy_number=0
+chr9	Liftoff	CDS	148740251	148740370	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=16;extra_copy_number=0
+chr9	Liftoff	CDS	148742120	148742233	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=15;extra_copy_number=0
+chr9	Liftoff	CDS	148742491	148742636	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=14;extra_copy_number=0
+chr9	Liftoff	CDS	148743609	148743801	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=13;extra_copy_number=0
+chr9	Liftoff	CDS	148744389	148744499	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=12;extra_copy_number=0
+chr9	Liftoff	CDS	148744582	148744815	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=11;extra_copy_number=0
+chr9	Liftoff	CDS	148745072	148745185	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=10;extra_copy_number=0
+chr9	Liftoff	CDS	148746363	148746476	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=9;extra_copy_number=0
+chr9	Liftoff	CDS	148746843	148747028	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=8;extra_copy_number=0
+chr9	Liftoff	CDS	148747228	148747383	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=7;extra_copy_number=0
+chr9	Liftoff	CDS	148747682	148747915	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=6;extra_copy_number=0
+chr9	Liftoff	CDS	148748534	148748656	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=5;extra_copy_number=0
+chr9	Liftoff	CDS	148751940	148752278	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=4;extra_copy_number=0
+chr9	Liftoff	CDS	148752807	148753069	.	-	1	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=3;extra_copy_number=0
+chr9	Liftoff	CDS	148775883	148775961	.	-	2	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=2;extra_copy_number=0
+chr9	Liftoff	CDS	148777585	148777645	.	-	0	Parent=NM_017617.5;db_xref=GeneID:4851;gbkey=CDS;gene=NOTCH1;product=neurogenic locus notch homolog protein 1 preproprotein;protein_id=NP_060087.3;tag=MANE Select;exon_number=1;extra_copy_number=0
+chr9	Liftoff	gene	148779701	148781603	.	+	.	ID=NALT1;gene_name=NALT1;db_xref=HGNC:HGNC:51192;description=NOTCH1 associated lncRNA in T cell acute lymphoblastic leukemia 1;gbkey=Gene;gene=NALT1;gene_biotype=lncRNA;gene_synonym=TCONS_l2_00029132;coverage=1.0;sequence_ID=1.0;extra_copy_number=0;copy_num_ID=NALT1_0
+chr9	Liftoff	transcript	148779701	148781603	.	+	.	ID=NR_121577.1;Parent=NALT1;db_xref=GeneID:101928483;gbkey=ncRNA;gene=NALT1;product=NOTCH1 associated lncRNA in T cell acute lymphoblastic leukemia 1;transcript_biotype=lnc_RNA;extra_copy_number=0
+chr9	Liftoff	exon	148779701	148779960	.	+	.	Parent=NR_121577.1;db_xref=GeneID:101928483;gene=NALT1;product=NOTCH1 associated lncRNA in T cell acute lymphoblastic leukemia 1;transcript_biotype=lnc_RNA;exon_number=1;extra_copy_number=0
+chr9	Liftoff	exon	148780991	148781077	.	+	.	Parent=NR_121577.1;db_xref=GeneID:101928483;gene=NALT1;product=NOTCH1 associated lncRNA in T cell acute lymphoblastic leukemia 1;transcript_biotype=lnc_RNA;exon_number=2;extra_copy_number=0
+chr9	Liftoff	exon	148781405	148781603	.	+	.	Parent=NR_121577.1;db_xref=GeneID:101928483;gene=NALT1;product=NOTCH1 associated lncRNA in T cell acute lymphoblastic leukemia 1;transcript_biotype=lnc_RNA;exon_number=3;extra_copy_number=0

+ 114 - 26
src/circ.rs

@@ -1,5 +1,6 @@
 use std::{
-    collections::HashMap,
+    cmp::Reverse,
+    collections::{BinaryHeap, HashMap, HashSet},
     fs::File,
     io::{BufRead, BufReader, Write},
 };
@@ -860,31 +861,35 @@ impl Circos {
     }
 }
 
-// #[derive(Debug)]
-// pub struct GeneLabel {
-//     pub gene: String,
-//     pub chr: String,
-//     pub pos: u32,
-// }
-//
-// pub fn read_gene_labels(path: &str) -> anyhow::Result<Vec<GeneLabel>> {
-//     let mut rdr = ReaderBuilder::new().delimiter(b'\t').from_path(path)?;
-//     let mut records = Vec::new();
-//
-//     for result in rdr.records() {
-//         let record = result?;
-//         let gene = record[0].to_string();
-//         let chr = record[1].to_string();
-//         let pos: u32 = record[2].parse()?;
-//         records.push(GeneLabel { gene, chr, pos });
-//     }
-//     Ok(records)
-// }
-//
-// fn avg_angle_short_way(a: f64, b: f64) -> f64 {
-//     // Vector average: works across the -π/π wrap and picks the short way
-//     (a.sin() + b.sin()).atan2(a.cos() + b.cos())
-// }
+#[derive(Debug)]
+pub struct CircosLabel {
+    pub label: String,
+    pub chr: String,
+    pub pos: u32,
+}
+
+pub fn read_gene_labels(path: &str) -> anyhow::Result<Vec<CircosLabel>> {
+    let mut rdr = ReaderBuilder::new().delimiter(b'\t').from_path(path)?;
+    let mut records = Vec::new();
+
+    for result in rdr.records() {
+        let record = result?;
+        let gene = record[0].to_string();
+        let chr = record[1].to_string();
+        let pos: u32 = record[2].parse()?;
+        records.push(CircosLabel {
+            label: gene,
+            chr,
+            pos,
+        });
+    }
+    Ok(records)
+}
+
+fn avg_angle_short_way(a: f64, b: f64) -> f64 {
+    // Vector average: works across the -π/π wrap and picks the short way
+    (a.sin() + b.sin()).atan2(a.cos() + b.cos())
+}
 
 #[derive(Debug)]
 pub struct TranslocData {
@@ -923,3 +928,86 @@ pub fn read_translocs(path: &str) -> anyhow::Result<Vec<TranslocData>> {
     }
     Ok(records)
 }
+
+#[derive(Debug, Clone, Eq, PartialEq, Hash)]
+pub struct ArcData {
+    pub chr: String,
+    pub start: u32,
+    pub end: u32,
+}
+
+pub fn read_arcs(path: &str) -> anyhow::Result<Vec<ArcData>> {
+    let mut rdr = ReaderBuilder::new().delimiter(b'\t').from_path(path)?;
+    let mut records = Vec::new();
+
+    for result in rdr.records() {
+        let record = result?;
+        let chr = record[0].to_string();
+        let start: u32 = record[1].parse()?;
+        let end: u32 = record[2].parse()?;
+
+        records.push(ArcData { chr, start, end });
+    }
+    Ok(records)
+}
+
+/// Half-open intervals [start, end). For CLOSED intervals, change the `<=` test to `<`.
+
+
+#[inline]
+fn len(a: &ArcData) -> u32 { a.end - a.start } // half-open; for closed use +1
+
+/// Minimal tracks per chromosome, first-fit (left-justified),
+/// and for intervals with the same `start`, place longer ones first.
+pub fn pack_tracks(mut arcs: Vec<ArcData>) -> Vec<Vec<ArcData>> {
+    // Sort by chromosome, then by start asc, and for equal start by length desc, then end asc
+    arcs.sort_by(|a, b| {
+        a.chr
+            .cmp(&b.chr)
+            .then_with(|| a.start.cmp(&b.start))
+            .then_with(|| len(b).cmp(&len(a))) // longer first when same start
+            .then_with(|| a.end.cmp(&b.end))
+    });
+
+    let mut tracks: Vec<Vec<ArcData>> = Vec::new();
+    let mut i = 0;
+
+    while i < arcs.len() {
+        let chr = arcs[i].chr.clone();
+        let start_i = i;
+        while i < arcs.len() && arcs[i].chr == chr { i += 1; }
+        let slice = &arcs[start_i..i];
+
+        let local = pack_one_chr_first_fit(slice);
+
+        if tracks.len() < local.len() {
+            tracks.resize_with(local.len(), Vec::new);
+        }
+        for (j, v) in local.into_iter().enumerate() {
+            tracks[j].extend(v);
+        }
+    }
+
+    tracks
+}
+
+/// First-fit per chromosome: always use the smallest compatible track index.
+/// Compatibility rule is half-open: previous_end <= start. For closed intervals, use `<`.
+fn pack_one_chr_first_fit(slice: &[ArcData]) -> Vec<Vec<ArcData>> {
+    let mut local_tracks: Vec<Vec<ArcData>> = Vec::new();
+    let mut last_end: Vec<u32> = Vec::new(); // end per track
+
+    'next: for arc in slice.iter().cloned() {
+        for idx in 0..last_end.len() {
+            if last_end[idx] <= arc.start {
+                local_tracks[idx].push(arc.clone());
+                last_end[idx] = arc.end;
+                continue 'next;
+            }
+        }
+        // need a new track
+        local_tracks.push(vec![arc.clone()]);
+        last_end.push(arc.end);
+    }
+    local_tracks
+}

+ 185 - 0
src/gene_model.rs

@@ -0,0 +1,185 @@
+use std::{collections::HashMap, fs::File, io::{BufRead, BufReader}, path::Path};
+
+use anyhow::{anyhow, Context, Result};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Strand {
+    Plus,
+    Minus,
+    Unknown,
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct Interval {
+    pub start: u64, // 1-based inclusive
+    pub end: u64,   // 1-based inclusive
+}
+
+#[derive(Debug)]
+pub struct GeneModel {
+    pub gene_id: String,
+    pub chrom: String,
+    pub strand: Strand,
+    pub start: u64,
+    pub end: u64,
+    pub exons: Vec<Interval>, // merged, sorted, non-overlapping (genomic coords)
+}
+
+pub fn load_gene_model_from_gff3<P: AsRef<Path>>(
+    path: P,
+    transcript_id: &str,
+) -> Result<GeneModel> {
+    let f = File::open(&path)
+        .with_context(|| format!("Failed to open GFF3 at {}", path.as_ref().display()))?;
+    let reader = BufReader::new(f);
+
+    let mut chrom: Option<String> = None;
+    let mut strand: Option<Strand> = None;
+    let mut tx_start: Option<u64> = None;
+    let mut tx_end: Option<u64> = None;
+
+    let mut exon_intervals: Vec<Interval> = Vec::new();
+    let mut gene_name: Option<String> = None;
+
+    for (lineno, line) in reader.lines().enumerate() {
+        let line = line.with_context(|| format!("Error reading line {}", lineno + 1))?;
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+        let mut fields = line.split('\t');
+        let seqid = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (seqid) at line {}", lineno + 1))?;
+        let _source = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (source) at line {}", lineno + 1))?;
+        let ftype = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (type) at line {}", lineno + 1))?;
+        let start_s = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (start) at line {}", lineno + 1))?;
+        let end_s = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (end) at line {}", lineno + 1))?;
+        let _score = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (score) at line {}", lineno + 1))?;
+        let strand_s = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (strand) at line {}", lineno + 1))?;
+        let _phase = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (phase) at line {}", lineno + 1))?;
+        let attrs_s = fields
+            .next()
+            .ok_or_else(|| anyhow!("Malformed GFF3 (attributes) at line {}", lineno + 1))?;
+
+        let start: u64 = start_s
+            .parse()
+            .with_context(|| format!("Invalid start at line {}: {}", lineno + 1, start_s))?;
+        let end: u64 = end_s
+            .parse()
+            .with_context(|| format!("Invalid end at line {}: {}", lineno + 1, end_s))?;
+        let s = match strand_s {
+            "+" => Strand::Plus,
+            "-" => Strand::Minus,
+            "." => Strand::Unknown,
+            _ => Strand::Unknown,
+        };
+
+        let attrs = parse_attrs(attrs_s);
+
+        // Capture transcript row
+        if ftype.eq_ignore_ascii_case("transcript") {
+            if let Some(id) = attrs.get("ID") {
+                if id == transcript_id {
+                    chrom = Some(seqid.to_string());
+                    strand = Some(s);
+                    tx_start = Some(start);
+                    tx_end = Some(end);
+                    if let Some(gene) = attrs.get("gene") {
+                        gene_name = Some(gene.clone());
+                    }
+                }
+            }
+        }
+
+        // Capture exons of this transcript
+        if ftype.eq_ignore_ascii_case("exon") {
+            if let Some(parent) = attrs.get("Parent") {
+                if parent == transcript_id {
+                    if chrom.is_none() {
+                        chrom = Some(seqid.to_string());
+                    }
+                    if strand.is_none() {
+                        strand = Some(s);
+                    }
+                    exon_intervals.push(Interval { start, end });
+                    // If no explicit transcript record, derive span from exons
+                    tx_start = Some(tx_start.map_or(start, |v| v.min(start)));
+                    tx_end = Some(tx_end.map_or(end, |v| v.max(end)));
+                    if gene_name.is_none() {
+                        if let Some(gene) = attrs.get("gene") {
+                            gene_name = Some(gene.clone());
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    if exon_intervals.is_empty() {
+        return Err(anyhow!(
+            "No exons found for transcript ID '{}' in {}",
+            transcript_id,
+            path.as_ref().display()
+        ));
+    }
+
+    // Merge/sort exons
+    exon_intervals.sort_by_key(|iv| (iv.start, iv.end));
+    let mut merged: Vec<Interval> = Vec::with_capacity(exon_intervals.len());
+    for iv in exon_intervals {
+        if let Some(last) = merged.last_mut() {
+            if iv.start <= last.end.saturating_add(1) {
+                if iv.end > last.end {
+                    last.end = iv.end;
+                }
+            } else {
+                merged.push(iv);
+            }
+        } else {
+            merged.push(iv);
+        }
+    }
+
+    let gm = GeneModel {
+        gene_id: gene_name.unwrap_or_else(|| transcript_id.to_string()),
+        chrom: chrom.ok_or_else(|| anyhow!("Chromosome not determined for '{}'", transcript_id))?,
+        strand: strand.unwrap_or(Strand::Unknown),
+        start: tx_start.ok_or_else(|| anyhow!("Start not determined for '{}'", transcript_id))?,
+        end: tx_end.ok_or_else(|| anyhow!("End not determined for '{}'", transcript_id))?,
+        exons: merged,
+    };
+
+    Ok(gm)
+}
+
+fn parse_attrs(attr_field: &str) -> HashMap<String, String> {
+    let mut m = HashMap::new();
+    for kv in attr_field.split(';') {
+        if kv.is_empty() {
+            continue;
+        }
+        let mut it = kv.splitn(2, '=');
+        let k = it.next().unwrap().trim();
+        if let Some(v) = it.next() {
+            // GFF3 allows percent-encoding; here we keep it simple.
+            m.insert(k.to_string(), v.trim().to_string());
+        } else {
+            m.insert(k.to_string(), String::new());
+        }
+    }
+    m
+}

+ 429 - 70
src/lib.rs

@@ -1,19 +1,26 @@
 pub mod circ;
 pub mod circos;
 pub mod cytoband;
+pub mod gene_model;
+pub mod loliplot;
+pub mod loliplot_parser;
+pub mod mutation;
 pub mod report;
 pub mod theme;
 
 #[cfg(test)]
 mod tests {
-    use std::collections::HashMap;
-
-    use cytoband::{read_ranges, svg_chromosome, AdditionalRect, Lollipop, RectPosition};
+    use crate::{
+        gene_model::load_gene_model_from_gff3, loliplot::{Lollipop, SvgConfig}, loliplot_parser::{parse_domains_by_gene_name, Rgb}, mutation::{dels_to_ranges, mutations_to_lollipops, parse_kind_colors, parse_mutations}
+    };
+    use cytoband::{read_ranges, svg_chromosome, AdditionalRect, RectPosition};
     use report::compile_typst_report;
+    use std::{collections::HashMap, fs::File, path::Path};
 
     use crate::{
-        circ::read_translocs,
+        circ::{pack_tracks, read_arcs, read_gene_labels, read_translocs},
         circos::{classic_caps, no_caps, AngleRange, Circos, LabelMode},
+        loliplot::gene_model_to_svg,
     };
 
     use super::*;
@@ -60,13 +67,13 @@ mod tests {
         ];
 
         let lollipops = vec![
-            Lollipop {
+            cytoband::Lollipop {
                 position: 5000000,
                 above: true,
                 letter: 'A',
                 color: String::from("red"),
             },
-            Lollipop {
+            cytoband::Lollipop {
                 position: 20000000,
                 above: false,
                 letter: 'B',
@@ -137,7 +144,7 @@ mod tests {
     }
 
     #[test]
-    fn circos() -> anyhow::Result<()> {
+    fn translocations() -> anyhow::Result<()> {
         let mut circos = circ::Circos::new();
         let r = 1050.0;
         let cx = 0.0;
@@ -163,18 +170,6 @@ mod tests {
             chr_idx[c] = name;
         }
 
-        // for c in 1..=23 {
-        //     let name = if c <= 22 { format!("chr{c}") } else if c == 23 { "chrX".to_string() };
-        //
-        //     let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", &name)?;
-        //
-        //     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);
-        // }
-
         for (set_idx, name) in chr_idx.iter().enumerate() {
             println!("{name}");
             let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", name)?;
@@ -185,19 +180,6 @@ mod tests {
             band_track.add_arcs_for_set(set_idx, r * 0.955555, r, circ::classic_caps);
         }
 
-        // Create two tracks with reference genome size
-        // 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.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);
-        // }
-
         let r2 = r + 100.0;
         let r3 = r + 200.0;
 
@@ -274,7 +256,7 @@ mod tests {
                         start: trl.right_pos,
                         end: None,
                     },
-                    r - 100.0,
+                    r - 50.0,
                     0.3,
                     "red".to_string(),
                     2,
@@ -284,6 +266,7 @@ mod tests {
                     .entry(trl.left_gene)
                     .or_default()
                     .push((trl.left_chr.to_string(), trl.left_pos));
+
                 translocs_labels
                     .entry(trl.right_gene)
                     .or_default()
@@ -306,10 +289,12 @@ mod tests {
                 let n_alt = positions.len();
                 let pos = (positions.iter().map(|(_, p)| *p as f64).sum::<f64>() / n_alt as f64)
                     .round() as u32;
+
                 chr_idx
-                        .iter()
-                        .enumerate()
-                        .find_map(|(i, name)| if name == chr { Some(i) } else { None }).map(|set_idx| (gene.to_string(), set_idx, pos, n_alt))
+                    .iter()
+                    .enumerate()
+                    .find_map(|(i, name)| if name == chr { Some(i) } else { None })
+                    .map(|set_idx| (gene.to_string(), set_idx, pos, n_alt))
             })
             .collect();
 
@@ -334,8 +319,6 @@ mod tests {
             }
         }
 
-        println!("{labels_pos:?}");
-
         for (label, set_idx, pos, n, bump) in labels_pos {
             annot_track.add_label(
                 circ::Attach::Data {
@@ -365,7 +348,7 @@ mod tests {
                 r + 10.0,
                 r2 - 70.0,
                 "black".to_string(),
-                1
+                1,
             );
         }
 
@@ -378,8 +361,8 @@ mod tests {
     }
 
     #[test]
-    fn wg() -> anyhow::Result<()> {
-        test_init();
+    fn deletions() -> anyhow::Result<()> {
+        let mut circos = circ::Circos::new();
         let r = 1050.0;
         let cx = 0.0;
         let cy = 0.0;
@@ -387,50 +370,426 @@ mod tests {
         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(
+        let mut band_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
+        let mut annot_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
+
+        // let mut chr_to_idx = HashMap::new(:);
+        let mut chr_idx = vec![String::new(); 23];
+
+        for c in 0..23 {
+            let name = if c <= 21 {
+                format!("chr{}", c + 1)
+            } else if c == 22 {
+                "chrX".to_string()
+            } else {
+                panic!("unexpected");
+            };
+            chr_idx[c] = name;
+        }
+
+        let ri_chr = r * 0.955555;
+        for (set_idx, name) in chr_idx.iter().enumerate() {
+            println!("{name}");
+            let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", name)?;
+
+            band_track.add_range_set(name.to_string(), ranges.clone());
+            annot_track.add_range_set(name.to_string(), ranges);
+
+            band_track.add_arcs_for_set(set_idx, ri_chr, r, circ::classic_caps);
+        }
+
+        let r2 = r + 100.0;
+        let r3 = r + 200.0;
+
+        // Adding chromosome names labels
+        for (set_idx, name) in chr_idx.iter().enumerate() {
+            let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", name)?;
+
+            let p = ranges
+                .iter()
+                .find_map(|r| {
+                    if r.category.as_str() == "acen" {
+                        Some(r.start + r.end.saturating_sub(r.start))
+                    } else {
+                        None
+                    }
+                })
+                .unwrap_or(0);
+
+            annot_track.add_label(
                 circ::Attach::Data {
-                    set_idx: 0,
-                    start: 55000000,
+                    set_idx,
+                    start: p,
                     end: None,
                 },
+                r3,
+                circ::LabelMode::Perimeter,
+                28.0,
+                Some("white".to_string()),
+                0.85,
+                name.to_string(),
+            );
+            annot_track.add_arc(
+                circ::Attach::Data {
+                    set_idx,
+                    start: p,
+                    end: Some(p),
+                },
+                r + 10.0,
+                r3 - 35.0,
+                "gpos50".to_string(),
+                circ::CapStyle::None,
+            );
+        }
+
+        let arcs = read_arcs("/data/tmp_deletions.tsv")?;
+
+        println!("arcs loaded: {}", arcs.len());
+        let packed = pack_tracks(arcs);
+        println!("tracks: {}", packed.len());
+
+        println!("{packed:?}");
+
+        let r_del_track = ri_chr - 25.0;
+        let del_track_height = 25.0;
+
+        for (i, track) in packed.iter().enumerate() {
+            for arc in track {
+                if let Some(set_idx) = chr_idx.iter().enumerate().find_map(|(id, name)| {
+                    if arc.chr.as_str() == name {
+                        Some(id)
+                    } else {
+                        None
+                    }
+                }) {
+                    let ri = r_del_track - (i as f64 * del_track_height);
+
+                    annot_track.add_arc(
+                        circ::Attach::Data {
+                            set_idx,
+                            start: arc.start,
+                            end: Some(arc.end),
+                        },
+                        ri,
+                        ri + del_track_height - 5.0,
+                        "blue".to_string(),
+                        circ::CapStyle::None,
+                    );
+                } else {
+                    println!("Failde to find: {}", arc.chr);
+                }
+            }
+        }
+
+        let mut labels: Vec<(String, usize, u32)> = read_gene_labels("/data/tmp_labels.tsv")?
+            .into_iter()
+            .filter_map(|label| {
+                let set_idx = chr_idx.iter().enumerate().find_map(|(id, name)| {
+                    if label.chr.as_str() == name {
+                        Some(id)
+                    } else {
+                        None
+                    }
+                })?;
+                Some((label.label, set_idx, label.pos))
+            })
+            .collect();
+        labels.sort_by(|a, b| (a.1, a.2).cmp(&(b.1, b.2)));
+
+        let mut labels_pos = Vec::new();
+        let dist_thre = 10_000_000;
+        for (label, id, pos) in labels {
+            let (_last_label, last_id, last_pos, last_bump) = if labels_pos.is_empty() {
+                labels_pos.push((label, id, pos, None));
+                continue;
+            } else {
+                labels_pos.last().unwrap()
+            };
+
+            let last_ref_pos = last_bump.unwrap_or(*last_pos);
+
+            if *last_id == id && pos.saturating_sub(last_ref_pos) < dist_thre {
+                labels_pos.push((label, id, pos, Some(last_ref_pos + dist_thre)));
+            } else {
+                labels_pos.push((label, id, pos, None));
+            }
+        }
+
+        for (label, set_idx, pos, bump) in labels_pos {
+            annot_track.add_label(
                 circ::Attach::Data {
-                    set_idx: 3,
-                    start: 1000000,
+                    set_idx,
+                    start: bump.unwrap_or(pos),
                     end: None,
                 },
-                950.0,
-                -580.0,
-                "red".to_string(),
-                200,
+                r2,
+                circ::LabelMode::Perimeter,
+                18.0,
+                Some("yellow".to_string()),
+                0.95,
+                label,
             );
-            t.add_cord(
+
+            annot_track.add_path(
                 circ::Attach::Data {
-                    set_idx: 0,
-                    start: 55000000,
+                    set_idx,
+                    start: pos,
                     end: None,
                 },
                 circ::Attach::Data {
-                    set_idx: 1,
-                    start: 1000000,
+                    set_idx,
+                    start: bump.unwrap_or(pos),
                     end: None,
                 },
-                950.0,
-                80.0,
-                "red".to_string(),
-                2,
+                r + 10.0,
+                r2 - 70.0,
+                "black".to_string(),
+                1,
             );
         }
-        c.save_to_file("/data/benti.svg", 100.0)?;
+
+        circos.add_track(band_track);
+        circos.add_track(annot_track);
+
+        circos.save_to_file("/data/deletions.svg", 100.0)?;
+
+        Ok(())
+    }
+
+    #[test]
+    fn inversions() -> anyhow::Result<()> {
+        let mut circos = circ::Circos::new();
+        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 band_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
+        let mut annot_track = circ::Track::new(cx, cy, angle_start, angle_end, gap);
+
+        // let mut chr_to_idx = HashMap::new(:);
+        let mut chr_idx = vec![String::new(); 23];
+
+        for c in 0..23 {
+            let name = if c <= 21 {
+                format!("chr{}", c + 1)
+            } else if c == 22 {
+                "chrX".to_string()
+            } else {
+                panic!("unexpected");
+            };
+            chr_idx[c] = name;
+        }
+
+        let ri_chr = r * 0.955555;
+        for (set_idx, name) in chr_idx.iter().enumerate() {
+            println!("{name}");
+            let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", name)?;
+
+            band_track.add_range_set(name.to_string(), ranges.clone());
+            annot_track.add_range_set(name.to_string(), ranges);
+
+            band_track.add_arcs_for_set(set_idx, ri_chr, r, circ::classic_caps);
+        }
+
+        let r2 = r + 100.0;
+        let r3 = r + 200.0;
+
+        // Adding chromosome names labels
+        for (set_idx, name) in chr_idx.iter().enumerate() {
+            let ranges = read_ranges("/data/ref/hs1/cytoBandMapped.bed", name)?;
+
+            let p = ranges
+                .iter()
+                .find_map(|r| {
+                    if r.category.as_str() == "acen" {
+                        Some(r.start + r.end.saturating_sub(r.start))
+                    } else {
+                        None
+                    }
+                })
+                .unwrap_or(0);
+
+            annot_track.add_label(
+                circ::Attach::Data {
+                    set_idx,
+                    start: p,
+                    end: None,
+                },
+                r3,
+                circ::LabelMode::Perimeter,
+                28.0,
+                Some("white".to_string()),
+                0.85,
+                name.to_string(),
+            );
+            annot_track.add_arc(
+                circ::Attach::Data {
+                    set_idx,
+                    start: p,
+                    end: Some(p),
+                },
+                r + 10.0,
+                r3 - 35.0,
+                "gpos50".to_string(),
+                circ::CapStyle::None,
+            );
+        }
+
+        let translocs = read_translocs("/data/tmp_inversions.tsv")?;
+        for trl in translocs {
+            if let (Some(left_set_idx), Some(right_set_idx)) = (
+                chr_idx.iter().enumerate().find_map(|(i, name)| {
+                    if name == &trl.left_chr {
+                        Some(i)
+                    } else {
+                        None
+                    }
+                }),
+                chr_idx.iter().enumerate().find_map(|(i, name)| {
+                    if name == &trl.right_chr {
+                        Some(i)
+                    } else {
+                        None
+                    }
+                }),
+            ) {
+                annot_track.add_cord(
+                    circ::Attach::Data {
+                        set_idx: left_set_idx,
+                        start: trl.left_pos,
+                        end: None,
+                    },
+                    circ::Attach::Data {
+                        set_idx: right_set_idx,
+                        start: trl.right_pos,
+                        end: None,
+                    },
+                    r - 50.0,
+                    0.3,
+                    "red".to_string(),
+                    2,
+                );
+            }
+        }
+
+        let mut labels: Vec<(String, usize, u32)> = read_gene_labels("/data/tmp_inv_labels.tsv")?
+            .into_iter()
+            .filter_map(|label| {
+                let set_idx = chr_idx.iter().enumerate().find_map(|(id, name)| {
+                    if label.chr.as_str() == name {
+                        Some(id)
+                    } else {
+                        None
+                    }
+                })?;
+                Some((label.label, set_idx, label.pos))
+            })
+            .collect();
+        labels.sort_by(|a, b| (a.1, a.2).cmp(&(b.1, b.2)));
+
+        let mut labels_pos = Vec::new();
+        let dist_thre = 10_000_000;
+        for (label, id, pos) in labels {
+            let (_last_label, last_id, last_pos, last_bump) = if labels_pos.is_empty() {
+                labels_pos.push((label, id, pos, None));
+                continue;
+            } else {
+                labels_pos.last().unwrap()
+            };
+
+            let last_ref_pos = last_bump.unwrap_or(*last_pos);
+
+            if *last_id == id && pos.saturating_sub(last_ref_pos) < dist_thre {
+                labels_pos.push((label, id, pos, Some(last_ref_pos + dist_thre)));
+            } else {
+                labels_pos.push((label, id, pos, None));
+            }
+        }
+
+        for (label, set_idx, pos, bump) in labels_pos {
+            annot_track.add_label(
+                circ::Attach::Data {
+                    set_idx,
+                    start: bump.unwrap_or(pos),
+                    end: None,
+                },
+                r2,
+                circ::LabelMode::Perimeter,
+                18.0,
+                Some("yellow".to_string()),
+                0.95,
+                label,
+            );
+
+            annot_track.add_path(
+                circ::Attach::Data {
+                    set_idx,
+                    start: pos,
+                    end: None,
+                },
+                circ::Attach::Data {
+                    set_idx,
+                    start: bump.unwrap_or(pos),
+                    end: None,
+                },
+                r + 10.0,
+                r2 - 70.0,
+                "black".to_string(),
+                1,
+            );
+        }
+
+        circos.add_track(band_track);
+        circos.add_track(annot_track);
+
+        circos.save_to_file("/data/inversions.svg", 100.0)?;
+
+        Ok(())
+    }
+
+    #[test]
+    fn lol() -> anyhow::Result<()> {
+        let id = "NM_017617.5"; // NOTCH1
+                                // let id = "NM_000314.8"; // PTEN
+                                // let id = "NM_001015877.2"; // PHF6
+                                // let id = "NM_001005361.3"; // DNM2
+        let mut pten_cfg = SvgConfig::default();
+        pten_cfg.range_max_lanes = 20;
+
+        // let gff3 = Path::new("./notch_gff.gff3");
+        let gff3 = Path::new("/data/ref/hs1/chm13v2.0_RefSeq_Liftoff_v5.1.gff3");
+        let r = load_gene_model_from_gff3(gff3, id)?;
+        let gene_name = r.gene_id.clone();
+
+        let domains = parse_domains_by_gene_name(
+            "/data/ref/hs1/my_protein_domains_hs1_lifted_v2.4.0_sorted.gff3",
+            &gene_name,
+        )?;
+        let colors = parse_kind_colors("/data/colors.tsv")?;
+        let muts = parse_mutations(format!("/data/genes_results/{gene_name}_muts.tsv"))?;
+        println!("{}", muts.len());
+        let dels = dels_to_ranges(
+            format!("/data/genes_results/{gene_name}_dels.tsv"),
+            &colors,
+            loliplot::LollipopSide::Bottom, // or Bottom — your choice for this track
+        )?;
+
+        let lols = mutations_to_lollipops(&muts, &colors);
+
+        println!("{:?}", lols);
+        println!("{}", lols.len());
+        lols.iter()
+            .filter(|l| l.kind == "SNV_MISS")
+            .for_each(|l| println!("{l:?}"));
+        let svg = gene_model_to_svg(&r, &domains, &lols, &dels, &pten_cfg); // write svg to file
+        std::fs::write(
+            format!("/data/genes_results/{gene_name}_struct_muts.svg"),
+            svg,
+        )?;
+
         Ok(())
     }
 

+ 1321 - 0
src/loliplot.rs

@@ -0,0 +1,1321 @@
+// gene_svg.rs
+//
+// Overview
+// --------
+// Render a gene/transcript model as an SVG string with:
+//   • Exons scaled to genomic length (rectangles)
+//   • Introns at fixed pixel width (connectors)
+//   • Protein domains drawn inside the exon band (to scale, optional labels)
+//   • Lollipop pins for point-like events (e.g., variants), with automatic multi-lane layout
+//   • Interval “ranges” tracks above/below the exon band, with lane packing
+//   • Orientation baseline with 5′ / 3′ labels
+//   • Auto-built legend (aggregates counts by `kind` across lollipops and ranges)
+//
+// Dependencies
+// ------------
+//   • std only
+//   • Consumes crate types: `crate::gene_model::{GeneModel, Interval, Strand}`,
+//     `crate::loliplot_parser::{ProteinDomain, Rgb}` and `crate::mutation::Range`.
+//
+// This module does NOT read GFF3/TSV files. Upstream code should build the inputs
+// (`GeneModel`, protein `domains`, lollipop list, and `ranges`). Chromosome
+// matching is a simple string equality on `chrom` fields.
+//
+// Quick start
+// -----------
+//   let svg = gene_svg::gene_model_to_svg(
+//       &gm,            // GeneModel { chrom, strand, exons, start, end, ... }
+//       &domains,       // &[ProteinDomain { start, end, name, color: Option<Rgb> }]
+//       &lollipops,     // &[Lollipop { text, chrom, pos, color, side, kind }]
+//       &ranges,        // &[Range { chrom, start, end, color, side, kind }]
+//       &SvgConfig::default()
+//   );
+//   std::fs::write("gene.svg", svg)?;
+//
+// Coordinates & layout
+// --------------------
+// • Exons are drawn to scale in transcript left→right space. For minus-strand genes,
+//   exons are reversed so exon1 is still on the left; internal mapping honors strand.
+// • Introns have a FIXED pixel width (not to scale) to keep long genes readable.
+// • A central “baseline” indicates transcription direction with chevrons and 5′/3′ labels.
+// • Domains are clipped to exons, drawn within the exon band; labels are rotated if enabled.
+// • Text rendering uses a sans-serif font with configurable sizes.
+//
+// Lollipops (point events)
+// ------------------------
+// • Each lollipop has: desired genomic x (`x_des`), final head x (`x_fin`), lane, side (Top/Bottom),
+//   color, and label text.
+// • Placement algorithm (per side):
+//     1) Grid-based, order-preserving snapping with minimum head spacing (`pin_hgap + 2*radius`)
+//        and max displacement (`pin_max_dx`), opening up to `pin_max_lanes` lanes.
+//     2) Try to realign heads exactly above their sticks when feasible (no overlaps, within budget).
+//     3) Balance head positions within each lane by bounded isotonic regression to share deviation.
+// • Geometry: a two-bend stem (vertical@stick → horizontal@lane → short vertical@head base) plus
+//   a circular head with a triangular tip pointing toward the exon band. Labels are centered in heads.
+//
+// Ranges (interval tracks)
+// ------------------------
+// • Each range is mapped to transcript space and assigned to Top/Bottom. A greedy lane packer places
+//   non-overlapping blocks with minimum horizontal gap (`range_hgap`), up to `range_max_lanes`.
+// • Rectangles are drawn with configurable height and alpha, outside the exon band with clearances.
+//
+// Legend
+// ------
+// • Aggregates `kind` across lollipops (summing `text` as count when it parses as usize, else +1)
+//   and across ranges (+1 each). Centered multi-line layout with color swatches.
+//
+// Configuration (SvgConfig)
+// -------------------------
+// • Canvas & track: svg_width/height, exon_height, intron_width, left_right_margin.
+// • Domains: domain_height, domain_alpha, show_domain_labels.
+// • Exon labels: show_exon_numbers; base font size (`font_size`).
+// • Lollipops: pin_* (clearance, radius, point/stem sizes, stroke, font size, hgap, max_dx,
+//   lane_gap, max_lanes).
+// • Ranges: range_height/alpha, range_clearance, range_lane_gap, range_hgap, range_max_lanes.
+//
+// Output
+// ------
+// • Returns a self-contained `<svg>` string with grouped layers: pins (stems, heads), track,
+//   domains, ranges, baseline, legend.
+//
+// Notes
+// -----
+// • All distances are in pixels; genomic inputs are u64 base positions.
+// • This file focuses purely on drawing. Parsing/merging exons, domain sourcing, chromosome
+//   normalization (e.g., stripping “chr”), and data filtering belong to upstream code.
+//
+
+use crate::gene_model::{GeneModel, Interval, Strand};
+use crate::loliplot_parser::{ProteinDomain, Rgb};
+use crate::mutation::Range;
+use std::fmt::Write as _;
+
+#[derive(Debug)]
+pub struct Lollipop {
+    pub text: String,
+    pub chrom: String,
+    pub pos: u64,
+    pub color: Rgb,
+    pub side: LollipopSide,
+    pub kind: String,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum LollipopSide {
+    Top,
+    Bottom,
+}
+
+/// Rendering configuration for `gene_model_to_svg`.
+#[derive(Debug, Clone)]
+pub struct SvgConfig {
+    /// Overall SVG width in pixels.
+    pub svg_width: u32,
+    /// Overall SVG height in pixels.
+    pub svg_height: u32,
+
+    /// Height of exon rectangles (px).
+    pub exon_height: u32,
+    /// Fixed width for introns (px, not to scale).
+    pub intron_width: u32,
+    /// Left/right padding of the canvas (px).
+    pub left_right_margin: u32,
+
+    /// Maximum domain band height inside exons (px).
+    pub domain_height: u32,
+    /// Opacity for domain rectangles (0.0–1.0).
+    pub domain_alpha: f32,
+
+    /// Base font size for exon labels and legend (px).
+    pub font_size: u32,
+    /// Show exon indices (1..N) if true.
+    pub show_exon_numbers: bool,
+    /// Show protein domain names if true.
+    pub show_domain_labels: bool,
+
+    // Lollipops (point events)
+    /// Gap from exon band to pin apex (px).
+    pub pin_clearance: f32,
+    /// Radius of circular head (px).
+    pub pin_radius: f32,
+    /// Height of triangular tip pointing to band (px).
+    pub pin_point_h: f32,
+    /// Vertical stem length from baseline to apex (px).
+    pub pin_stem_h: f32,
+    /// Stroke width for pins (px).
+    pub pin_stroke_w: f32,
+    /// Font size for pin head text (px).
+    pub pin_font_size: u32,
+    /// Extra horizontal gap between pin heads (px).
+    pub pin_hgap: f32,
+    /// Maximum horizontal displacement from genomic x (px).
+    pub pin_max_dx: f32,
+    /// Vertical gap between lanes on same side (px).
+    pub pin_lane_gap: f32,
+    /// Maximum number of lanes per side (≥1).
+    pub pin_max_lanes: u32,
+
+    // Ranges (interval tracks)
+    /// Height of range rectangles (px).
+    pub range_height: u32,
+    /// Opacity for ranges (0.0–1.0).
+    pub range_alpha: f32,
+    /// Gap from exon band to first range lane (px).
+    pub range_clearance: f32,
+    /// Vertical gap between range lanes (px).
+    pub range_lane_gap: f32,
+    /// Minimum horizontal gap between ranges (px).
+    pub range_hgap: f32,
+    /// Maximum number of lanes per side (≥1).
+    pub range_max_lanes: u32,
+}
+
+impl Default for SvgConfig {
+    fn default() -> Self {
+        Self {
+            svg_width: 1500,
+            svg_height: 500,
+            exon_height: 150,
+            intron_width: 10,
+            left_right_margin: 20,
+            domain_height: 150,
+            domain_alpha: 0.65,
+            font_size: 11,
+            show_exon_numbers: true,
+            show_domain_labels: true,
+            pin_clearance: 20.0,
+            pin_radius: 9.0,
+            pin_point_h: 8.0,
+            pin_stem_h: 18.0,
+            pin_stroke_w: 1.5,
+            pin_font_size: 10,
+            pin_hgap: 1.0,
+            pin_max_dx: 60.0,
+            pin_lane_gap: 33.0,
+            pin_max_lanes: 4,
+
+            range_height: 10,
+            range_alpha: 0.95,
+            range_clearance: 18.0,
+            range_lane_gap: 18.0,
+            range_hgap: 10.0,
+            range_max_lanes: 6,
+        }
+    }
+}
+
+/// Generate a self-contained SVG for a gene/transcript view.
+///
+/// Renders:
+/// - Exons to genomic scale; introns at fixed pixel width.
+/// - Protein domains within the exon band (to scale; optional labels).
+/// - Lollipop pins (point events) above/below with multi-lane placement.
+/// - Interval ranges (tracks) above/below with greedy lane packing.
+/// - Orientation baseline with 5′/3′ labels and an aggregated legend.
+///
+/// Inputs:
+/// - `gm`: Gene model (`chrom`, `strand`, `exons`, `start..end`). Exon order is
+///   left→right; minus strand is reversed for display while coordinate mapping honors strand.
+/// - `domains`: Protein domains (genomic `start..end`, `name`, optional `Rgb`).
+/// - `lollipops`: Point events (`chrom`, `pos`, `text`, `color`, `side`, `kind`).
+/// - `ranges`: Interval events (`chrom`, `start..end`, `color`, `side`, `kind`).
+/// - `cfg`: Rendering parameters (sizes, spacing, alpha, fonts, lane limits).
+///
+/// Mapping & filtering:
+/// - Genomic → x is piecewise: exons to scale; introns linearly across `intron_width`.
+/// - Items with mismatched chromosome or outside `gm.start..=gm.end` are skipped.
+/// - Domains named `"NA"` are ignored. Domain and range segments are clipped to exon/track extent.
+///
+/// Lollipops:
+/// - Desired head x is the mapped genomic position. Per side (Top/Bottom), heads are
+///   placed on a grid with min spacing `2*pin_radius + pin_hgap`, bounded displacement
+///   `±pin_max_dx`, up to `pin_max_lanes` lanes. A realignment step snaps heads to their
+///   sticks when feasible, then a bounded isotonic pass balances deviations within each lane.
+/// - Geometry: vertical@stick → horizontal@lane → short vertical@head, circular head with a tip
+///   toward the exon band; label text centered with automatic contrast.
+///
+/// Ranges:
+/// - Packed per side with a greedy lane allocator enforcing `range_hgap`, up to
+///   `range_max_lanes`; drawn outside the exon band with `range_clearance` and `range_lane_gap`.
+///
+/// Labels & legend:
+/// - Exon indices shown if `cfg.show_exon_numbers`. Domain labels if `cfg.show_domain_labels`.
+/// - Baseline chevrons and 5′/3′ depend on `gm.strand`.
+/// - Legend groups by `kind`: lollipops add `text.parse::<usize>().unwrap_or(1)`, ranges add 1.
+///
+/// Returns:
+/// - `String` containing a complete `<svg>` element (no external assets).
+///
+/// Panics:
+/// - If `gm.exons` is empty.
+pub fn gene_model_to_svg(
+    gm: &GeneModel,
+    domains: &[ProteinDomain],
+    lollipops: &[Lollipop],
+    ranges: &[Range],
+    cfg: &SvgConfig,
+) -> String {
+    // ----- exon order: exon1 on the left
+    let mut exons = gm.exons.clone();
+    exons.sort_by_key(|iv| iv.start);
+    if matches!(gm.strand, Strand::Minus) {
+        exons.reverse();
+    }
+    let n = exons.len();
+    assert!(n > 0, "GeneModel has no exons");
+
+    // Layout scale (exons to scale, introns fixed)
+    let total_exon_bp: u64 = exons.iter().map(|iv| iv.end - iv.start + 1).sum();
+    let intron_total = cfg.intron_width.saturating_mul(n.saturating_sub(1) as u32) as u64;
+    let avail_width = (cfg.svg_width as u64)
+        .saturating_sub((2 * cfg.left_right_margin) as u64)
+        .saturating_sub(intron_total)
+        .max(1);
+    let bp_to_px = (avail_width as f64) / (total_exon_bp as f64);
+
+    let track_y = ((cfg.svg_height as i64 - cfg.exon_height as i64) / 2).max(0) as u32;
+    let baseline_y = track_y + cfg.exon_height / 2;
+
+    // Domain band
+    let dh = cfg.domain_height.min(cfg.exon_height);
+    let domain_y = track_y + ((cfg.exon_height - dh) / 2);
+    let alpha = cfg.domain_alpha.clamp(0.0, 1.0);
+
+    // ---- Precompute drawable segments (exon + intron) for position mapping ----
+    #[derive(Clone, Copy)]
+    struct Seg {
+        is_exon: bool,
+        g_start: u64,
+        g_end: u64,
+        x: f64,
+        w: f64,
+        exon: Option<Interval>,
+    }
+
+    let mut segs: Vec<Seg> = Vec::with_capacity(2 * n - 1);
+    let mut cursor_x = cfg.left_right_margin as f64;
+
+    for (i, ex) in exons.iter().enumerate() {
+        let exon_bp = (ex.end - ex.start + 1) as f64;
+        let exon_px = (exon_bp * bp_to_px).max(1.0);
+        segs.push(Seg {
+            is_exon: true,
+            g_start: ex.start,
+            g_end: ex.end,
+            x: cursor_x,
+            w: exon_px,
+            exon: Some(*ex),
+        });
+        cursor_x += exon_px;
+
+        if i + 1 < exons.len() {
+            // intron genomic range (for mapping positions inside introns to the fixed width)
+            let (g_start, g_end) = if matches!(gm.strand, Strand::Minus) {
+                // transcript order is reversed genomic; intron in between:
+                (exons[i + 1].end + 1, ex.start - 1)
+            } else {
+                (ex.end + 1, exons[i + 1].start - 1)
+            };
+            let w = cfg.intron_width as f64;
+            segs.push(Seg {
+                is_exon: false,
+                g_start,
+                g_end,
+                x: cursor_x,
+                w,
+                exon: None,
+            });
+            cursor_x += w;
+        }
+    }
+    let gene_start_x = segs
+        .first()
+        .map(|s| s.x)
+        .unwrap_or(cfg.left_right_margin as f64);
+    let gene_end_x = cursor_x;
+
+    // Helper: map genomic pos -> canvas x in transcript left→right space
+    let map_pos_to_x = |pos: u64| -> Option<f64> {
+        for seg in &segs {
+            if seg.is_exon {
+                let ex = seg.exon.unwrap();
+                if pos < ex.start || pos > ex.end {
+                    continue;
+                }
+                // within exon: strand decides direction inside segment
+                let off_bp = match gm.strand {
+                    Strand::Plus | Strand::Unknown => pos.saturating_sub(ex.start) as f64,
+                    Strand::Minus => ex.end.saturating_sub(pos) as f64,
+                };
+                return Some(seg.x + off_bp * bp_to_px);
+            } else {
+                // intron: project linearly across fixed width
+                // guard against 0-length (rare)
+                let g_lo = seg.g_start.min(seg.g_end);
+                let g_hi = seg.g_start.max(seg.g_end);
+                if pos < g_lo || pos > g_hi || g_hi == g_lo {
+                    continue;
+                }
+                let frac = (pos - g_lo) as f64 / (g_hi - g_lo) as f64;
+                return Some(seg.x + frac * seg.w);
+            }
+        }
+        None
+    };
+
+    // ----- SVG header -----
+    let mut svg = String::new();
+    let _ = write!(
+        &mut svg,
+        r#"<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" width="{}" height="{}" viewBox="0 0 {} {}">
+  <defs>
+    <!-- right-pointing chevron for baseline; inherits stroke color -->
+    <marker id="chev" markerWidth="4" markerHeight="4" refX="3.2" refY="2" orient="auto" markerUnits="strokeWidth">
+      <path d="M0,0 L3.2,2 L0,4" fill="none" stroke="context-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3"/>
+    </marker>
+  </defs>
+  <style>
+    text {{ font-family: sans-serif; font-size: {}px; }}
+    .exon {{ stroke: #000; fill: #ccc; }}
+    .intron {{ stroke: #666; stroke-width: 2; fill: none; }}
+    .baseline {{ stroke: #555; stroke-width: 2; fill: none; }}
+    .label {{ fill: #555; dominant-baseline: text-before-edge; pointer-events: none; }}
+    .dom {{ stroke: #333; stroke-width: 1; }}
+    .domlbl {{ font-size: {}px; pointer-events: none; }}
+    .pinlbl {{ font-size: {}px; pointer-events: none; }}
+    .stem {{ stroke-linecap: round; stroke-linejoin: round; }}
+  </style>
+"#,
+        cfg.svg_width,
+        cfg.svg_height,
+        cfg.svg_width,
+        cfg.svg_height,
+        cfg.font_size,
+        cfg.font_size.saturating_sub(1),
+        cfg.pin_font_size
+    );
+
+    // ===== 1) LOLLIPOPS =====
+    let _ = writeln!(&mut svg, r#"<g id="pins">"#);
+
+    // Buffers for layered drawing
+    let mut pin_stems_svg = String::new(); // behind
+    let mut pin_heads_svg = String::new(); // on top
+
+    // Contrast: white/black for the head text
+    let pin_text_color = |rgb: Rgb| -> &'static str {
+        let l = 0.2126 * (rgb.r as f32) / 255.0
+            + 0.7152 * (rgb.g as f32) / 255.0
+            + 0.0722 * (rgb.b as f32) / 255.0;
+        if l > 0.55 {
+            "#000"
+        } else {
+            "#fff"
+        }
+    };
+
+    // Collect desired x per side; skip if pos can’t be mapped
+    let mut wants_top: Vec<Want> = Vec::new();
+    let mut wants_bot: Vec<Want> = Vec::new();
+    for (i, lp) in lollipops.iter().enumerate() {
+        if lp.chrom != gm.chrom {
+            continue;
+        }
+        if let Some(x) = map_pos_to_x(lp.pos) {
+            match lp.side {
+                LollipopSide::Top => wants_top.push(Want { idx: i, x_des: x }),
+                LollipopSide::Bottom => wants_bot.push(Want { idx: i, x_des: x }),
+            }
+        }
+    }
+
+    let r = cfg.pin_radius.max(1.0) as f64;
+    let min_dx = 2.0 * r + (cfg.pin_hgap as f64);
+    let max_dx = (cfg.pin_max_dx as f64).max(min_dx);
+    let max_lanes = cfg.pin_max_lanes.max(1);
+    let stroke_w = cfg.pin_stroke_w.max(0.5) as f64;
+    let tip_h = cfg.pin_point_h.max(1.0) as f64;
+    let clearance = cfg.pin_clearance.max(0.0) as f64;
+
+    let mut placed_top = place_side_grid_ordered(wants_top, min_dx, max_dx, max_lanes);
+    let mut placed_bot = place_side_grid_ordered(wants_bot, min_dx, max_dx, max_lanes);
+
+    // NEW: try to align heads with sticks where feasible (no overlap, within max_dx)
+    placed_top = align_heads_to_sticks(placed_top, min_dx, max_dx);
+    placed_bot = align_heads_to_sticks(placed_bot, min_dx, max_dx);
+
+    // NEW: balanced optimization per lane
+    placed_top = balance_heads_within_lanes(placed_top, min_dx, max_dx);
+    placed_bot = balance_heads_within_lanes(placed_bot, min_dx, max_dx);
+
+    // Lane → apex y (outside exon band). Lanes extend AWAY from the band.
+    let lane_gap = cfg.pin_lane_gap.max(0.0) as f64;
+    let apex_y_top = |lane: u32| -> f64 { (track_y as f64) - clearance - (lane as f64) * lane_gap };
+    let apex_y_bot = |lane: u32| -> f64 {
+        ((track_y + cfg.exon_height) as f64) + clearance + (lane as f64) * lane_gap
+    };
+
+    // Drawer with TWO bends: vertical (at x_des) → horizontal (to x_fin) → short vertical to head base.
+    // Tip orientation always points toward the exon band (down for Top, up for Bottom).
+    let draw_pin = |stems: &mut String,
+                    heads: &mut String,
+                    x_des: f64,
+                    x_fin: f64,
+                    lane: u32,
+                    side: LollipopSide,
+                    col: Rgb,
+                    txt: &str| {
+        let (apex_y, circle_cy, tip_base_y, tip_apex_y) = match side {
+            LollipopSide::Top => {
+                let apex = apex_y_top(lane);
+                let cy = apex - tip_h - r;
+                let base = cy + r * 0.25;
+                (apex, cy, base, apex)
+            }
+            LollipopSide::Bottom => {
+                let apex = apex_y_bot(lane);
+                let cy = apex + tip_h + r;
+                let base = cy - r * 0.25;
+                (apex, cy, base, apex)
+            }
+        };
+
+        // —— STEMS (behind): vertical@x_des, horizontal@apex, short vertical@x_fin
+        // let (y0, y1) = match side {
+        //     LollipopSide::Top => (apex_y, baseline_y as f64),
+        //     LollipopSide::Bottom => (baseline_y as f64, apex_y),
+        // };
+        // —— STEMS (behind): three exact segments + round caps at junctions
+        let stroke_attrs = format!(
+            r#"stroke="rgb({},{},{})" stroke-width="{}" stroke-linecap="round""#,
+            col.r, col.g, col.b, stroke_w
+        );
+
+        // 1) vertical (baseline ↔ apex)
+        let (y_vert_1, y_vert_2) = match side {
+            LollipopSide::Top => (apex_y, baseline_y as f64),
+            LollipopSide::Bottom => (baseline_y as f64, apex_y),
+        };
+        let _ = writeln!(
+            stems,
+            r#"<line x1="{x_des:.2}" y1="{y_vert_1:.2}" x2="{x_des:.2}" y2="{y_vert_2:.2}" {stroke_attrs}/>"#
+        );
+
+        // 2) horizontal at apex — EXACTLY from x_des to x_fin
+        let _ = writeln!(
+            stems,
+            r#"<line x1="{x_des:.2}" y1="{apex_y:.2}" x2="{x_fin:.2}" y2="{apex_y:.2}" {stroke_attrs}/>"#
+        );
+
+        // 3) short vertical to head base — EXACTLY from apex to tip_base
+        let _ = writeln!(
+            stems,
+            r#"<line x1="{x_fin:.2}" y1="{apex_y:.2}" x2="{x_fin:.2}" y2="{tip_base_y:.2}" {stroke_attrs}/>"#
+        );
+
+        // 4) elbow caps (mask the joints without changing geometry)
+        let rcap = 0.5 * stroke_w + 0.01; // tiny epsilon avoids hairline seams
+        let cap_attrs = format!(r#"fill="rgb({},{},{})""#, col.r, col.g, col.b);
+
+        // cap at (x_des, apex_y)
+        let _ = writeln!(
+            stems,
+            r#"<circle cx="{x_des:.2}" cy="{apex_y:.2}" r="{rcap:.3}" {cap_attrs}/>"#
+        );
+
+        // cap at (x_fin, apex_y)
+        let _ = writeln!(
+            stems,
+            r#"<circle cx="{x_fin:.2}" cy="{apex_y:.2}" r="{rcap:.3}" {cap_attrs}/>"#
+        );
+
+        // —— HEADS (on top): circle + tip + text
+        let tip_x1 = x_fin - r * 0.7;
+        let tip_x2 = x_fin + r * 0.7;
+
+        let _ = writeln!(
+            heads,
+            r#"  <circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="rgb({},{},{})" stroke="\#222" stroke-width="{}"/>"#,
+            x_fin, circle_cy, r, col.r, col.g, col.b, stroke_w
+        );
+        let _ = writeln!(
+            heads,
+            r#"  <path d="M{:.2},{:.2} L{:.2},{:.2} L{:.2},{:.2} Z" fill="rgb({},{},{})" stroke="\#222" stroke-width="{}"/>"#,
+            tip_x1,
+            tip_base_y,
+            tip_x2,
+            tip_base_y,
+            x_fin,
+            tip_apex_y,
+            col.r,
+            col.g,
+            col.b,
+            stroke_w
+        );
+        let _ = writeln!(
+            heads,
+            r#"  <text class="pinlbl" x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="central" fill="{}">{}</text>"#,
+            x_fin,
+            circle_cy,
+            pin_text_color(col),
+            escape_text(txt)
+        );
+
+        // white inner circle (perimeter only)
+        let inset_px: f32 = 1.0; // how far inside the main circle
+        let ring_width: f32 = 0.5; // stroke thickness
+        let r_white = r as f32 - inset_px;
+
+        let _ = writeln!(
+            heads,
+            r#"  <circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="none" stroke="white" stroke-width="{:.2}"/>"#,
+            x_fin, circle_cy, r_white, ring_width
+        );
+    };
+
+    // Draw TOP pins (lane 0 nearest the band; larger lane → further up)
+    for p in &placed_top {
+        let lp = &lollipops[p.idx];
+        draw_pin(
+            &mut pin_stems_svg,
+            &mut pin_heads_svg,
+            p.x_des,
+            p.x_fin,
+            p.lane,
+            LollipopSide::Top,
+            lp.color,
+            &lp.text,
+        );
+    }
+    for p in &placed_bot {
+        let lp = &lollipops[p.idx];
+        draw_pin(
+            &mut pin_stems_svg,
+            &mut pin_heads_svg,
+            p.x_des,
+            p.x_fin,
+            p.lane,
+            LollipopSide::Bottom,
+            lp.color,
+            &lp.text,
+        );
+    }
+
+    let _ = writeln!(&mut svg, r#"<g id="pin-stems">"#);
+    svg.push_str(&pin_stems_svg);
+    let _ = writeln!(&mut svg, "</g>");
+
+    let _ = writeln!(&mut svg, r#"<g id="pin-heads">"#);
+    svg.push_str(&pin_heads_svg);
+    let _ = writeln!(&mut svg, "</g>");
+
+    let _ = writeln!(&mut svg, "</g>");
+
+    // ===== 2) INTRONS + EXONS (BASE TRACK) =====
+    let _ = writeln!(&mut svg, r#"<g id="track">"#);
+    for (i, seg) in segs.iter().enumerate() {
+        if seg.is_exon {
+            let _ = writeln!(
+                &mut svg,
+                r#"  <rect class="exon" x="{:.2}" y="{}" width="{:.2}" height="{}" />"#,
+                seg.x, track_y, seg.w, cfg.exon_height
+            );
+            if cfg.show_exon_numbers {
+                // exon index among exons only: seg index / 2 in this interleaved list
+                let exon_idx = (i / 2) + 1;
+                let _ = writeln!(
+                    &mut svg,
+                    r#"  <text class="label" x="{:.2}" y="{}">{}</text>"#,
+                    seg.x,
+                    track_y + cfg.exon_height + 4,
+                    exon_idx
+                );
+            }
+        } else {
+            // intron line
+            let _ = writeln!(
+                &mut svg,
+                r#"  <line class="intron" x1="{:.2}" y1="{}" x2="{:.2}" y2="{}" />"#,
+                seg.x,
+                baseline_y,
+                seg.x + seg.w,
+                baseline_y
+            );
+        }
+    }
+    let _ = writeln!(&mut svg, "</g>");
+
+    // ===== 3) DOMAINS =====
+    let _ = writeln!(&mut svg, r#"<g id="domains">"#);
+
+    let to_left_offset_plus =
+        |ex: Interval, bp: u64| -> f64 { (bp.saturating_sub(ex.start) as f64) * bp_to_px };
+    let to_left_offset_minus =
+        |ex: Interval, bp: u64| -> f64 { (ex.end.saturating_sub(bp) as f64) * bp_to_px };
+    let min_label_w = 10.0_f64;
+
+    for d in domains {
+        if d.name.eq_ignore_ascii_case("NA") {
+            continue;
+        }
+        if d.end < gm.start || d.start > gm.end {
+            continue;
+        }
+
+        for seg in segs.iter().filter(|s| s.is_exon) {
+            let ex = seg.exon.unwrap();
+            let s_bp = d.start.max(ex.start);
+            let e_bp = d.end.min(ex.end);
+            if s_bp > e_bp {
+                continue;
+            }
+
+            let left_off_px = match gm.strand {
+                Strand::Plus | Strand::Unknown => to_left_offset_plus(ex, s_bp),
+                Strand::Minus => to_left_offset_minus(ex, e_bp),
+            };
+            let seg_bp = (e_bp - s_bp + 1) as f64;
+            let mut seg_w_px = (seg_bp * bp_to_px).max(1.0);
+            seg_w_px = seg_w_px.min(seg.w);
+
+            let (r, g, b) = d.color.map(|c| (c.r, c.g, c.b)).unwrap_or((153, 153, 153));
+            let x = seg.x + left_off_px;
+
+            let _ = writeln!(
+                &mut svg,
+                r#"  <rect class="dom" x="{x:.2}" y="{domain_y}" width="{seg_w_px:.2}" height="{dh}" fill="rgb({r},{g},{b})" fill-opacity="{alpha:.3}" stroke-opacity="{alpha:.3}" />"#
+            );
+
+            if cfg.show_domain_labels && seg_w_px >= min_label_w && !d.name.is_empty() {
+                let cx = x + seg_w_px / 2.0;
+                let cy = (domain_y + dh / 2) as f64;
+                let txt_col = pin_text_color(Rgb { r, g, b });
+                let _ = writeln!(
+                    &mut svg,
+                    r#"  <text class="domlbl" x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="central" fill="{}" transform="rotate(-90 {:.2} {:.2})">{}</text>"#,
+                    cx,
+                    cy,
+                    txt_col,
+                    cx,
+                    cy,
+                    escape_text(&d.name)
+                );
+            }
+        }
+    }
+    let _ = writeln!(&mut svg, "</g>");
+
+    // ===== 5) ORIENTATION BASELINE + 5′/3′ LABELS (FOREGROUND) =====
+    let _ = writeln!(&mut svg, r#"<g id="baseline">"#);
+    let stub = (cfg.intron_width as f64).min((cfg.svg_width as f64) * 0.15);
+    let left_x1 = (gene_start_x - stub).max(0.0);
+    let right_x2 = (gene_end_x + stub).min(cfg.svg_width as f64);
+
+    let (left_lbl, right_lbl) = match gm.strand {
+        Strand::Minus => ("3′", "5′"),
+        _ => ("5′", "3′"),
+    };
+
+    // left stub
+    let _ = writeln!(
+        &mut svg,
+        r#"  <line class="baseline" x1="{left_x1:.2}" y1="{baseline_y}" x2="{gene_start_x}" y2="{baseline_y}" marker-end="url(#chev)" />"#
+    );
+    let _ = writeln!(
+        &mut svg,
+        r#"  <text class="label" x="{:.2}" y="{}" text-anchor="end">{}</text>"#,
+        left_x1 - 2.0,
+        baseline_y.saturating_sub(12),
+        left_lbl
+    );
+    // right stub
+    let _ = writeln!(
+        &mut svg,
+        r#"  <line class="baseline" x1="{gene_end_x}" y1="{baseline_y}" x2="{right_x2:.2}" y2="{baseline_y}" marker-end="url(#chev)" />"#
+    );
+    let _ = writeln!(
+        &mut svg,
+        r#"  <text class="label" x="{:.2}" y="{}" text-anchor="start">{}</text>"#,
+        right_x2 + 2.0,
+        baseline_y.saturating_sub(12),
+        right_lbl
+    );
+    let _ = writeln!(&mut svg, "</g>");
+
+    let map_span_to_x = |s: u64, e: u64| -> Option<(f64, f64)> {
+        let s1 = s.max(gm.start);
+        let e1 = e.min(gm.end);
+        if s1 > e1 {
+            return None;
+        }
+        let x_s = map_pos_to_x(s1)?;
+        let x_e = map_pos_to_x(e1)?;
+        Some(if x_s <= x_e { (x_s, x_e) } else { (x_e, x_s) })
+    };
+    // ===== RANGES (interval tracks) =====
+    let mut ranges_top_xy: Vec<(usize, f64, f64)> = Vec::new();
+    let mut ranges_bot_xy: Vec<(usize, f64, f64)> = Vec::new();
+
+    for (i, rg) in ranges.iter().enumerate() {
+        if rg.chrom != gm.chrom {
+            continue;
+        }
+        if let Some((x1, x2)) = map_span_to_x(rg.start, rg.end) {
+            match rg.side {
+                LollipopSide::Top => ranges_top_xy.push((i, x1, x2)),
+                LollipopSide::Bottom => ranges_bot_xy.push((i, x1, x2)),
+            }
+        }
+    }
+
+    let range_h = cfg.range_height as f64;
+    let r_clear = cfg.range_clearance.max(0.0) as f64;
+    let lane_gap = cfg.range_lane_gap.max(0.0) as f64;
+    let max_lanes = cfg.range_max_lanes.max(1);
+    let alpha_r = cfg.range_alpha.clamp(0.0, 1.0);
+    let hgap = cfg.range_hgap.max(0.0) as f64;
+
+    // pack per side
+    let packed_top = place_ranges_multilane(&ranges_top_xy, hgap, max_lanes);
+    let packed_bot = place_ranges_multilane(&ranges_bot_xy, hgap, max_lanes);
+
+    // lane → y coordinate (outside exon band, like pins)
+    let y_top = |lane: u32| (track_y as f64) - r_clear - (lane as f64) * lane_gap - range_h;
+    let y_bot =
+        |lane: u32| ((track_y + cfg.exon_height) as f64) + r_clear + (lane as f64) * lane_gap;
+
+    // draw
+    let _ = writeln!(&mut svg, r#"<g id="ranges">"#);
+    for d in packed_top {
+        let r = &ranges[d.idx];
+        let y = y_top(d.lane);
+        let w = (d.x2 - d.x1).max(1.0);
+        let _ = writeln!(
+            &mut svg,
+            r#"  <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="rgb({},{},{})" fill-opacity="{:.3}" stroke="rgb({},{},{})" stroke-opacity="{:.3}" stroke-width="1"/>"#,
+            d.x1,
+            y,
+            w,
+            range_h,
+            r.color.r,
+            r.color.g,
+            r.color.b,
+            alpha_r,
+            r.color.r,
+            r.color.g,
+            r.color.b,
+            alpha_r
+        );
+    }
+    for d in packed_bot {
+        let r = &ranges[d.idx];
+        let y = y_bot(d.lane);
+        let w = (d.x2 - d.x1).max(1.0);
+        let _ = writeln!(
+            &mut svg,
+            r#"  <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="rgb({},{},{})" fill-opacity="{:.3}" stroke="rgb({},{},{})" stroke-opacity="{:.3}" stroke-width="1"/>"#,
+            d.x1,
+            y,
+            w,
+            range_h,
+            r.color.r,
+            r.color.g,
+            r.color.b,
+            alpha_r,
+            r.color.r,
+            r.color.g,
+            r.color.b,
+            alpha_r
+        );
+    }
+    let _ = writeln!(&mut svg, "</g>");
+
+    // ===== LEGEND (kinds from lollipops + ranges) =====
+    use std::collections::BTreeMap;
+    let mut counts: BTreeMap<String, (Rgb, usize)> = BTreeMap::new();
+
+    // from lollipops
+    for lp in lollipops {
+        let e = counts.entry(lp.kind.clone()).or_insert((lp.color, 0));
+        e.1 += lp.text.parse::<usize>().unwrap_or(1);
+    }
+    // from ranges
+    for rg in ranges {
+        let e = counts.entry(rg.kind.clone()).or_insert((rg.color, 0));
+        e.1 += 1;
+    }
+
+    let box_size = cfg.font_size as f64;
+    let hgap = (cfg.font_size as f64) * 2.5;
+    let line_height = box_size * 2.5;
+    let max_width = (cfg.svg_width as f64) - 2.0 * (cfg.left_right_margin as f64);
+
+    // Precompute items with widths (same as before)
+    let mut lines: Vec<Vec<(&str, &Rgb, &usize, f64)>> = Vec::new();
+    let mut current_line: Vec<(&str, &Rgb, &usize, f64)> = Vec::new();
+    let mut line_width = 0.0;
+
+    for (kind, (rgb, n)) in &counts {
+        let item_width =
+            box_size + 4.0 + (kind.len() as f64 + 6.0) * (cfg.font_size as f64 * 0.6) + hgap;
+
+        if line_width + item_width > max_width && !current_line.is_empty() {
+            lines.push(current_line);
+            current_line = Vec::new();
+            line_width = 0.0;
+        }
+
+        current_line.push((kind, rgb, n, item_width));
+        line_width += item_width;
+    }
+    if !current_line.is_empty() {
+        lines.push(current_line);
+    }
+
+    // Start rendering from the top
+    let mut legend_y =
+        cfg.svg_height as f64 - (cfg.left_right_margin as f64 * 2.0) - cfg.font_size as f64;
+    writeln!(&mut svg, r#"<g id="legend">"#).unwrap();
+
+    for line in lines {
+        let total_width: f64 = line.iter().map(|(_, _, _, w)| *w).sum();
+        let mut legend_x = (cfg.svg_width as f64 - total_width) / 2.0; // center this line
+
+        for (kind, rgb, n, item_width) in line {
+            let box_x = legend_x - box_size / 2.0;
+            writeln!(
+                &mut svg,
+                r#"  <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"
+                    fill="rgb({},{},{})" stroke="\#000" stroke-width="0.5"/>"#,
+                box_x,
+                legend_y - box_size * 0.8,
+                box_size,
+                box_size,
+                rgb.r,
+                rgb.g,
+                rgb.b
+            )
+            .unwrap();
+
+            let label = format!("{kind} (n={n})");
+            let text_x = legend_x + box_size / 2.0 + 4.0;
+            writeln!(
+                &mut svg,
+                r#"  <text x="{:.2}" y="{:.2}" font-size="{}" 
+                dominant-baseline="hanging" text-anchor="start">{}</text>"#,
+                text_x,
+                legend_y - box_size * 0.8,
+                cfg.font_size,
+                escape_text(&label)
+            )
+            .unwrap();
+
+            legend_x += item_width;
+        }
+
+        legend_y += line_height;
+    }
+    writeln!(&mut svg, "</g>").unwrap();
+
+    svg.push_str("</svg>\n");
+    svg
+}
+// Minimal XML escaper for text nodes.
+fn escape_text(s: &str) -> String {
+    let mut out = String::with_capacity(s.len());
+    for ch in s.chars() {
+        match ch {
+            '&' => out.push_str("&amp;"),
+            '<' => out.push_str("&lt;"),
+            '>' => out.push_str("&gt;"),
+            '"' => out.push_str("&quot;"),
+            '\'' => out.push_str("&apos;"),
+            _ => out.push(ch),
+        }
+    }
+    out
+}
+
+#[derive(Clone, Copy)]
+struct Placed {
+    idx: usize,
+    x_des: f64,
+    x_fin: f64,
+    lane: u32,
+}
+
+#[derive(Clone, Copy)]
+struct Want {
+    idx: usize,
+    x_des: f64,
+}
+
+#[derive(Clone, Copy)]
+struct DrawRange {
+    idx: usize,
+    x1: f64,
+    x2: f64,
+    lane: u32,
+}
+
+fn place_ranges_multilane(
+    spans: &[(usize, f64, f64)], // (index in input vec, x1<=x2)
+    hgap: f64,
+    max_lanes: u32,
+) -> Vec<DrawRange> {
+    // sort by left edge
+    let mut items = spans.to_vec();
+    items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
+
+    // for each lane, track rightmost end used
+    let mut right_edge: Vec<f64> = vec![f64::NEG_INFINITY; max_lanes as usize];
+    let mut out = Vec::with_capacity(items.len());
+
+    'outer: for (idx, x1, x2) in items {
+        for lane in 0..max_lanes {
+            let re = right_edge[lane as usize];
+            if x1 >= re + hgap {
+                out.push(DrawRange { idx, x1, x2, lane });
+                right_edge[lane as usize] = x2;
+                continue 'outer;
+            }
+        }
+        // If no lane fits, put into the last lane anyway (minimal overlap fallback)
+        let lane = max_lanes.saturating_sub(1);
+        out.push(DrawRange { idx, x1, x2, lane });
+        right_edge[lane as usize] = right_edge[lane as usize].max(x2);
+    }
+
+    out
+}
+
+/// Grid-based, order-preserving lollipop placement.
+/// - Heads are snapped on a grid of step = min_dx
+/// - For each desired x (sorted), choose the lane & grid column with minimal |x_fin-x_des|
+/// - Enforces: monotone x within each lane, head spacing >= min_dx, |x_fin-x_des| <= max_dx
+fn place_side_grid_ordered(
+    mut wants: Vec<Want>,
+    min_dx: f64,
+    max_dx: f64,
+    max_lanes: u32,
+) -> Vec<Placed> {
+    wants.sort_by(|a, b| a.x_des.partial_cmp(&b.x_des).unwrap());
+
+    let mut lane_right_edge: Vec<f64> = Vec::new(); // rightmost x per lane
+    let mut placed: Vec<Placed> = Vec::with_capacity(wants.len());
+
+    let step = min_dx.max(f64::EPSILON);
+    let snap_to_grid = |x: f64| (x / step).round() * step;
+
+    for w in wants {
+        let lo = w.x_des - max_dx;
+        let hi = w.x_des + max_dx;
+
+        let cand_center = snap_to_grid(w.x_des);
+        let max_probes = 1 + ((hi - lo) / step).ceil().min(400.0) as i32;
+
+        let mut best: Option<(usize, f64, f64)> = None; // (lane, x_fin, cost)
+
+        // Try existing lanes first
+        'probe: for k in 0..max_probes {
+            let off = (k / 2 + 1) as f64 * step;
+            let x_try = if k == 0 {
+                cand_center
+            } else if k % 2 == 1 {
+                cand_center + off
+            } else {
+                cand_center - off
+            };
+            if x_try < lo - 1e-9 || x_try > hi + 1e-9 {
+                continue;
+            }
+
+            let lanes_len = lane_right_edge.len();
+
+            #[allow(clippy::needless_range_loop)]
+            for lane in 0..lanes_len {
+                // spacing constraint for this lane
+                let xmin = lane_right_edge[lane] + min_dx - 1e-9;
+
+                // advance to first grid column >= xmin
+                let x_snap = if x_try < xmin {
+                    let m = ((xmin - x_try) / step).ceil().max(0.0);
+                    x_try + m * step
+                } else {
+                    x_try
+                };
+                if x_snap < lo - 1e-9 || x_snap > hi + 1e-9 {
+                    continue;
+                }
+                if x_snap + 1e-9 < xmin {
+                    continue;
+                }
+
+                let cost = (x_snap - w.x_des).abs();
+                if let Some((_b_lane, _b_x, b_cost)) = best {
+                    if cost < b_cost - 1e-9 {
+                        best = Some((lane, x_snap, cost));
+                    }
+                } else {
+                    best = Some((lane, x_snap, cost));
+                }
+
+                // Perfect match found on an existing lane: stop probing.
+                if cost <= 1e-9 {
+                    break 'probe;
+                }
+            }
+        }
+
+        // If no existing lane fits, try opening a new lane (if allowed).
+        if best.is_none() && (lane_right_edge.len() as u32) < max_lanes {
+            let new_lane = lane_right_edge.len(); // capture index before push
+            let mut local_best: Option<(usize, f64, f64)> = None;
+
+            for k in 0..max_probes {
+                let off = (k / 2 + 1) as f64 * step;
+                let x_try = if k == 0 {
+                    snap_to_grid(w.x_des)
+                } else if k % 2 == 1 {
+                    snap_to_grid(w.x_des + off)
+                } else {
+                    snap_to_grid(w.x_des - off)
+                };
+                if x_try < lo - 1e-9 || x_try > hi + 1e-9 {
+                    continue;
+                }
+                let cost = (x_try - w.x_des).abs();
+                if let Some((_b_lane, _b_x, b_cost)) = local_best {
+                    if cost < b_cost - 1e-9 {
+                        local_best = Some((new_lane, x_try, cost));
+                    }
+                } else {
+                    local_best = Some((new_lane, x_try, cost));
+                }
+                if cost <= 1e-9 {
+                    break;
+                }
+            }
+
+            if let Some(b) = local_best {
+                best = Some(b);
+            }
+        }
+
+        if let Some((lane, x_fin, _cost)) = best {
+            // Open the lane if needed; no immutable borrow is active here.
+            if lane >= lane_right_edge.len() {
+                lane_right_edge.push(f64::NEG_INFINITY);
+            }
+            placed.push(Placed {
+                idx: w.idx,
+                x_des: w.x_des,
+                x_fin,
+                lane: lane as u32,
+            });
+            lane_right_edge[lane] = x_fin;
+        }
+        // else: skip if impossible within max_dx (or implement clamping if desired)
+    }
+
+    placed.sort_by(|a, b| {
+        a.lane
+            .cmp(&b.lane)
+            .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
+    });
+    placed
+}
+
+/// Try to align each head with its stick (x_fin := x_des) if it causes no overlap in its lane
+/// and stays within the ±max_dx displacement budget. Keeps order and spacing.
+fn align_heads_to_sticks(mut placed: Vec<Placed>, min_dx: f64, max_dx: f64) -> Vec<Placed> {
+    use std::collections::BTreeMap;
+
+    // Group indices by lane
+    let mut by_lane: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
+    for (i, p) in placed.iter().enumerate() {
+        by_lane.entry(p.lane).or_default().push(i);
+    }
+
+    // For each lane, consider items in current x_fin order and try to move to x_des if safe
+    for (_lane, idxs) in by_lane {
+        let mut ids = idxs;
+        ids.sort_by(|&a, &b| {
+            placed[a]
+                .x_fin
+                .partial_cmp(&placed[b].x_fin)
+                .unwrap()
+                .then_with(|| placed[a].x_des.partial_cmp(&placed[b].x_des).unwrap())
+        });
+
+        for k in 0..ids.len() {
+            let i = ids[k];
+
+            // Neighbor bounds using current positions
+            let left_x = if k == 0 {
+                f64::NEG_INFINITY
+            } else {
+                placed[ids[k - 1]].x_fin
+            };
+            let right_x = if k + 1 == ids.len() {
+                f64::INFINITY
+            } else {
+                placed[ids[k + 1]].x_fin
+            };
+
+            // We can move x_fin to any X in [left_x + min_dx, right_x - min_dx]
+            let left_bound = left_x + min_dx - 1e-9;
+            let right_bound = right_x - min_dx + 1e-9;
+
+            // Also must respect displacement budget around x_des
+            let lo = placed[i].x_des - max_dx;
+            let hi = placed[i].x_des + max_dx;
+
+            // Feasible interval for the head
+            let l = left_bound.max(lo);
+            let r = right_bound.min(hi);
+
+            // If x_des lies in the feasible interval, snap to it
+            if l <= placed[i].x_des && placed[i].x_des <= r {
+                placed[i].x_fin = placed[i].x_des;
+            }
+        }
+    }
+
+    // Stable drawing order: by lane, then x_fin
+    placed.sort_by(|a, b| {
+        a.lane
+            .cmp(&b.lane)
+            .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
+    });
+    placed
+}
+
+/// Distribute divergence fairly among near heads within each lane using
+/// bounded isotonic regression on the transformed variables y_i = x_i - i*min_dx.
+/// Objective: minimize sum (x_i - x_des_i)^2 with spacing >= min_dx and |x_i - x_des_i| <= max_dx.
+fn balance_heads_within_lanes(mut placed: Vec<Placed>, min_dx: f64, max_dx: f64) -> Vec<Placed> {
+    use std::collections::BTreeMap;
+
+    // Group indices by lane, in *lane order* (use the order by desired x to keep identity stable)
+    let mut by_lane: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
+    for (i, p) in placed.iter().enumerate() {
+        by_lane.entry(p.lane).or_default().push(i);
+    }
+    for (_lane, ids) in by_lane.iter_mut() {
+        ids.sort_by(|&a, &b| placed[a].x_des.partial_cmp(&placed[b].x_des).unwrap());
+    }
+
+    // Solve per lane
+    for (_lane, ids) in by_lane {
+        let n = ids.len();
+        if n <= 1 {
+            continue;
+        }
+
+        // Build transformed targets and bounds
+        let mut t: Vec<f64> = Vec::with_capacity(n);
+        let mut lo: Vec<f64> = Vec::with_capacity(n);
+        let mut hi: Vec<f64> = Vec::with_capacity(n);
+
+        for (k, &ix) in ids.iter().enumerate() {
+            let x_des = placed[ix].x_des;
+            let l = x_des - max_dx;
+            let r = x_des + max_dx;
+            let shift = (k as f64) * min_dx;
+
+            t.push(x_des - shift);
+            lo.push(l - shift);
+            hi.push(r - shift);
+        }
+
+        // Run bounded isotonic regression on y with targets t and box [lo,hi]
+        let y = bounded_isotonic_regression(&t, &lo, &hi);
+
+        // Recover x_i = y_i + i*min_dx and assign back
+        for (k, &ix) in ids.iter().enumerate() {
+            let x_new = y[k] + (k as f64) * min_dx;
+            placed[ix].x_fin = x_new;
+        }
+    }
+
+    // keep stable draw order
+    placed.sort_by(|a, b| {
+        a.lane
+            .cmp(&b.lane)
+            .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
+    });
+    placed
+}
+
+/// Bounded isotonic regression (PAV) for squared loss:
+/// Given targets t[i] with box constraints lo[i] <= y[i] <= hi[i],
+/// find nondecreasing y minimizing sum (y[i]-t[i])^2.
+/// All vectors must have same length; panics if empty.
+fn bounded_isotonic_regression(t: &[f64], lo: &[f64], hi: &[f64]) -> Vec<f64> {
+    assert!(!t.is_empty() && t.len() == lo.len() && t.len() == hi.len());
+
+    // Each block keeps: weight (w), mean (m), lower (L), upper (U), and indices covered
+    #[derive(Clone)]
+    struct Block {
+        w: f64,
+        m: f64,
+        l: f64,
+        u: f64,
+        start: usize,
+        end: usize, // inclusive
+    }
+
+    let n = t.len();
+    let mut stack: Vec<Block> = Vec::new();
+
+    for i in 0..n {
+        // Start a new block with raw mean = t[i], then clamp to [lo,hi]
+        let b = Block {
+            w: 1.0,
+            m: t[i].max(lo[i]).min(hi[i]),
+            l: lo[i],
+            u: hi[i],
+            start: i,
+            end: i,
+        };
+        stack.push(b);
+
+        // Merge while monotonicity violated
+        while stack.len() >= 2 {
+            let len = stack.len();
+            let (b2, b1) = (stack[len - 2].clone(), stack[len - 1].clone());
+            if b2.m <= b1.m + 1e-12 {
+                break;
+            }
+            // Merge b2 and b1
+            let w = b2.w + b1.w;
+            let mut m = (b2.m * b2.w + b1.m * b1.w) / w;
+            let l = b2.l.max(b1.l);
+            let u = b2.u.min(b1.u);
+            // Clamp merged mean into intersection box
+            m = m.max(l).min(u);
+
+            // Replace last two by merged block
+            let merged = Block {
+                w,
+                m,
+                l,
+                u,
+                start: b2.start,
+                end: b1.end,
+            };
+            stack.pop();
+            *stack.last_mut().unwrap() = merged;
+        }
+    }
+
+    // Expand blocks to solution vector
+    let mut y = vec![0.0; n];
+    for b in stack {
+        for v in &mut y[b.start..=b.end] {
+            *v = b.m;
+        }
+    }
+    y
+}

+ 111 - 0
src/loliplot_parser.rs

@@ -0,0 +1,111 @@
+use anyhow::{anyhow, Context, Result};
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::{BufRead, BufReader, Read};
+use std::path::Path;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Rgb {
+    pub r: u8,
+    pub g: u8,
+    pub b: u8,
+}
+
+#[derive(Debug, Clone)]
+pub struct ProteinDomain {
+    pub start: u64,                 // 1-based inclusive
+    pub end: u64,                   // 1-based inclusive
+    pub name: String,               // attributes: Name=
+    pub color: Option<Rgb>,         // attributes: color=#RRGGBB (None if missing/invalid)
+    pub protein_domain_id: String,  // attributes: protein_domain_id=
+}
+
+/// Parse protein_domain features for a given gene_name from a (bgzipped) GFF3.
+pub fn parse_domains_by_gene_name<P: AsRef<Path>>(path: P, gene_name: &str) -> Result<Vec<ProteinDomain>> {
+    let reader = open_maybe_gzip(path.as_ref())
+        .with_context(|| format!("Opening {}", path.as_ref().display()))?;
+    parse_domains_from_reader(reader, gene_name)
+}
+
+fn parse_domains_from_reader<R: Read>(reader: R, gene_name: &str) -> Result<Vec<ProteinDomain>> {
+    let mut out = Vec::new();
+    let br = BufReader::new(reader);
+
+    for (lineno, line) in br.lines().enumerate() {
+        let line = line.with_context(|| format!("Reading line {}", lineno + 1))?;
+        if line.is_empty() || line.starts_with('#') { continue; }
+
+        let mut it = line.split('\t');
+        let _seqid  = it.next().ok_or_else(|| anyhow!("Malformed line {}: seqid",  lineno + 1))?;
+        let _source = it.next().ok_or_else(|| anyhow!("Malformed line {}: source", lineno + 1))?;
+        let ftype   = it.next().ok_or_else(|| anyhow!("Malformed line {}: type",   lineno + 1))?;
+        let start_s = it.next().ok_or_else(|| anyhow!("Malformed line {}: start",  lineno + 1))?;
+        let end_s   = it.next().ok_or_else(|| anyhow!("Malformed line {}: end",    lineno + 1))?;
+        let _score  = it.next().ok_or_else(|| anyhow!("Malformed line {}: score",  lineno + 1))?;
+        let _strand = it.next().ok_or_else(|| anyhow!("Malformed line {}: strand", lineno + 1))?;
+        let _phase  = it.next().ok_or_else(|| anyhow!("Malformed line {}: phase",  lineno + 1))?;
+        let attrs_s = it.next().ok_or_else(|| anyhow!("Malformed line {}: attrs",  lineno + 1))?;
+
+        if !ftype.eq_ignore_ascii_case("protein_domain") { continue; }
+
+        let start: u64 = start_s.parse().with_context(|| format!("Invalid start at line {}: {}", lineno + 1, start_s))?;
+        let end: u64   = end_s.parse().with_context(|| format!("Invalid end at line {}: {}",   lineno + 1, end_s))?;
+
+        let attrs = parse_attrs(attrs_s);
+
+        // filter by gene_name
+        if attrs.get("gene_name").map(String::as_str) != Some(gene_name) {
+            continue;
+        }
+
+        let name = attrs.get("Name").cloned().unwrap_or_default();
+        let color = attrs.get("color").and_then(|c| parse_hex_color(c));
+        let pdid = attrs.get("protein_domain_id")
+            .cloned()
+            .ok_or_else(|| anyhow!("protein_domain_id missing at line {}", lineno + 1))?;
+
+        out.push(ProteinDomain { start, end, name, color, protein_domain_id: pdid });
+    }
+
+    Ok(out)
+}
+
+fn parse_attrs(s: &str) -> HashMap<String, String> {
+    let mut m = HashMap::new();
+    for kv in s.split(';') {
+        if kv.is_empty() { continue; }
+        let mut it = kv.splitn(2, '=');
+        let k = it.next().unwrap().trim();
+        if let Some(v) = it.next() {
+            m.insert(k.to_string(), v.trim().to_string());
+        } else {
+            m.insert(k.to_string(), String::new());
+        }
+    }
+    m
+}
+
+fn parse_hex_color(s: &str) -> Option<Rgb> {
+    // Accept "#RRGGBB" or "RRGGBB"
+    let t = s.strip_prefix('#').unwrap_or(s);
+    if t.len() != 6 { return None; }
+    let r = u8::from_str_radix(&t[0..2], 16).ok()?;
+    let g = u8::from_str_radix(&t[2..4], 16).ok()?;
+    let b = u8::from_str_radix(&t[4..6], 16).ok()?;
+    Some(Rgb { r, g, b })
+}
+
+/// Open plain or .gz path as a reader.
+/// Supports concatenated gzip streams (bgzip is fine for reading).
+fn open_maybe_gzip(path: &Path) -> Result<Box<dyn Read>> {
+    let file = File::open(path)?;
+    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
+        if ext.eq_ignore_ascii_case("gz") {
+            // flate2 = "1"
+            let dec = flate2::read::MultiGzDecoder::new(file);
+            return Ok(Box::new(dec));
+        }
+    }
+    Ok(Box::new(file))
+}
+

+ 417 - 0
src/mutation.rs

@@ -0,0 +1,417 @@
+use anyhow::{anyhow, Context, Result};
+use flate2::read::MultiGzDecoder;
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::{BufRead, BufReader, Read};
+use std::path::Path;
+
+use crate::loliplot::{Lollipop, LollipopSide};
+use crate::loliplot_parser::Rgb;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum AlterationKind {
+    // Losses
+    DeletionAa,
+    DeletionExon,
+    DeletionGene,
+    DeletionLocus,
+    DeletionChrom,
+    DeletionDownstream,
+    DeletionStop,
+
+    // Insertions
+    InsertionAa,
+    InsertionExon,
+    InsertionSplice,
+
+    // Gains
+    DuplicationExon,
+    DuplicationLocus,
+    Amplification,
+    InsertionStop,
+    InsertionTrec,
+    InsertionDownstream,
+
+    // Intron / misc
+    IntronInsertion,
+    DownstreamDuplication,
+
+    // SNVs
+    SnvMiss,
+    SnvSplice,
+    SnvStop,
+
+    // Inversions
+    InversionExon,
+    InversionChrom,
+
+    // Translocation
+    Translocation,
+
+    // Unknown
+    Other(String),
+}
+
+impl AlterationKind {
+    pub fn as_str(&self) -> &str {
+        use AlterationKind::*;
+        match self {
+            DeletionAa => "DELETION_AA",
+            DeletionExon => "DELETION_EXON",
+            DeletionGene => "DELETION_GENE",
+            DeletionLocus => "DELETION_LOCUS",
+            DeletionChrom => "DELETION_CHROM",
+            DeletionDownstream => "DELETION_DOWNSTREAM",
+            DeletionStop => "DELETION_STOP",
+            InsertionAa => "INSERTION_AA",
+            InsertionExon => "INSERTION_EXON",
+            InsertionSplice => "INSERTION_SPLICE",
+            DuplicationExon => "DUPLICATION_EXON",
+            DuplicationLocus => "DUPLICATION_LOCUS",
+            Amplification => "AMPLIFICATION",
+            InsertionStop => "INSERTION_STOP",
+            InsertionTrec => "INSERTION_TREC",
+            InsertionDownstream => "INSERTION_DOWNSTREAM",
+            IntronInsertion => "INTRON_INSERTION",
+            DownstreamDuplication => "DOWNSTREAM_DUPLICATION",
+            SnvMiss => "SNV_MISS",
+            SnvSplice => "SNV_SPLICE",
+            SnvStop => "SNV_STOP",
+            InversionExon => "INVERSION_EXON",
+            InversionChrom => "INVERSION_CHROM",
+            Translocation => "TRANSLOCATION",
+            Other(s) => s.as_str(),
+        }
+    }
+}
+
+pub fn parse_kind(s: &str) -> AlterationKind {
+    use AlterationKind::*;
+    match s.to_ascii_uppercase().as_str() {
+        "DELETION_AA" => DeletionAa,
+        "DELETION_EXON" => DeletionExon,
+        "DELETION_GENE" => DeletionGene,
+        "DELETION_LOCUS" => DeletionLocus,
+        "DELETION_CHROM" => DeletionChrom,
+        "DELETION_DOWNSTREAM" => DeletionDownstream,
+        "DELETION_STOP" => DeletionStop,
+        "INSERTION_AA" => InsertionAa,
+        "INSERTION_EXON" => InsertionExon,
+        "INSERTION_SPLICE" => InsertionSplice,
+        "DUPLICATION_EXON" => DuplicationExon,
+        "DUPLICATION_LOCUS" => DuplicationLocus,
+        "AMPLIFICATION" => Amplification,
+        "INSERTION_STOP" => InsertionStop,
+        "INSERTION_TREC" => InsertionTrec,
+        "INSERTION_DOWNSTREAM" => InsertionDownstream,
+        "INTRON_INSERTION" => IntronInsertion,
+        "DOWNSTREAM_DUPLICATION" => DownstreamDuplication,
+        "SNV_MISS" => SnvMiss,
+        "SNV_SPLICE" => SnvSplice,
+        "SNV_STOP" => SnvStop,
+        "INVERSION_EXON" => InversionExon,
+        "INVERSION_CHROM" => InversionChrom,
+        "TRANSLOCATION" => Translocation,
+        other => Other(other.to_string()),
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct Mutation {
+    pub case_id: String,
+    pub alteration_kind: AlterationKind,
+    pub chrom: String,
+    pub pos: u64, // 1-based
+}
+
+pub fn parse_mutations<P: AsRef<Path>>(path: P) -> Result<Vec<Mutation>> {
+    let reader = open_maybe_gzip(path.as_ref())
+        .with_context(|| format!("Opening {}", path.as_ref().display()))?;
+    parse_mutations_from_reader(reader)
+}
+
+fn parse_mutations_from_reader<R: Read>(reader: R) -> Result<Vec<Mutation>> {
+    let br = BufReader::new(reader);
+    let mut out = Vec::new();
+
+    for (lineno, line) in br.lines().enumerate() {
+        let line = line.with_context(|| format!("Reading line {}", lineno + 1))?;
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+
+        // Split on any ASCII whitespace (tabs or spaces)
+        let mut cols = line.split_whitespace();
+
+        // Detect and skip header
+        if lineno == 0 {
+            let peek = cols.clone().collect::<Vec<_>>();
+            if peek.len() >= 4
+                && peek[0].eq_ignore_ascii_case("case_id")
+                && peek[1].eq_ignore_ascii_case("alteration_kind")
+                && peek[2].eq_ignore_ascii_case("chr")
+                && peek[3].eq_ignore_ascii_case("pos")
+            {
+                continue;
+            }
+        }
+
+        let case_id = cols
+            .next()
+            .ok_or_else(|| anyhow!("Missing case_id at line {}", lineno + 1))?;
+        let kind_s = cols
+            .next()
+            .ok_or_else(|| anyhow!("Missing alteration_kind at line {}", lineno + 1))?;
+        let chrom = cols
+            .next()
+            .ok_or_else(|| anyhow!("Missing chr at line {}", lineno + 1))?;
+        let pos_s = cols
+            .next()
+            .ok_or_else(|| anyhow!("Missing pos at line {}", lineno + 1))?;
+
+        let pos: u64 = pos_s
+            .parse()
+            .with_context(|| format!("Invalid pos at line {}: {}", lineno + 1, pos_s))?;
+
+        let mutn = Mutation {
+            case_id: case_id.to_string(),
+            alteration_kind: parse_kind(kind_s),
+            chrom: chrom.to_string(),
+            pos,
+        };
+        out.push(mutn);
+    }
+    Ok(out)
+}
+
+fn open_maybe_gzip(path: &Path) -> Result<Box<dyn Read>> {
+    let file = File::open(path)?;
+    let is_gz = path
+        .extension()
+        .and_then(|e| e.to_str())
+        .map_or(false, |e| e.eq_ignore_ascii_case("gz"));
+    if is_gz {
+        Ok(Box::new(MultiGzDecoder::new(file)))
+    } else {
+        Ok(Box::new(file))
+    }
+}
+
+fn parse_hex_color(s: &str) -> Option<Rgb> {
+    let t = s.strip_prefix('#').unwrap_or(s);
+    if t.len() != 6 {
+        return None;
+    }
+    let r = u8::from_str_radix(&t[0..2], 16).ok()?;
+    let g = u8::from_str_radix(&t[2..4], 16).ok()?;
+    let b = u8::from_str_radix(&t[4..6], 16).ok()?;
+    Some(Rgb { r, g, b })
+}
+
+/// Parse a 2-column table "kind<ws>color", returning a map kind -> Rgb.
+/// - Accepts a header ("kind  color") and ignores it.
+/// - Accepts lines starting with '#' as comments.
+/// - Accepts both tabs and spaces as separators.
+/// - For duplicates, the **last** occurrence wins.
+pub fn parse_kind_colors<P: AsRef<Path>>(path: P) -> Result<HashMap<String, Rgb>> {
+    let reader = open_maybe_gzip(path.as_ref())
+        .with_context(|| format!("Opening {}", path.as_ref().display()))?;
+    parse_kind_colors_from_reader(reader)
+}
+
+fn parse_kind_colors_from_reader<R: Read>(reader: R) -> Result<HashMap<String, Rgb>> {
+    let br = BufReader::new(reader);
+    let mut map: HashMap<String, Rgb> = HashMap::new();
+
+    for (lineno, line) in br.lines().enumerate() {
+        let line = line.with_context(|| format!("Reading line {}", lineno + 1))?;
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+
+        // Split on ASCII whitespace (tab/space)
+        let mut it = line.split_whitespace();
+
+        // Skip header (case-insensitive)
+        if lineno == 0 {
+            let peek: Vec<&str> = it.clone().collect();
+            if peek.len() >= 2
+                && peek[0].eq_ignore_ascii_case("kind")
+                && peek[1].eq_ignore_ascii_case("color")
+            {
+                continue;
+            }
+        }
+
+        let kind = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing kind at line {}", lineno + 1))?;
+        let color_s = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing color at line {}", lineno + 1))?;
+        let rgb = parse_hex_color(color_s)
+            .ok_or_else(|| anyhow!("Invalid hex color at line {}: {}", lineno + 1, color_s))?;
+
+        // store (preserve original casing of kind)
+        map.insert(kind.to_string(), rgb);
+    }
+
+    Ok(map)
+}
+
+use std::collections::BTreeMap;
+
+/// Build lollipops grouped by (chrom, pos, kind).
+/// - One lollipop per (chrom, pos, kind)
+/// - Text = number of mutations with that kind at that site
+/// - Color = from `kind_colors[kind.as_str()]` (fallback gray if missing)
+/// - Side  = alternates Top/Bottom among groups at the same genomic site
+pub fn mutations_to_lollipops(
+    mutations: &[Mutation],
+    kind_colors: &HashMap<String, Rgb>,
+) -> Vec<Lollipop> {
+    // 1) Count by (chrom, pos, kind)
+    // Use BTreeMap for stable iteration order (chrom, pos, kind_key)
+    let mut counts: BTreeMap<(String, u64, String), usize> = BTreeMap::new();
+    for m in mutations {
+        let kind_key = m.alteration_kind.as_str().to_string();
+        *counts
+            .entry((m.chrom.clone(), m.pos, kind_key))
+            .or_insert(0) += 1;
+    }
+
+    // 2) Group keys by (chrom, pos) → Vec<(kind_key, count)>
+    let mut by_site: BTreeMap<(String, u64), Vec<(String, usize)>> = BTreeMap::new();
+    for ((chrom, pos, kind_key), n) in counts {
+        by_site.entry((chrom, pos)).or_default().push((kind_key, n));
+    }
+
+    // 3) Build lollipops, alternating side per site
+    let mut out = Vec::new();
+    for ((chrom, pos), mut vec_kc) in by_site {
+        // Deterministic order of kinds at a site
+        vec_kc.sort_by(|a, b| a.0.cmp(&b.0));
+
+        for (idx, (kind_key, n)) in vec_kc.into_iter().enumerate() {
+            let color = kind_colors.get(&kind_key).copied().unwrap_or(Rgb {
+                r: 160,
+                g: 160,
+                b: 160,
+            });
+
+            let side = LollipopSide::Top;
+
+            out.push(Lollipop {
+                text: n.to_string(),
+                chrom: chrom.clone(),
+                pos,
+                color,
+                side,
+                kind: kind_key,
+            });
+        }
+    }
+
+    out
+}
+
+
+#[derive(Debug, Clone)]
+pub struct Range {
+    pub chrom: String,
+    pub start: u64, // genomic, 1-based inclusive
+    pub end: u64,   // genomic, 1-based inclusive
+    pub kind: String,
+    pub color: Rgb,
+    pub side: LollipopSide, // reuse Top/Bottom
+}
+
+
+pub fn dels_to_ranges<P: AsRef<Path>>(
+    path: P,
+    kind_colors: &HashMap<String, Rgb>,
+    default_side: LollipopSide,
+) -> Result<Vec<Range>> {
+    let reader = open_maybe_gzip(path.as_ref())
+        .with_context(|| format!("Opening {}", path.as_ref().display()))?;
+    parse_dels_to_ranges_from_reader(reader, kind_colors, default_side)
+}
+
+fn parse_dels_to_ranges_from_reader<R: Read>(
+    reader: R,
+    kind_colors: &HashMap<String, Rgb>,
+    default_side: LollipopSide,
+) -> Result<Vec<Range>> {
+    let br = BufReader::new(reader);
+    let mut out = Vec::new();
+
+    for (lineno, line) in br.lines().enumerate() {
+        let line = line.with_context(|| format!("Reading line {}", lineno + 1))?;
+        let line = line.trim();
+        if line.is_empty() || line.starts_with('#') {
+            continue;
+        }
+
+        // split on tabs or spaces
+        let mut it = line.split_whitespace();
+
+        // detect header
+        if lineno == 0 {
+            let peek: Vec<&str> = it.clone().collect();
+            if peek.len() >= 4
+                && peek[0].eq_ignore_ascii_case("chr")
+                && peek[1].eq_ignore_ascii_case("start")
+                && peek[2].eq_ignore_ascii_case("end")
+                && (peek[3].eq_ignore_ascii_case("alteration_kind")
+                    || peek[3].eq_ignore_ascii_case("kind"))
+            {
+                continue;
+            }
+        }
+
+        let chrom = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing chr at line {}", lineno + 1))?;
+        let start_s = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing start at line {}", lineno + 1))?;
+        let end_s = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing end at line {}", lineno + 1))?;
+        let kind_raw = it
+            .next()
+            .ok_or_else(|| anyhow!("Missing alteration_kind at line {}", lineno + 1))?;
+
+        let mut start: u64 = start_s
+            .parse()
+            .with_context(|| format!("Invalid start at line {}: {}", lineno + 1, start_s))?;
+        let mut end: u64 = end_s
+            .parse()
+            .with_context(|| format!("Invalid end at line {}: {}", lineno + 1, end_s))?;
+
+        if end < start {
+            std::mem::swap(&mut start, &mut end);
+        }
+
+        // normalize key (match your color table keys)
+        let kind_key = kind_raw.to_ascii_uppercase();
+        let color = kind_colors
+            .get(&kind_key)
+            .copied()
+            .unwrap_or(Rgb { r: 160, g: 160, b: 160 });
+
+        out.push(Range {
+            chrom: chrom.to_string(),
+            start,
+            end,
+            kind: kind_key, // keep canonical key for legend grouping
+            color,
+            side: default_side,
+        });
+    }
+
+    Ok(out)
+}
+