"""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] = identifier_col === "TXNAME" ? data.TXNAME[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] = identifier_col === "TXNAME" ? source.data.TXNAME[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