Skip to content

Commit fe9e23a

Browse files
committed
Open/filled data points based whether inside or outside exon
1 parent 39b2d8b commit fe9e23a

2 files changed

Lines changed: 101 additions & 11 deletions

File tree

src/gcnvplot/core.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,10 @@ def build_background(args: argparse.Namespace) -> int:
373373

374374

375375
def rows_for_plot(
376-
args: argparse.Namespace, region: Interval, background_summary: BackgroundSummary
376+
args: argparse.Namespace,
377+
region: Interval,
378+
background_summary: BackgroundSummary,
379+
transcript: TranscriptAnnotation | None = None,
377380
) -> list[dict[str, object]]:
378381
"""Join one sample with the background for plotting."""
379382
counts = parse_read_counts(args.read_counts)
@@ -413,6 +416,9 @@ def rows_for_plot(
413416
"bg_upper_norm": bg_upper,
414417
"background_n": int(bg["N"]),
415418
}
419+
row["overlaps_exon"] = transcript is None or any(
420+
overlaps(interval[1], interval[2], exon.start, exon.end) for exon in transcript.exons
421+
)
416422
row["signal"] = log2_ratio(sample_value, bg_median, 0.01)
417423
row["signal_lower"] = log2_ratio(bg_lower, bg_median, 0.01)
418424
row["signal_upper"] = log2_ratio(bg_upper, bg_median, 0.01)
@@ -505,7 +511,9 @@ def y_for(value: float) -> float:
505511
".grid { stroke: #d8dee4; stroke-width: 1; }",
506512
".ribbon { fill: #c8d2df; opacity: 0.85; }",
507513
".sample { fill: none; stroke: #127c78; stroke-width: 2.2; }",
508-
".point { fill: #127c78; stroke: white; stroke-width: 1.2; }",
514+
".point { stroke-width: 1.6; }",
515+
".point-filled { fill: #127c78; stroke: white; }",
516+
".point-open { fill: white; stroke: #127c78; }",
509517
".baseline { stroke: #5b6472; stroke-width: 2; stroke-dasharray: 6 5; }",
510518
".highlight-band { fill: #f59e0b; opacity: 0.28; stroke: #b45309; stroke-width: 1.1; }",
511519
".panel { fill: #ffffff; stroke: #d8dee4; stroke-width: 1.2; }",
@@ -564,8 +572,9 @@ def y_for(value: float) -> float:
564572
f"SIGNAL={fmt(float(row['signal']))}\n"
565573
f"BACKGROUND_N={row['background_n']}"
566574
)
575+
point_class = "point point-filled" if bool(row["overlaps_exon"]) else "point point-open"
567576
elements.append(
568-
f'<circle class="point" cx="{x:.2f}" cy="{y:.2f}" r="4.8">'
577+
f'<circle class="{point_class}" cx="{x:.2f}" cy="{y:.2f}" r="4.8">'
569578
f"<title>{html.escape(title_text)}</title></circle>"
570579
)
571580

@@ -770,12 +779,10 @@ def add_arrow(x_pos: float) -> None:
770779
elements[3] = f'<rect class="panel" x="{panel_x}" y="{panel_y}" width="{panel_width}" height="{panel_h}"/>'
771780
elements.extend(
772781
[
773-
f'<rect x="{panel_x + 16}" y="{legend_y - 13}" width="15" height="15" fill="#c8d2df" opacity="0.85"/>',
774-
f'<text class="panel-text" x="{panel_x + 40}" y="{legend_y}">background</text>',
775-
f'<line x1="{panel_x + 16}" y1="{legend_y + 23}" x2="{panel_x + 34}" y2="{legend_y + 23}" stroke="#5b6472" stroke-width="2" stroke-dasharray="6 5"/>',
776-
f'<text class="panel-text" x="{panel_x + 40}" y="{legend_y + 27}">expected baseline</text>',
777-
f'<circle class="point" cx="{panel_x + 24}" cy="{legend_y + 48}" r="5"/>',
778-
f'<text class="panel-text" x="{panel_x + 40}" y="{legend_y + 52}">sample signal</text>',
782+
f'<circle class="point point-filled" cx="{panel_x + 24}" cy="{legend_y}" r="5"/>',
783+
f'<text class="panel-text" x="{panel_x + 40}" y="{legend_y + 4}">Overlaps exon</text>',
784+
f'<circle class="point point-open" cx="{panel_x + 24}" cy="{legend_y + 24}" r="5"/>',
785+
f'<text class="panel-text" x="{panel_x + 40}" y="{legend_y + 28}">Outside exon</text>',
779786
"</svg>",
780787
]
781788
)
@@ -793,7 +800,7 @@ def plot_sample(args: argparse.Namespace) -> int:
793800
region = (transcript.contig, transcript.start, transcript.end)
794801

795802
background_summary = parse_background(args.background)
796-
rows = rows_for_plot(args, region, background_summary)
803+
rows = rows_for_plot(args, region, background_summary, transcript=transcript)
797804
if not rows:
798805
raise SystemExit("No intervals with background statistics overlap the selected region.")
799806

