3636}
3737INTRA_TYPES = ("DEL" , "DUP" , "INV" )
3838
39+ # INS is a single-breakpoint event; drawn as its own marker tick (not a link,
40+ # and by default not folded into the shared breakpoint-density histogram).
41+ INS_COLOR = "#E8862A" # orange, distinct from DEL/DUP/INV/TRA
42+
3943
4044def normalize_chrom (chrom ):
4145 """Normalize a chromosome name to 'chrN' form; return as-is if unrecognized."""
@@ -93,6 +97,7 @@ def __init__(
9397 intra_max_span = 50_000_000 ,
9498 include_ins = False ,
9599 ins_support = 3 ,
100+ ins_in_density = False ,
96101 bin_size = 5_000_000 ,
97102 drop_del = False ,
98103 drop_dup = False ,
@@ -108,6 +113,7 @@ def __init__(
108113 self .intra_max_span = intra_max_span
109114 self .include_ins = include_ins
110115 self .ins_support = ins_support
116+ self .ins_in_density = ins_in_density
111117 self .bin_size = bin_size
112118 self .drop_del = drop_del
113119 self .drop_dup = drop_dup
@@ -116,6 +122,7 @@ def __init__(
116122
117123 self .links = [] # list of dicts: chrom1,pos1,chrom2,pos2,svtype,support
118124 self .breakpoints = [] # list of (chrom, pos) for density
125+ self .ins_markers = [] # list of (chrom, pos) for the INS marker track
119126 self .oversized = [] # intra events > intra_max_span
120127 self .stats = defaultdict (int )
121128
@@ -168,14 +175,20 @@ def extract(self):
168175 if not self .include_ins :
169176 self .stats ["drop_ins" ] += 1
170177 continue
171- if support is None or support < self .ins_support :
178+ if self . ins_support > 0 and ( support is None or support < self .ins_support ) :
172179 self .stats ["drop_ins_lowsupport" ] += 1
173180 continue
174- if self ._on_primary (c1 ) and 1 <= p1 <= self .chrom_sizes [c1 ]:
181+ if not (self ._on_primary (c1 ) and 1 <= p1 <= self .chrom_sizes [c1 ]):
182+ self .stats ["drop_ins_offchrom" ] += 1
183+ continue
184+ if self .ins_in_density :
185+ # Legacy behavior: fold INS into the shared density histogram.
175186 self .breakpoints .append ((c1 , p1 ))
176187 self .stats ["ins_density" ] += 1
177188 else :
178- self .stats ["drop_ins_offchrom" ] += 1
189+ # Default: INS gets its own marker tick, kept out of density.
190+ self .ins_markers .append ((c1 , p1 ))
191+ self .stats ["ins_marker" ] += 1
179192 continue
180193
181194 # ---- type on/off switches ----
@@ -196,7 +209,7 @@ def extract(self):
196209
197210 # ---- TRA / interchromosomal ----
198211 if svtype == "TRA" :
199- if support is None or support < self .tra_support :
212+ if self . tra_support > 0 and ( support is None or support < self .tra_support ) :
200213 self .stats ["drop_tra_lowsupport" ] += 1
201214 continue
202215 if not (self ._on_primary (c1 ) and self ._on_primary (c2 )):
@@ -230,7 +243,7 @@ def extract(self):
230243 if span < self .intra_min_span :
231244 self .stats ["drop_intra_small" ] += 1
232245 continue
233- if support is None or support < self .intra_support :
246+ if self . intra_support > 0 and ( support is None or support < self .intra_support ) :
234247 self .stats ["drop_intra_lowsupport" ] += 1
235248 continue
236249 if not (1 <= p1 <= self .chrom_sizes [c1 ] and 1 <= p2 <= self .chrom_sizes [c1 ]):
@@ -290,9 +303,18 @@ def plot(
290303
291304 circos = Circos (sectors = self .chrom_sizes , space = 2 )
292305 chr_r = (97 , 100 )
293- dens_r = (84 , 95 )
306+ ins_r = (94.5 , 96.5 ) # INS marker ticks (only used when ins_markers present)
307+ dens_r = (83 , 93.5 )
294308 link_r = 82
295309
310+ # INS positions per chromosome for the marker track.
311+ ins_per_chrom = defaultdict (list )
312+ for c , p in self .ins_markers :
313+ ins_per_chrom [c ].append (p )
314+ # Half-width (bp) of each INS tick, so a single insertion stays visible at
315+ # genome scale (a raw 30 bp INS would be invisible otherwise).
316+ ins_tick_halfwidth = 1_000_000
317+
296318 for sector in circos .sectors :
297319 chrom = sector .name
298320 ct = sector .add_track (chr_r )
@@ -314,6 +336,16 @@ def plot(
314336 dt .bar (mids , counts , width = self .bin_size * 0.9 , vmin = 0 , vmax = max_count ,
315337 color = "#4D4D4D" , ec = "#4D4D4D" , lw = 0 , align = "center" )
316338
339+ # INS marker track: one orange tick per insertion (fixed-width so
340+ # small insertions remain visible; never folded into density here).
341+ ins_pos = ins_per_chrom .get (chrom , [])
342+ if ins_pos :
343+ it = sector .add_track (ins_r )
344+ for p in ins_pos :
345+ start = max (1 , p - ins_tick_halfwidth )
346+ end = min (sector .size , p + ins_tick_halfwidth )
347+ it .rect (start , end , fc = INS_COLOR , ec = INS_COLOR , lw = 0 )
348+
317349 def draw (subset , alpha , lw , height ):
318350 for r in subset :
319351 color = SV_COLORS .get (r ["svtype" ], "#999999" )
@@ -332,16 +364,20 @@ def draw(subset, alpha, lw, height):
332364 n_tra = sum (1 for r in self .links if r ["svtype" ] == "TRA" )
333365 n_intra = n - n_tra
334366 n_bp = len (self .breakpoints )
367+ n_ins = len (self .ins_markers )
368+ ins_line = f"{ n_ins :,} INS markers\n " if n_ins else ""
335369 circos .text (
336370 f"{ sample_name } \n { n :,} links\n (intra { n_intra :,} / TRA { n_tra :,} )\n "
337- f"{ n_bp :,} breakpoints\n bin ={ self .bin_size / 1e6 :g} Mb" ,
371+ f"{ n_bp :,} breakpoints\n { ins_line } bin ={ self .bin_size / 1e6 :g} Mb" ,
338372 r = 22 , size = 9 , ha = "center" , va = "center" ,
339373 )
340374
341375 fig = circos .plotfig ()
342376 present = [t for t in ("DEL" , "DUP" , "INV" , "TRA" )
343377 if any (r ["svtype" ] == t for r in self .links )]
344378 handles = [Line2D ([0 ], [0 ], color = SV_COLORS [t ], lw = 2 , label = t ) for t in present ]
379+ if self .ins_markers :
380+ handles .append (Line2D ([0 ], [0 ], color = INS_COLOR , lw = 4 , label = "INS" ))
345381 handles .append (Line2D ([0 ], [0 ], color = "#4D4D4D" , lw = 4 ,
346382 label = f"{ self .bin_size / 1e6 :g} Mb breakpoint count" ))
347383 circos .ax .legend (handles = handles , loc = "lower center" , bbox_to_anchor = (0.5 , - 0.07 ),
0 commit comments