From b3de1ff840c8521c39ce18e859535a415471fcb6 Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Mon, 30 Nov 2020 13:22:50 +0000 Subject: [PATCH 1/9] Add plotly functionality. Using Bokeh class implementation as starting point --- .../GraphicRecord/GraphicRecord.py | 3 +- .../GraphicRecord/PlotlyPlottableMixin.py | 196 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py diff --git a/dna_features_viewer/GraphicRecord/GraphicRecord.py b/dna_features_viewer/GraphicRecord/GraphicRecord.py index c04abe2..37f680e 100644 --- a/dna_features_viewer/GraphicRecord/GraphicRecord.py +++ b/dna_features_viewer/GraphicRecord/GraphicRecord.py @@ -15,9 +15,10 @@ from .MatplotlibPlottableMixin import MatplotlibPlottableMixin from .BokehPlottableMixin import BokehPlottableMixin +from .PlotlyPlottableMixin import PlotlyPlottableMixin -class GraphicRecord(MatplotlibPlottableMixin, BokehPlottableMixin): +class GraphicRecord(MatplotlibPlottableMixin, BokehPlottableMixin, PlotlyPlottableMixin): """Set of Genetic Features of a same DNA sequence, to be plotted together. Parameters diff --git a/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py new file mode 100644 index 0000000..96ec483 --- /dev/null +++ b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py @@ -0,0 +1,196 @@ +try: + import plotly.graph_objects as go + + PLOTLY_AVAILABLE = True +except ImportError: + PLOTLY_AVAILABLE = False + +try: + import pandas as pd + + PANDAS_AVAILABLE = True +except ImportError: + PANDAS_AVAILABLE = False + +import matplotlib.pyplot as plt + + +class PlotlyPlottableMixin: + def bokeh_feature_patch( + self, + start, + end, + strand, + figure_width=5, + width=0.4, + level=0, + arrow_width_inches=0.05, + **kwargs + ): + """Return a dict with points coordinates of a Bokeh Feature arrow. + + Parameters + ---------- + + start, end, strand + + """ + hw = width / 2.0 + x1, x2 = (start, end) if (strand >= 0) else (end, start) + bp_per_width = figure_width / self.sequence_length + delta = arrow_width_inches / bp_per_width + if strand >= 0: + head_base = max(x1, x2 - delta) + else: + head_base = min(x1, x2 + delta) + result = dict( + xs=[x1, x1, head_base, x2, head_base, x1], + ys=[e + level for e in [-hw, hw, hw, 0, -hw, -hw]], + ) + result.update(kwargs) + return result + + def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): + """Plot the graphic record using Plotly. + The returned fig object can be used in a Dash dashboard. + + Examples + -------- + + >>> + + + """ + if not PLOTLY_AVAILABLE: + raise ImportError("``plot_with_plotly`` requires plotly installed.") + if not PANDAS_AVAILABLE: + raise ImportError("``plot_with_plotly`` requires plotly installed.") + + # Set up default tools + # if tools == "auto": + # tools = [HoverTool(tooltips="@hover_html"), "xpan,xwheel_zoom,reset,tap"] + + # FIRST PLOT WITH MATPLOTLIB AND GATHER INFOS ON THE PLOT + ax, (features_levels, plot_data) = self.plot(figure_width=figure_width) + width, height = [int(100 * e) for e in ax.figure.get_size_inches()] + plt.close(ax.figure) + if figure_height == "auto": + height = int(0.5 * height) + else: + height = 100 * figure_height + height = max(height, 185) # Minimal height to see all icons + + max_y = max( + [data["annotation_y"] for f, data in plot_data.items()] + + list(features_levels.values()) + ) + + patches_df = pd.DataFrame.from_records( + [ + self.bokeh_feature_patch( + feature.start, + feature.end, + feature.strand, + figure_width=figure_width, + level=level, + color=feature.color, + label=feature.label, + hover_html=( + feature.html + if feature.html is not None + else feature.label + ), + ) + for feature, level in features_levels.items() + ] + ) + + fig = go.Figure() + + # Update plot width and height + fig.update_layout( + autosize=False, + width=width, + height=height, + margin=dict(l=0, r=20, t=0, b=20) + ) + + # Update axes properties + fig.update_xaxes( + range=[0, self.sequence_length], + zeroline=False, + ) + + fig.update_yaxes( + range=[-1, max_y + 1], + zeroline=False, + showline=False, + showgrid=False, + visible=False, + ) + + # Add patches + for patch in patches_df.to_dict(orient="records"): + fig.add_trace( + go.Scatter( + x=patch["xs"], + y=patch["ys"], + fill="toself", + mode="lines", + name="", + text=patch["label"], + line_color="#000000", + fillcolor=patch["color"], + ) + ) + + if plot_data != {}: + + text_df = pd.DataFrame.from_records( + [ + dict( + x=feature.x_center, + y=pdata["annotation_y"], + text=feature.label, + color=feature.color, + ) + for feature, pdata in plot_data.items() + ] + ) + + segments_df = pd.DataFrame.from_records( + [ + dict( + x0=feature.x_center, + x1=feature.x_center, + y0=pdata["annotation_y"], + y1=pdata["feature_y"], + ) + for feature, pdata in plot_data.items() + ] + ) + + # Scatter trace of text labels + fig.add_trace(go.Scatter( + x=text_df["x"], + y=text_df["y"], + text=text_df["text"], + mode="text", + hoverinfo="skip", + textfont=dict(size=12, family="arial"), + textposition="middle center", + )) + + # Add segments + for seg in segments_df.to_dict(orient="records"): + fig.add_shape(type="line", + x0=seg["x0"], y0=seg["y0"], x1=seg["x1"], y1=seg["y1"], + line=dict(color="#000000", width=0.5) + ) + + fig.update_layout( + template="simple_white", + showlegend=False, + ) + + return fig From b800d3779621abc486c226b4abcd900032a77dad Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Mon, 30 Nov 2020 13:24:38 +0000 Subject: [PATCH 2/9] Add plotly example and output html --- examples/plot_with_plotly.html | 25 +++++++++++++++++++++++++ examples/plot_with_plotly.py | 9 +++++++++ 2 files changed, 34 insertions(+) create mode 100644 examples/plot_with_plotly.html create mode 100644 examples/plot_with_plotly.py diff --git a/examples/plot_with_plotly.html b/examples/plot_with_plotly.html new file mode 100644 index 0000000..e26cb9e --- /dev/null +++ b/examples/plot_with_plotly.html @@ -0,0 +1,25 @@ + + + +
+ + + +
+ +
+ + \ No newline at end of file diff --git a/examples/plot_with_plotly.py b/examples/plot_with_plotly.py new file mode 100644 index 0000000..a2c82ab --- /dev/null +++ b/examples/plot_with_plotly.py @@ -0,0 +1,9 @@ +"""Simple example with plotly output. Requires the plotly library installed. +""" + +from dna_features_viewer import BiopythonTranslator + +record = BiopythonTranslator().translate_record(record="example_sequence.gb") +plot = record.plot_with_plotly(figure_width=8) + +plot.write_html("plot_with_plotly.html", include_plotlyjs="cdn") From 61f129532013b6a6423a1ff31a3f4c98832e9b48 Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Mon, 30 Nov 2020 14:46:43 +0000 Subject: [PATCH 3/9] Add plotly tests, mirrored bokeh test --- tests/test_basics.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_basics.py b/tests/test_basics.py index 7f3324d..f0bae57 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -144,6 +144,28 @@ def test_plot_with_bokeh_no_labels(tmpdir): assert len(f.read()) > 5000 +def test_plot_with_plotly(tmpdir): + gb_record = SeqIO.read(example_genbank, "genbank") + record = BiopythonTranslator().translate_record(record=gb_record) + plot = record.plot_with_plotly(figure_width=8) + target_file = os.path.join(str(tmpdir), "plot_with_plotly.html") + plot.write_html(target_file, include_plotlyjs="cdn") + with open(target_file, "r") as f: + assert len(f.read()) > 5000 + + +def test_plot_with_plotly_no_labels(tmpdir): + gb_record = SeqIO.read(example_genbank, "genbank") + record = BiopythonTranslator().translate_record(record=gb_record) + for feature in record.features: + feature.label = None + plot = record.plot_with_plotly(figure_width=8) + target_file = os.path.join(str(tmpdir), "plot_with_plotly.html") + plot.write_html(target_file, include_plotlyjs="cdn") + with open(target_file, "r") as f: + assert len(f.read()) > 5000 + + def test_split_overflowing_features(): features = [ GraphicFeature(start=10, end=20, strand=+1, label="a"), From 21c25e2bd42ba7c28459e883ec0eb4da77ea7487 Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Mon, 30 Nov 2020 14:53:57 +0000 Subject: [PATCH 4/9] plotly readme update --- README.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 15afdb3..2d90a15 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,12 @@ If you intend to use the bokeh features, you need to also install Bokeh and Pand (sudo) pip install bokeh pandas +If you intend to use the plotly features, you need to also install Plotly and Pandas: + +.. code:: python + + (sudo) pip install plotly pandas + To parse GFF files, install the ``bcbio-gff`` library: .. code:: From 76e993f352215517574025feb4ac34bcf02bb1d4 Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Mon, 30 Nov 2020 15:35:00 +0000 Subject: [PATCH 5/9] Update travis yaml, plotly pip install --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 3131110..1f16982 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: install: - pip install coveralls pytest-cov==2.6 pytest==3.2.3 Biopython bcbio-gff - pip install bokeh pandas + - pip install plotly - pip install -e . # command to run tests script: From 8315b2a665a54c957b4743854c98f20c640c26da Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Tue, 1 Dec 2020 12:28:16 +0000 Subject: [PATCH 6/9] rename feature patch in plotly class --- dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py index 96ec483..4080ec8 100644 --- a/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py +++ b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py @@ -16,7 +16,7 @@ class PlotlyPlottableMixin: - def bokeh_feature_patch( + def plotly_feature_patch( self, start, end, @@ -27,7 +27,7 @@ def bokeh_feature_patch( arrow_width_inches=0.05, **kwargs ): - """Return a dict with points coordinates of a Bokeh Feature arrow. + """Return a dict with points coordinates of a plotly shape. Same as bokeh feature arrow Parameters ---------- @@ -87,7 +87,7 @@ def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): patches_df = pd.DataFrame.from_records( [ - self.bokeh_feature_patch( + self.plotly_feature_patch( feature.start, feature.end, feature.strand, From deed7560962c3555fdcdc4088d1140cf896ffe0f Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Tue, 1 Dec 2020 12:45:26 +0000 Subject: [PATCH 7/9] Remove unnecessary use of pandas df --- .../GraphicRecord/PlotlyPlottableMixin.py | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py index 4080ec8..d970c28 100644 --- a/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py +++ b/dna_features_viewer/GraphicRecord/PlotlyPlottableMixin.py @@ -85,26 +85,6 @@ def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): + list(features_levels.values()) ) - patches_df = pd.DataFrame.from_records( - [ - self.plotly_feature_patch( - feature.start, - feature.end, - feature.strand, - figure_width=figure_width, - level=level, - color=feature.color, - label=feature.label, - hover_html=( - feature.html - if feature.html is not None - else feature.label - ), - ) - for feature, level in features_levels.items() - ] - ) - fig = go.Figure() # Update plot width and height @@ -130,7 +110,21 @@ def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): ) # Add patches - for patch in patches_df.to_dict(orient="records"): + for feature, level in features_levels.items(): + patch = self.plotly_feature_patch( + feature.start, + feature.end, + feature.strand, + figure_width=figure_width, + level=level, + color=feature.color, + label=feature.label, + hover_html=( + feature.html + if feature.html is not None + else feature.label + ), + ) fig.add_trace( go.Scatter( x=patch["xs"], @@ -158,18 +152,6 @@ def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): ] ) - segments_df = pd.DataFrame.from_records( - [ - dict( - x0=feature.x_center, - x1=feature.x_center, - y0=pdata["annotation_y"], - y1=pdata["feature_y"], - ) - for feature, pdata in plot_data.items() - ] - ) - # Scatter trace of text labels fig.add_trace(go.Scatter( x=text_df["x"], @@ -182,9 +164,13 @@ def plot_with_plotly(self, figure_width=5, figure_height="auto", tools="auto"): )) # Add segments - for seg in segments_df.to_dict(orient="records"): - fig.add_shape(type="line", - x0=seg["x0"], y0=seg["y0"], x1=seg["x1"], y1=seg["y1"], + for feature, pdata in plot_data.items(): + fig.add_shape( + type="line", + x0=feature.x_center, + y0=pdata["annotation_y"], + x1=feature.x_center, + y1=pdata["feature_y"], line=dict(color="#000000", width=0.5) ) From cb2218a7e96f88286ac05c9adc7835565833039f Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Tue, 1 Dec 2020 13:15:53 +0000 Subject: [PATCH 8/9] Add tests for plotly and bokeh feature patch --- tests/test_basics.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_basics.py b/tests/test_basics.py index f0bae57..55b18ae 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -154,6 +154,34 @@ def test_plot_with_plotly(tmpdir): assert len(f.read()) > 5000 +def test_plotly_feature_patch(): + record = GraphicRecord(sequence_length=50, sequence="ATGCATGCAT") + patch = record.plotly_feature_patch( + start=10, + end=20, + strand=+1, + figure_width=8, + width=0.4, + level=1.0, + arrow_width_inches=0.05 + ) + assert patch == dict(xs=[10, 10, 19.6875, 20, 19.6875, 10], ys=[0.8, 1.2, 1.2, 1.0, 0.8, 0.8]) + + +def test_bokeh_feature_patch(): + record = GraphicRecord(sequence_length=50, sequence="ATGCATGCAT") + patch = record.bokeh_feature_patch( + start=10, + end=20, + strand=+1, + figure_width=8, + width=0.4, + level=1.0, + arrow_width_inches=0.05 + ) + assert patch == dict(xs=[10, 10, 19.6875, 20, 19.6875, 10], ys=[0.8, 1.2, 1.2, 1.0, 0.8, 0.8]) + + def test_plot_with_plotly_no_labels(tmpdir): gb_record = SeqIO.read(example_genbank, "genbank") record = BiopythonTranslator().translate_record(record=gb_record) From d3c991546b65717a7ef472958bf2c942e8de11d5 Mon Sep 17 00:00:00 2001 From: Adam Taylor Date: Tue, 1 Dec 2020 13:28:36 +0000 Subject: [PATCH 9/9] retrigger checks