From 7194b38360c2e170e9b34c0184f14bca1aafe191 Mon Sep 17 00:00:00 2001 From: Neil Horner Date: Wed, 20 May 2026 12:31:06 +0000 Subject: [PATCH] [CW-7207] Volcanic --- bin/workflow_glue/report.py | 71 +- bin/workflow_glue/tests/common/test_report.py | 11 +- bin/workflow_glue/volcano.py | 1269 +++++++++++++++++ 3 files changed, 1339 insertions(+), 12 deletions(-) create mode 100644 bin/workflow_glue/volcano.py diff --git a/bin/workflow_glue/report.py b/bin/workflow_glue/report.py index 306a54b..fe5dbef 100644 --- a/bin/workflow_glue/report.py +++ b/bin/workflow_glue/report.py @@ -4,23 +4,36 @@ import json import math from pathlib import Path -from dominate.tags import div, h4, p, pre, strong +from bokeh.resources import INLINE as BOKEH_INLINE +from dominate.tags import div, h3, h4, p, pre, script, strong from dominate.util import raw from ezcharts.components import fastcat +from ezcharts.components.ezchart import EZChart from ezcharts.components.reports import labs +from ezcharts.components.theme import LAB_head_resources +from ezcharts.layout.resource import Resource as EZC_Resource from ezcharts.layout.snippets import Tabs from ezcharts.layout.snippets.table import DataTable import pandas as pd from .util import get_named_logger, wf_parser # noqa: ABS101 +from .volcano import volcano # noqa: ABS101 -def _find_table(directory, pattern): - matches = sorted(Path(directory).glob(pattern)) - return matches[0] if matches else None +def get_bokeh_widgets_js(): + """Return the inline Bokeh widgets JavaScript bundle.""" + widgets_index = BOKEH_INLINE.components_for("js").index("bokeh-widgets") + return raw(BOKEH_INLINE.js_raw[widgets_index]) + + +def get_bokeh_tables_js(): + """Return the inline Bokeh tables JavaScript bundle.""" + tables_index = BOKEH_INLINE.components_for("js").index("bokeh-tables") + return raw(BOKEH_INLINE.js_raw[tables_index]) def _read_table(path, **kwargs): + """Read a TSV file into a DataFrame, returning None if path is absent.""" if path is None or not Path(path).exists(): return None return pd.read_csv(path, sep="\t", **kwargs) @@ -56,6 +69,7 @@ def _format_ratio_value(value): def _cohort_summary(cohort_dir): + """Return cohort-level metrics and transcript class counts DataFrames.""" tx_meta = _read_table(Path(cohort_dir) / "transcript_metadata.tsv") if tx_meta is None: return None, None @@ -79,6 +93,7 @@ def _cohort_summary(cohort_dir): def _sample_summaries(samples_dir): + """Return a dict of per-sample metrics DataFrames keyed by sample name.""" summaries = {} for sample_dir in sorted(Path(samples_dir).iterdir()): if not sample_dir.is_dir(): @@ -104,6 +119,7 @@ def _sample_summaries(samples_dir): def _sqanti_tables(sqanti_dir): + """Return a dict of SQANTI3 classification summary DataFrames keyed by label.""" tables = {} for summary in sorted(Path(sqanti_dir).rglob("classification_summary.tsv")): label = summary.parent.name @@ -111,7 +127,8 @@ def _sqanti_tables(sqanti_dir): return tables -def _top_results(de_dir, filename, n=20): +def _contrast_results(de_dir, filename, n=None): + """Return a dict of per-contrast result DataFrames read from filename.""" tables = {} for contrast_dir in sorted(Path(de_dir).iterdir()): if not contrast_dir.is_dir(): @@ -119,7 +136,10 @@ def _top_results(de_dir, filename, n=20): table = _read_table(contrast_dir / filename) if table is None or table.empty: continue - tables[contrast_dir.name] = table.head(n) + data = table + if n is not None: + data = data.head(n) + tables[contrast_dir.name] = data return tables @@ -266,6 +286,10 @@ def main(args): args.params, args.versions, args.wf_version, + head_resources=[ + *LAB_head_resources, + EZC_Resource(func=get_bokeh_widgets_js, tag=script), + EZC_Resource(func=get_bokeh_tables_js, tag=script)] ) with open(args.metadata, "r") as handle: @@ -869,7 +893,9 @@ def main(args): with report.add_section("Differential gene expression", "DGE"): tabs = Tabs() - for contrast, table in _top_results(args.de_dir, "results_dge.tsv").items(): + for contrast, table in _contrast_results( + args.de_dir, "results_dge.tsv", n=20 + ).items(): with tabs.add_tab(contrast): # Check for contrast-specific warnings if de_qc and contrast in de_qc.get("contrasts", {}): @@ -900,9 +926,35 @@ def main(args): DataTable.from_pandas(table, use_index=False) + h3("Gene expression volcano Plot") + vol, class_table, selected_table = volcano(table) + EZChart(vol, width="100%", height="550") + raw(""" + + """) + with div(_class="volcano-table-grid"): + EZChart(class_table, width="100%", height="auto") + EZChart(selected_table, width="100%", height="auto") + with report.add_section("Differential transcript usage", "DTU"): tabs = Tabs() - dtu_tables = _top_results(args.de_dir, "results_dtu_transcript.tsv") + dtu_tables = _contrast_results( + args.de_dir, "results_dtu_transcript.tsv", n=20) for contrast in sorted(Path(args.de_dir).iterdir()): if not contrast.is_dir(): @@ -940,6 +992,9 @@ def main(args): dtu_tables[contrast_name], use_index=False, ) + h3("Transcript expression volcano Plot") + # EZChart(volcano(dtu_tables[contrast_name])) + else: p("No DTU results available for this contrast.") diff --git a/bin/workflow_glue/tests/common/test_report.py b/bin/workflow_glue/tests/common/test_report.py index 3a9f7e0..bda75f0 100644 --- a/bin/workflow_glue/tests/common/test_report.py +++ b/bin/workflow_glue/tests/common/test_report.py @@ -94,13 +94,16 @@ def _build_report_args(tmp_path, de_qc=None): contrast_dir.mkdir() _write( contrast_dir / "results_dge.tsv", - "GENEID\tlog2FoldChange\tpadj\n" - "gene1\t1.0\t0.01\n", + ( + "GENEID\tnewGeneClass\tgene_name\tbaseMean\tlog2FoldChange\tlfcSE" + "\tstat\tpvlaue\tpadj\n" + "gene1\tannotation\tgene2\t100.0\t1.0\t-0.02\t-8.5\t0.001\t0.05\n" + ) ) _write( contrast_dir / "results_dtu_transcript.tsv", - "featureID\tgroupID\tpadj\n" - "tx1\tgene1\t0.05\n", + "featureID\tgroupID\tlog2FoldChange\tpvalue\tpadj\texonBaseMean\n" + "tx1\tgene1\t1.0\t0.01\t0.05\t20.0\n" ) out_report = tmp_path / "wf-transcriptomes-report.html" diff --git a/bin/workflow_glue/volcano.py b/bin/workflow_glue/volcano.py new file mode 100644 index 0000000..a4115e9 --- /dev/null +++ b/bin/workflow_glue/volcano.py @@ -0,0 +1,1269 @@ +"""Interactive volcano plot visualization for differential expression analysis.""" + +from bokeh.events import Tap +from bokeh.layouts import column as bokeh_column, row as bokeh_row +from bokeh.models import ( + AutocompleteInput, + Button, + ColumnDataSource, + CustomJS, + CustomJSHover, + DataTable, + HoverTool, + HTMLTemplateFormatter, + LabelSet, + NumberFormatter, + Range1d, + Slider, + Span, + TableColumn, + TextInput, + Toggle +) +from bokeh.plotting import figure +from ezcharts.plots import BokehPlot +import numpy as np +import pandas as pd + +SIGNIFICANCE_CLASSES = { + "significant_high_effect": { + "color": "#d62728", + "marker": "circle", + "symbol": "●", + "label": "Significant, high effect", + }, + "significant_low_effect": { + "color": "#1f77b4", + "marker": "square", + "symbol": "■", + "label": "Significant, low effect", + }, + "non_significant_high_effect": { + "color": "#2ca02c", + "marker": "triangle", + "symbol": "▲", + "label": "Not significant, high effect", + }, + "non_significant_low_effect": { + "color": "#8a8a8a", + "marker": "x", + "symbol": "✕", + "label": "Not significant, low effect", + }, +} + + +def _class_attr(attr,): + return {k: v[attr] for k, v in SIGNIFICANCE_CLASSES.items()} + + +def _class_attr_list(attr): + return [v[attr] for k, v in SIGNIFICANCE_CLASSES.items()] + + +def _volcano_source_data(data, fold_threshold=1, p_threshold=0.05): + """Prepare the minimal browser payload for the volcano/MA plot.""" + data = data.copy() + input_columns = set(data.columns) + data["log2FoldChange"] = pd.to_numeric(data["log2FoldChange"], errors="coerce") + data["padj"] = pd.to_numeric(data["padj"], errors="coerce") + is_transcript_plot = "featureID" in data.columns + if is_transcript_plot: + data.rename(columns={ + "featureID": "TXNAME", + "groupID": "GENEID", + "exonBaseMean": "mean_expression" + }, + inplace=True) + identifier_column = "TXNAME" + + else: + identifier_column = "GENEID" + data.rename(columns={ + "baseMean": "mean_expression" + }, inplace=True) + + if identifier_column not in data.columns: + raise ValueError( + "Volcano plot data must contain 'TXNAME' for transcript plots or " + "'GENEID' for gene plots." + ) + + if "mean_expression" not in data.columns: + raise ValueError( + "Volcano/MA plot data must contain 'baseMean' or 'exonBaseMean' column." + ) + + data["mean_expression"] = pd.to_numeric( + data['mean_expression'], errors="coerce" + ) + + has_gene_name = "gene_name" in data.columns + if not has_gene_name: + data["gene_name"] = "" + + data["gene_group"] = data["GENEID"] if "GENEID" in data.columns else "" + if is_transcript_plot: + if "gene_name" in data.columns: + named_group = data["gene_name"].astype(bool) + data.loc[named_group, "gene_group"] = data.loc[named_group, "gene_name"] + else: + named_group = data["GENEID"].astype(bool) + data.loc[named_group, "gene_group"] = data.loc[named_group, "GENEID"] + + data["selected_label"] = "" + + # Transformations and thresholds + data["neg_log10_padj"] = -np.log10(data["padj"]) + y_threshold = -np.log10(p_threshold) + outside_fold_threshold = data["log2FoldChange"].abs() >= fold_threshold + above_p_threshold = data["neg_log10_padj"] >= y_threshold + + # Setup initial significance classes + data["volcano_class"] = "non_significant_low_effect" + data.loc[ + outside_fold_threshold & (data["neg_log10_padj"] < y_threshold), + "volcano_class", + ] = "non_significant_high_effect" + data.loc[ + outside_fold_threshold & above_p_threshold, + "volcano_class", + ] = "significant_high_effect" + data.loc[ + ~outside_fold_threshold & above_p_threshold, + "volcano_class", + ] = "significant_low_effect" + data["color"] = data["volcano_class"].map(_class_attr("color")) + data["marker"] = data["volcano_class"].map(_class_attr("marker")) + + # All the columns we need to send to the browser for interactivity and export + source_columns = [ + identifier_column, + "gene_group", + "gene_name", + "log2FoldChange", + "mean_expression", + "marker", + "neg_log10_padj", + "padj", + "selected_label", + "color", + "volcano_class", + ] + + if is_transcript_plot: + source_columns.insert(1, "GENEID") + + source_data = {} + for column in source_columns: + values = data[column] + if column in ( + "log2FoldChange", + "mean_expression", + "padj", + "neg_log10_padj", + ): + values = values.astype(np.float32) + source_data[column] = values.reset_index(drop=True) + + export_columns = [identifier_column] + if is_transcript_plot: + export_columns.append("GENEID") + if "gene_name" in input_columns: + export_columns.append("gene_name") + export_columns.extend([ + "log2FoldChange", + "mean_expression", + "padj", + "volcano_class", + ]) + + return source_data, export_columns + + +def _tap_selection_callback_code(x_field, y_field): + """Build the shared JS tap-selection callback for volcano-style plots.""" + return f""" + const data = source.data; + const tapX = cb_obj.sx; + const tapY = cb_obj.sy; + const maxPixelDistanceSquared = 20 * 20; + function findPlotView(view, plotId) {{ + if (!view) {{ + return null; + }} + if (view.model && view.model.id === plotId && view.frame) {{ + return view; + }} + const childViews = view.child_views || view._child_views; + if (!childViews) {{ + return null; + }} + const children = childViews.values ? childViews.values() : childViews; + for (const child of children) {{ + const found = findPlotView(child, plotId); + if (found) {{ + return found; + }} + }} + return null; + }} + function selectNearestPointByPixelDistance() {{ + let plotView = Bokeh.index[plot.id]; + if (!plotView || !plotView.frame) {{ + for (const rootView of Object.values(Bokeh.index)) {{ + plotView = findPlotView(rootView, plot.id); + if (plotView) {{ + break; + }} + }} + }} + if ( + !plotView + || !Number.isFinite(tapX) + || !Number.isFinite(tapY) + ) {{ + return; + }} + const xScale = plotView.frame.x_scale; + const yScale = plotView.frame.y_scale; + let bestIndex = -1; + let bestDistance = Infinity; + let i = 0; + while (i !== data.{x_field}.length) {{ + const pointX = xScale.compute(data.{x_field}[i]); + const pointY = yScale.compute(data.{y_field}[i]); + if (!Number.isFinite(pointX) || !Number.isFinite(pointY)) {{ + i += 1; + continue; + }} + const dx = pointX - tapX; + const dy = pointY - tapY; + const distance = dx * dx + dy * dy; + if (Math.sign(bestDistance - distance) === 1) {{ + bestDistance = distance; + bestIndex = i; + }} + i += 1; + }} + if (bestIndex !== -1 && bestDistance <= maxPixelDistanceSquared) {{ + if (gene_select_toggle.active) {{ + const geneGroup = data.gene_group[bestIndex]; + const selected = []; + let j = 0; + while (j !== data.gene_group.length) {{ + if (data.gene_group[j] === geneGroup) {{ + selected.push(j); + }} + j += 1; + }} + selection_state.data.indices[0] = selected; + }} else {{ + const selected = new Set(selection_state.data.indices[0]); + selected.add(bestIndex); + selection_state.data.indices[0] = Array.from(selected).sort( + function(a, b) {{ + return a - b; + }}); + }} + update_callback.execute(source); + }} + }} + selectNearestPointByPixelDistance(); + """ + + +def volcano(data, fold_threshold=1, p_threshold=0.05): + """Build an interactive volcano plot with selection and filtering widgets.""" + source_data, original_columns = _volcano_source_data( + data, fold_threshold=fold_threshold, p_threshold=p_threshold + ) + is_transcript_plot = "TXNAME" in source_data + identifier_col = "TXNAME" if is_transcript_plot else "GENEID" + has_gene_name = "gene_name" in data.columns + counts = pd.Series(source_data["volcano_class"]).value_counts().to_dict() + x_min = float(source_data["log2FoldChange"].min()) + x_max = float(source_data["log2FoldChange"].max()) + y_max = float(source_data["neg_log10_padj"].max()) + # Exclude the top 0.1% from the initial axis scaling to prevent outliers dominating + y_init_max = ( + np.nanpercentile( + source_data["neg_log10_padj"] + .replace([np.inf, -np.inf], np.nan), 99.9) + ) + mean_min = float(source_data["mean_expression"].min()) + mean_max = float(source_data["mean_expression"].max()) + if x_min == x_max: + x_min -= 1 + x_max += 1 + if y_max <= 0: + y_max = 1 + y_init_max = 1 + if mean_min == mean_max: + mean_min *= 0.9 + mean_max *= 1.1 + # Log scale requires strictly positive lower bound; baseMean can be 0 + _pos_means = source_data["mean_expression"][source_data["mean_expression"] > 0] + mean_min_log = float(_pos_means.min()) if len(_pos_means) > 0 else 0.01 + y_threshold = -np.log10(p_threshold) + + source = ColumnDataSource(source_data) + selection_state = ColumnDataSource({"indices": [[]]}) + highlight_source = ColumnDataSource({ + "log2FoldChange": [], + "neg_log10_padj": [], + "mean_expression": [], + "gene_name": [], + }) + selected_source = ColumnDataSource({ + "source_index": [], + "owner_id": [], + "remove": [], + "GENEID": [], + "TXNAME": [], + "gene_name": [], + "log2FoldChange": [], + "padj": [], + "volcano_class": [], + }) + legend_source = ColumnDataSource({ + "symbol": _class_attr_list("symbol"), + "color": _class_attr_list("color"), + "label": _class_attr_list("label"), + "count": [counts.get(k, 0) for k in SIGNIFICANCE_CLASSES], + }) + + hover_content_formatter = CustomJSHover( + args=dict( + source=source, + identifier_col=identifier_col, + is_transcript_plot=is_transcript_plot, + has_gene_name=has_gene_name, + ), + code=""" + const n = special_vars.indices.length; + if (n > 3) { + if (special_vars.index !== special_vars.indices[0]) { return ''; } + return ( + '' + + n + ' points \u2014 zoom in to resolve' + + '' + ); + } + if (!n) { return ''; } + const data = source.data; + + function fmtPadj(v) { + const num = Number(v); + if (!isFinite(num)) { return String(v); } + if (num < 0.0001) { return num.toExponential(4); } + return num.toFixed(4); + } + + const th = 'style="text-align:right;padding-right:4px;color:#666;"'; + function row(label, value, valStyle) { + const td = valStyle + ? '' + value + '' + : '' + value + ''; + return '' + label + '' + td + ''; + } + function renderPoint(i) { + const idLabel = is_transcript_plot ? 'TX' : 'Gene'; + const idValue = is_transcript_plot + ? String(data.TXNAME ? data.TXNAME[i] || '' : '') + : (has_gene_name + ? String(data.gene_name[i] || '') + : String(data.GENEID ? data.GENEID[i] || '' : '')); + let h = ''; + h += row(idLabel, idValue, 'color:#6bb5d6;font-weight:bold;'); + if (is_transcript_plot) { + const gene = has_gene_name + ? String(data.gene_name[i] || '') + : String(data.GENEID ? data.GENEID[i] || '' : ''); + h += row('Gene', gene); + } + h += row('log2FC', Number(data.log2FoldChange[i]).toFixed(3)); + h += row('Mean expr', Number(data.mean_expression[i]).toFixed(3)); + h += row('padj', fmtPadj(data.padj[i])); + h += row('Class', String(data.volcano_class[i] || '')); + h += '
'; + return h; + } + + return renderPoint(special_vars.indices[0]); + """, + ) + hover = HoverTool( + tooltips=f"@{identifier_col}{{custom}}", + formatters={f"@{identifier_col}": hover_content_formatter}, + ) + + vol_fig = figure( + x_axis_label="log2 fold change", + y_axis_label="-log10 adjusted p-value", + height=480, + sizing_mode="stretch_width", + tools="pan,wheel_zoom,box_zoom,reset,save", + x_range=Range1d(x_min, x_max, bounds=(x_min, x_max)), + y_range=Range1d(0, y_init_max, bounds=(0, y_max)), + ) + vol_fig.toolbar.logo = None + + points_renderer = vol_fig.scatter( + x="log2FoldChange", + y="neg_log10_padj", + marker="marker", + source=source, + color="color", + size=7, + fill_alpha=0.25, + line_alpha=0.40, + selection_color="#111111", + selection_alpha=0.9, + nonselection_alpha=0.12, + ) + hover.renderers = [points_renderer] + hover.callback = CustomJS( + args=dict(selected_source=selected_source), + code=""" + const hovered = cb_data.index.indices; + if (!hovered.length || hovered.length > 3) { + selected_source.selected.indices = []; + selected_source.selected.change.emit(); + selected_source.change.emit(); + return; + } + const sourceIndex = hovered[0]; + const selectedRows = []; + let i = 0; + while (i !== selected_source.data.source_index.length) { + if (selected_source.data.source_index[i] === sourceIndex) { + selectedRows.push(i); + break; + } + i += 1; + } + selected_source.selected.indices = selectedRows; + selected_source.selected.change.emit(); + selected_source.change.emit(); + """, + ) + vol_fig.add_tools(hover) + vol_fig.circle( + x="log2FoldChange", + y="neg_log10_padj", + source=highlight_source, + size=15, + fill_alpha=0, + line_alpha=1, + line_color="#111111", + line_width=2, + ) + labels = LabelSet( + x="log2FoldChange", + y="neg_log10_padj", + text="selected_label", + source=source, + x_offset=7, + y_offset=7, + text_font_size="10px", + text_color="#111111", + ) + vol_fig.add_layout(labels) + + ma_hover = HoverTool( + tooltips=f"@{identifier_col}{{custom}}", + formatters={f"@{identifier_col}": hover_content_formatter}, + ) + ma_fig = figure( + x_axis_label="mean expression", + y_axis_label="log2 fold change", + height=480, + sizing_mode="stretch_width", + tools="pan,wheel_zoom,box_zoom,reset,save", + x_axis_type="log", + x_range=Range1d(mean_min_log, mean_max, bounds=(mean_min_log, mean_max)), + y_range=Range1d(x_min, x_max, bounds=(x_min, x_max)), + ) + ma_fig.toolbar.logo = None + ma_fig.visible = False + ma_points_renderer = ma_fig.scatter( + x="mean_expression", + y="log2FoldChange", + marker="marker", + source=source, + color="color", + size=7, + fill_alpha=0.25, + line_alpha=0.40, + selection_color="#111111", + selection_alpha=0.9, + nonselection_alpha=0.12, + ) + ma_hover.renderers = [ma_points_renderer] + ma_hover.callback = hover.callback + ma_fig.add_tools(ma_hover) + ma_fig.circle( + x="mean_expression", + y="log2FoldChange", + source=highlight_source, + size=15, + fill_alpha=0, + line_alpha=1, + line_color="#111111", + line_width=2, + ) + ma_labels = LabelSet( + x="mean_expression", + y="log2FoldChange", + text="selected_label", + source=source, + x_offset=7, + y_offset=7, + text_font_size="10px", + text_color="#111111", + ) + ma_fig.add_layout(ma_labels) + ma_zero_line = Span( + location=0, + dimension="width", + line_dash="dashed", + line_color="#444444", + line_width=1, + ) + ma_fig.add_layout(ma_zero_line) + + # Setup the selection table + table_columns = [ + TableColumn( + field="remove", + title="", + width=28, + sortable=False, + formatter=HTMLTemplateFormatter( + template=( + '' + ), + ), + ), + ] + if is_transcript_plot: + table_columns.extend([ + TableColumn(field="TXNAME", title="TXNAME"), + TableColumn(field="GENEID", title="GENEID"), + ]) + else: + table_columns.append(TableColumn(field="GENEID", title="GENEID")) + if has_gene_name: + table_columns.append(TableColumn(field="gene_name", title="gene_name")) + table_columns.extend([ + TableColumn( + field="log2FoldChange", + title="log2FC", + formatter=NumberFormatter(format="0.000"), + ), + TableColumn( + field="padj", + title="padj", + formatter=NumberFormatter(format="0.000e"), + ), + ]) + selected_table = DataTable( + source=selected_source, + columns=table_columns, + autosize_mode="force_fit", + height=310, + index_position=None, + ) + legend_table = DataTable( + source=legend_source, + columns=[ + TableColumn( + field="label", + title="Class", + width=150, + formatter=HTMLTemplateFormatter( + template=( + '<%= symbol %><%= value %>' + ), + ), + ), + TableColumn(field="count", title="n", width=50), + ], + height=130, + autosize_mode="force_fit", + index_position=None, + editable=False, + sortable=False, + styles={"padding-bottom": "4px"}, + ) + view_toggle = Toggle( + label="Toggle highlights", + button_type="default", + active=True, + width=120, + ) + gene_select_toggle = Toggle( + label="Select by gene", + button_type="default", + active=False, + width=120, + visible=is_transcript_plot, + ) + plot_mode_toggle = Button( + label="Show MA plot", + button_type="default", + width=120, + ) + left_fold_line = Span( + location=-fold_threshold, + dimension="height", + line_dash="dashed", + line_color="#444444", + line_width=1, + ) + right_fold_line = Span( + location=fold_threshold, + dimension="height", + line_dash="dashed", + line_color="#444444", + line_width=1, + ) + p_line = Span( + location=y_threshold, + dimension="width", + line_dash="dashed", + line_color="#444444", + line_width=1, + ) + vol_fig.add_layout(left_fold_line) + vol_fig.add_layout(right_fold_line) + vol_fig.add_layout(p_line) + + fold_slider = Slider( + title="Absolute log2 fold-change threshold", + value=fold_threshold, + start=0, + end=max(5, np.ceil(np.abs(source_data["log2FoldChange"]).max())), + step=0.1, + show_value=False, + sizing_mode="stretch_width", + ) + fold_input = TextInput( + title="", + value=f"{fold_threshold:.1f}", + width=80, + ) + p_slider = Slider( + title="Adjusted p-value threshold", + value=y_threshold, + start=0, + end=max(5, np.ceil(y_max)), + step=0.1, + show_value=False, + sizing_mode="stretch_width", + ) + p_input = TextInput( + title="", + value=f"{p_threshold:.3f}" if p_threshold >= 0.001 else f"{p_threshold:.2e}", + width=80, + ) + # Setup callbacks for interactivity + update_callback = CustomJS( + args=dict( + source=source, + selection_state=selection_state, + highlight_source=highlight_source, + selected_source=selected_source, + legend_source=legend_source, + fold_slider=fold_slider, + fold_input=fold_input, + p_slider=p_slider, + p_input=p_input, + left_fold_line=left_fold_line, + right_fold_line=right_fold_line, + p_line=p_line, + colors=_class_attr("color"), + markers=_class_attr("marker"), + labels=_class_attr("label"), + view_toggle=view_toggle, + identifier_col=identifier_col, + ), + code=""" + const data = source.data; + const fold = fold_slider.value; + const yThreshold = p_slider.value; + const pThreshold = Math.pow(10, -yThreshold); + fold_input.value = fold.toFixed(1); + const pThresholdLabel = pThreshold >= 0.001 + ? pThreshold.toFixed(3) + : pThreshold.toExponential(2); + p_input.value = pThresholdLabel; + const log2fc = data.log2FoldChange; + const negLog10Padj = data.neg_log10_padj; + const color = data.color; + const marker = data.marker; + const volcanoClass = data.volcano_class; + const selectedLabel = data.selected_label; + const counts = { + significant_high_effect: 0, + significant_low_effect: 0, + non_significant_high_effect: 0, + non_significant_low_effect: 0, + }; + + let i = 0; + while (i !== log2fc.length) { + const outsideFold = Math.sign(Math.abs(log2fc[i]) - fold) !== -1; + const aboveP = Math.sign(negLog10Padj[i] - yThreshold) !== -1; + let klass; + if (aboveP) { + if (outsideFold) { + klass = "significant_high_effect"; + } else { + klass = "significant_low_effect"; + } + } else { + if (outsideFold) { + klass = "non_significant_high_effect"; + } else { + klass = "non_significant_low_effect"; + } + } + volcanoClass[i] = klass; + counts[klass] += 1; + color[i] = colors[klass]; + marker[i] = markers[klass]; + i += 1; + } + const selected = Array.from( + new Set(selection_state.data.indices[0]) + ).sort(function(a, b) { + return a - b; + }); + selection_state.data.indices[0] = selected; + source.selected.indices = []; + + const selectedData = { + source_index: [], + owner_id: [], + remove: [], + GENEID: [], + TXNAME: [], + gene_name: [], + log2FoldChange: [], + padj: [], + volcano_class: [], + }; + i = 0; + while (i !== selectedLabel.length) { + selectedLabel[i] = ""; + i += 1; + } + selected.forEach(function(index) { + if (view_toggle.active) { + selectedLabel[index] = \ + data.gene_name[index] || data[identifier_col][index]; + } + selectedData.source_index.push(index); + selectedData.owner_id.push(selected_source.id); + selectedData.remove.push("x"); + selectedData.GENEID.push(data.GENEID ? data.GENEID[index] : ""); + selectedData.TXNAME.push(data.TXNAME ? data.TXNAME[index] : ""); + selectedData.gene_name.push(data.gene_name[index]); + selectedData.log2FoldChange.push(data.log2FoldChange[index]); + selectedData.padj.push(data.padj[index]); + selectedData.volcano_class.push(data.volcano_class[index]); + }); + selected_source.data = selectedData; + if (view_toggle.active) { + const highlightData = { + log2FoldChange: [], + neg_log10_padj: [], + mean_expression: [], + gene_name: [], + }; + selected.forEach(function(index) { + highlightData.log2FoldChange.push(data.log2FoldChange[index]); + highlightData.neg_log10_padj.push(data.neg_log10_padj[index]); + highlightData.mean_expression.push(data.mean_expression[index]); + highlightData.gene_name.push(data.gene_name[index]); + }); + highlight_source.data = highlightData; + } + + left_fold_line.location = -fold; + right_fold_line.location = fold; + p_line.location = yThreshold; + legend_source.data.count = [ + counts.significant_high_effect, + counts.significant_low_effect, + counts.non_significant_high_effect, + counts.non_significant_low_effect, + ]; + legend_source.change.emit(); + source.change.emit(); + selection_state.change.emit(); + highlight_source.change.emit(); + selected_source.change.emit(); + """, + ) + vol_fig.js_on_event(Tap, CustomJS( + args=dict( + plot=vol_fig, + source=source, + selection_state=selection_state, + gene_select_toggle=gene_select_toggle, + update_callback=update_callback, + ), + code=_tap_selection_callback_code("log2FoldChange", "neg_log10_padj"), + )) + ma_fig.js_on_event(Tap, CustomJS( + args=dict( + plot=ma_fig, + source=source, + selection_state=selection_state, + gene_select_toggle=gene_select_toggle, + update_callback=update_callback, + ), + code=_tap_selection_callback_code("mean_expression", "log2FoldChange"), + )) + fold_slider.js_on_change("value", update_callback) + fold_input.js_on_change("value", CustomJS( + args=dict( + fold_input=fold_input, + fold_slider=fold_slider, + update_callback=update_callback, + source=source, + ), + code=""" + const rawValue = fold_input.value.trim(); + if (!rawValue) { + return; + } + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return; + } + fold_slider.value = parsed; + update_callback.execute(source); + """, + )) + p_slider.js_on_change("value", update_callback) + p_input.js_on_change("value", CustomJS( + args=dict( + p_input=p_input, + p_slider=p_slider, + update_callback=update_callback, + source=source, + ), + code=""" + const rawValue = p_input.value.trim(); + if (!rawValue) { + return; + } + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) { + return; + } + p_slider.value = -Math.log10(parsed); + update_callback.execute(source); + """, + )) + view_toggle.js_on_change("active", CustomJS( + args=dict( + source=source, + selection_state=selection_state, + highlight_source=highlight_source, + view_toggle=view_toggle, + identifier_col=identifier_col, + ), + code=""" + const selectedLabel = source.data.selected_label; + let i = 0; + while (i !== selectedLabel.length) { + selectedLabel[i] = ""; + i += 1; + } + source.selected.indices = []; + if (view_toggle.active) { + const highlightData = { + log2FoldChange: [], + neg_log10_padj: [], + mean_expression: [], + gene_name: [], + }; + selection_state.data.indices[0].forEach(function(index) { + selectedLabel[index] = \ + source.data.gene_name[index] || source.data[identifier_col][index]; + highlightData.log2FoldChange.push(source.data.log2FoldChange[index]); + highlightData.neg_log10_padj.push(source.data.neg_log10_padj[index]); + highlightData.mean_expression.push(source.data.mean_expression[index]); + highlightData.gene_name.push(source.data.gene_name[index]); + }); + highlight_source.data = highlightData; + } else { + highlight_source.data = { + log2FoldChange: [], + neg_log10_padj: [], + mean_expression: [], + gene_name: [], + }; + } + source.change.emit(); + highlight_source.change.emit(); + """, + )) + gene_select_toggle.js_on_change("active", CustomJS( + args=dict( + source=source, + selection_state=selection_state, + gene_select_toggle=gene_select_toggle, + update_callback=update_callback, + ), + code=""" + if (!gene_select_toggle.active) { + return; + } + const selected = selection_state.data.indices[0]; + if (!selected.length) { + return; + } + const geneGroup = source.data.gene_group[selected[0]]; + const groupIndices = []; + let i = 0; + while (i !== source.data.gene_group.length) { + if (source.data.gene_group[i] === geneGroup) { + groupIndices.push(i); + } + i += 1; + } + selection_state.data.indices[0] = groupIndices; + update_callback.execute(source); + """, + )) + plot_mode_toggle.js_on_click(CustomJS( + args=dict( + volcano_fig=vol_fig, + ma_fig=ma_fig, + plot_mode_toggle=plot_mode_toggle, + ), + code=""" + const showingVolcano = volcano_fig.visible; + volcano_fig.visible = !showingVolcano; + ma_fig.visible = showingVolcano; + plot_mode_toggle.label = showingVolcano + ? "Show volcano plot" + : "Show MA plot"; + """, + )) + selected_source.selected.js_on_change("indices", CustomJS( + args=dict( + source=source, + selected_source=selected_source, + highlight_source=highlight_source, + view_toggle=view_toggle, + ), + code=""" + if (view_toggle.active) { + return; + } + const rows = selected_source.selected.indices; + if (rows.length) { + const row = rows[0]; + const sourceIndex = selected_source.data.source_index[row]; + highlight_source.data = { + log2FoldChange: [source.data.log2FoldChange[sourceIndex]], + neg_log10_padj: [source.data.neg_log10_padj[sourceIndex]], + mean_expression: [source.data.mean_expression[sourceIndex]], + gene_name: [source.data.gene_name[sourceIndex]], + }; + } else { + highlight_source.data = { + log2FoldChange: [], + neg_log10_padj: [], + mean_expression: [], + gene_name: [], + }; + } + highlight_source.change.emit(); + """, + )) + selected_source.js_on_change("data", CustomJS( + args=dict( + source=source, + selection_state=selection_state, + selected_source=selected_source, + highlight_source=highlight_source, + gene_select_toggle=gene_select_toggle, + update_callback=update_callback, + ), + code=""" + const listenerKey = "volcano_delete_listener_" + selected_source.id; + if (!window[listenerKey]) { + window[listenerKey] = true; + document.addEventListener("click", function(event) { + const path = event.composedPath ? event.composedPath() : []; + const deleteButton = path.find(function(element) { + return element.classList + && element.classList.contains("volcano-row-delete"); + }); + if (!deleteButton) { + return; + } + if (deleteButton.dataset.selectedSourceId !== selected_source.id) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + const sourceIndex = Number(deleteButton.dataset.sourceIndex); + if (gene_select_toggle.active) { + selection_state.data.indices[0] = []; + } else { + const kept = []; + const selected = selection_state.data.indices[0]; + let i = 0; + while (i !== selected.length) { + const index = selected[i]; + if (index !== sourceIndex) { + kept.push(index); + } + i += 1; + } + selection_state.data.indices[0] = kept; + } + selected_source.selected.indices = []; + highlight_source.data = { + log2FoldChange: [], + neg_log10_padj: [], + mean_expression: [], + gene_name: [], + }; + update_callback.execute(source); + }); + } + """, + )) + + search_input = AutocompleteInput( + placeholder=( + "Search by gene/transcript…" if is_transcript_plot else "Search by gene…"), + completions=[], + min_characters=1, + width=240, + ) + search_input.js_on_change("value_input", CustomJS( + args=dict( + source=source, + search_input=search_input, + ), + code=""" + if (search_input.completions.length) { + return; + } + const data = source.data; + const completions = new Set(); + const fields = [data.gene_name, data.GENEID]; + if (data.TXNAME) { + fields.push(data.TXNAME); + } + fields.forEach(function(field) { + let i = 0; + while (i !== field.length) { + const value = field[i]; + if (value) { + completions.add(String(value)); + } + i += 1; + } + }); + search_input.completions = Array.from(completions).sort(); + """, + )) + search_callback = CustomJS( + args=dict( + source=source, + selection_state=selection_state, + search_input=search_input, + update_callback=update_callback, + ), + code=""" + const query = search_input.value.trim().toLowerCase(); + if (!query) { + return; + } + const data = source.data; + const currentSelected = new Set(selection_state.data.indices[0]); + let i = 0; + while (i !== data.gene_name.length) { + const fields = [data.gene_name, data.GENEID]; + if (data.TXNAME) { + fields.push(data.TXNAME); + } + let matched = false; + let fi = 0; + while (fi !== fields.length) { + const val = fields[fi][i]; + if (val && val.toLowerCase() === query) { + matched = true; + break; + } + fi += 1; + } + if (matched) { + currentSelected.add(i); + } + i += 1; + } + selection_state.data.indices[0] = Array.from(currentSelected).sort( + function(a, b) { return a - b; } + ); + search_input.value = ""; + update_callback.execute(source); + """, + ) + search_input.js_on_change("value", search_callback) + + select_significant_button = Button( + label="Select significant", + button_type="primary", + width=150, + ) + select_significant_button.js_on_click(CustomJS( + args=dict( + source=source, + selection_state=selection_state, + fold_slider=fold_slider, + p_slider=p_slider, + update_callback=update_callback, + ), + code=""" + const data = source.data; + const fold = fold_slider.value; + const yThreshold = p_slider.value; + const passing = []; + let i = 0; + while (i !== data.log2FoldChange.length) { + const outsideFold = Math.abs(data.log2FoldChange[i]) >= fold; + const aboveP = data.neg_log10_padj[i] >= yThreshold; + if (outsideFold && aboveP) { + passing.push(i); + } + i += 1; + } + selection_state.data.indices[0] = passing; + update_callback.execute(source); + """, + )) + + clear_button = Button( + label="Clear selection", + button_type="default", + width=130, + ) + clear_button.js_on_click(CustomJS( + args=dict( + source=source, + selection_state=selection_state, + update_callback=update_callback, + ), + code=""" + selection_state.data.indices[0] = []; + update_callback.execute(source); + """, + )) + + export_button = Button( + label="\u2B07 TSV", + button_type="light", + width=55 + ) + + export_button.js_on_click(CustomJS( + args=dict( + source=source, + selection_state=selection_state, + original_columns=original_columns, + filename=( + "selected_transcripts.tsv" + if is_transcript_plot + else "selected_genes.tsv" + ), + ), + code=""" + const indices = selection_state.data.indices[0]; + if (!indices.length) { + return; + } + const cols = original_columns; + const data = source.data; + const rows = [cols.join('\\t')]; + indices.forEach(function(i) { + rows.push(cols.map(function(col) { + const v = data[col] !== undefined ? data[col][i] : ''; + return v !== null && v !== undefined ? String(v) : ''; + }).join('\\t')); + }); + const blob = new Blob([rows.join('\\n') + '\\n'], { + type: 'text/tab-separated-values', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + """, + )) + + volcano_ma_plot = BokehPlot() + controls = bokeh_row( + fold_slider, fold_input, p_slider, p_input, sizing_mode="stretch_width") + toggle_row = bokeh_row( + plot_mode_toggle, view_toggle, + gene_select_toggle, select_significant_button + ) + volcano_ma_plot._fig = bokeh_column( + bokeh_row(controls, sizing_mode="stretch_width"), + bokeh_row( + bokeh_column( + toggle_row, + vol_fig, + ma_fig, + sizing_mode="stretch_width", + ), + sizing_mode="stretch_width", + ), + sizing_mode="stretch_width", + ) + + classes_table = BokehPlot() + classes_table._fig = bokeh_column( + legend_table, + sizing_mode="stretch_width", + styles={ + "padding-top": "4px", + "padding-left": "35px", + "padding-right": "20px" + }, + ) + selected_plot = BokehPlot() + selected_plot._fig = bokeh_column( + bokeh_row( + bokeh_row( + search_input, clear_button, export_button) + ), + selected_table, sizing_mode="stretch_width", + styles={ + "padding-top": "25px", + }) + return volcano_ma_plot, classes_table, selected_plot