tests/test_cli.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ def svg_highlight_bands(svg: str) -> list[tuple[float, float, float, float]]:
7676
return bands
7777

7878

79+
def svg_point_class_by_interval(svg: str) -> dict[str, str]:
80+
"""Return point classes keyed by interval title."""
81+
classes: dict[str, str] = {}
82+
pattern = r'<circle class="([^"]*point[^"]*)"[^>]*><title>([^<]+)</title></circle>'
83+
for point_class, title_text in re.findall(pattern, svg):
84+
interval = title_text.split("&#10;", 1)[0]
85+
classes[interval] = point_class
86+
return classes
87+
88+
7989
def test_main_version(capsys: pytest.CaptureFixture[str]) -> None:
8090
with pytest.raises(SystemExit) as exc_info:
8191
cli.main(["--version"])
@@ -341,8 +351,9 @@ def test_plot_transcript_writes_exon_track_and_gene_name(
341351
assert "Exons" in svg
342352
assert "Highlight" in svg
343353
assert "log2(sample/background)" in svg
354+
assert "Overlaps exon" in svg
355+
assert "Outside exon" in svg
344356
assert ">2</text>" in svg
345-
assert ">background</text>" in svg
346357
assert svg.count('class="panel-separator"') == 2
347358
assert svg.index("Sample") < svg.index("Gene") < svg.index("Transcript") < svg.index("Region") < svg.index("Highlight") < svg.index("Exons")
348359
highlight_bands = svg_highlight_bands(svg)
@@ -358,6 +369,78 @@ def test_plot_transcript_writes_exon_track_and_gene_name(
358369
)
359370

360371

372+
def test_plot_transcript_uses_open_points_for_non_exonic_intervals(
373+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
374+
) -> None:
375+
background_path = tmp_path / "background.tsv"
376+
background_path.write_text(
377+
"\n".join(
378+
[
379+
"# normalization=median-of-ratios",
380+
"# baseline=median-positive-count",
381+
"# samples=2",
382+
"# lower_percentile=5",
383+
"# upper_percentile=95",
384+
"CONTIG\tSTART\tEND\tBASELINE_MEDIAN\tN\tBG_NORM_MEAN\tBG_NORM_MEDIAN\tBG_NORM_SD\tBG_NORM_P5\tBG_NORM_P95",
385+
"chr1\t100\t120\t10\t2\t10\t10\t0\t10\t10",
386+
"chr1\t121\t179\t20\t2\t20\t20\t0\t20\t20",
387+
"chr1\t180\t220\t30\t2\t30\t30\t0\t30\t30",
388+
]
389+
)
390+
+ "\n",
391+
encoding="utf-8",
392+
)
393+
sample_path = tmp_path / "sample.tsv"
394+
write_read_counts(
395+
sample_path,
396+
[
397+
("chr1", 100, 120, 12),
398+
("chr1", 121, 179, 18),
399+
("chr1", 180, 220, 33),
400+
],
401+
)
402+
gtf_path = tmp_path / "transcript.gtf.gz"
403+
write_gtf_gz(
404+
gtf_path,
405+
[
406+
'chr1\tsource\ttranscript\t100\t220\t.\t+\t.\tgene_id "GENE1"; gene_name "MYGENE"; transcript_id "TX1";',
407+
'chr1\tsource\texon\t100\t120\t.\t+\t.\tgene_id "GENE1"; gene_name "MYGENE"; transcript_id "TX1"; exon_number "1";',
408+
'chr1\tsource\texon\t180\t220\t.\t+\t.\tgene_id "GENE1"; gene_name "MYGENE"; transcript_id "TX1"; exon_number "2";',
409+
],
410+
)
411+
output_path = tmp_path / "plot.svg"
412+
413+
assert (
414+
cli.main(
415+
[
416+
"plot",
417+
"--read-counts",
418+
str(sample_path),
419+
"--background",
420+
str(background_path),
421+
"--transcript",
422+
"TX1",
423+
"--gtf",
424+
str(gtf_path),
425+
"--output",
426+
str(output_path),
427+
]
428+
)
429+
== 0
430+
)
431+
432+
svg = output_path.read_text(encoding="utf-8")
433+
point_classes = svg_point_class_by_interval(svg)
434+
assert point_classes["chr1:100-120"] == "point point-filled"
435+
assert point_classes["chr1:121-179"] == "point point-open"
436+
assert point_classes["chr1:180-220"] == "point point-filled"
437+
438+
assert capsys.readouterr().out == (
439+
"Plotted intervals: 3\n"
440+
f"Wrote: {output_path}\n"
441+
)
442+
443+
361444
def test_plot_reverse_transcript_arrows_do_not_touch_exons(
362445
tmp_path: Path, capsys: pytest.CaptureFixture[str]
363446
) -> None:

0 commit comments

Comments
 (0)