def _ang(bp, L): return math.pi/2 - 2*math.pi*(bp/L)
def _pt(bp, r, L):
a = _ang(bp, L); return r*math.cos(a), r*math.sin(a)
def _arc(s, e, r, L, n=240):
if e < s: e += L
bps = np.linspace(s, e, max(2, int(n*(e-s)/L)+2))
return ([r*math.cos(_ang(b, L)) for b in bps],
[r*math.sin(_ang(b, L)) for b in bps])
def gc_percent(seq):
s = str(seq).upper(); n = len(s) or 1
return 100.0 * (s.count("G") + s.count("C")) / n
def circular_map(rec, title=None, show_gc=True):
L = len(rec.seq); feats = norm_features(rec)
fig, ax = plt.subplots(figsize=(8, 8)); R = 1.0
if show_gc:
w = max(30, L // 120); step = max(1, w // 2); mean = gc_percent(rec.seq)
s = str(rec.seq).upper()
for i in range(0, L, step):
win = s[i:i+w] or s[i:] + s[:(i+w) % L]
dev = (gc_percent(win) - mean) / 100.0
rr = 0.72 + dev * 0.9
x0, y0 = _pt(i, 0.72, L); x1, y1 = _pt(i, rr, L)
ax.plot([x0, x1], [y0, y1],
color="#2e86ab" if dev >= 0 else "#d1495b", lw=1, alpha=0.5, zorder=1)
xs, ys = _arc(0, L, R, L); ax.plot(xs, ys, color="#333", lw=2.5, zorder=2)
step = max(1, round(L/12/100)*100) or max(1, L//12)
for t in range(0, L, step):
x0, y0 = _pt(t, R*1.015, L); x1, y1 = _pt(t, R*1.045, L)
ax.plot([x0, x1], [y0, y1], color="#999", lw=1, zorder=2)
lx, ly = _pt(t, R*1.10, L)
ax.text(lx, ly, f"{t:,}", ha="center", va="center", fontsize=7, color="#777")
for f in feats:
outer = f["strand"] >= 0
rr = R + 0.09 if outer else R - 0.09
xs, ys = _arc(f["start"], f["end"], rr, L)
ax.plot(xs, ys, color=f["color"], lw=10, solid_capstyle="butt", alpha=0.9, zorder=3)
tip = f["end"] if outer else f["start"]
a = _ang(tip, L); tx, ty = rr*math.cos(a), rr*math.sin(a)
d = -1 if outer else 1
tanx, tany = -math.sin(a)*d, math.cos(a)*d
px, py = math.cos(a), math.sin(a)
ln, wd = 0.055, 0.052
ax.add_patch(Polygon([(tx+tanx*ln, ty+tany*ln),
(tx+px*wd, ty+py*wd),
(tx-px*wd, ty-py*wd)],
color=f["color"], zorder=4))
span = (f["end"] - f["start"]) % L
mid = (f["start"] + span/2) % L
lx, ly = _pt(mid, (rr + 0.16) if outer else (rr - 0.16), L)
ax.text(lx, ly, f["label"], ha="center", va="center",
fontsize=8.5, color=f["color"], weight="bold", zorder=5)
circ = "circular" if rec.annotations.get("topology") == "circular" else "linear"
ax.text(0, 0.05, title or rec.name, ha="center", va="center", fontsize=15, weight="bold")
ax.text(0, -0.07, f"{L:,} bp · {gc_percent(rec.seq):.1f}% GC · {circ}",
ha="center", va="center", fontsize=9.5, color="#555")
ax.set_xlim(-1.45, 1.45); ax.set_ylim(-1.45, 1.45)
ax.set_aspect("equal"); ax.axis("off"); plt.tight_layout(); plt.show()