From 4a3e3b31f96aff3f7a038f24ae8ae9f998d9ff42 Mon Sep 17 00:00:00 2001 From: chasem Date: Fri, 5 Jun 2026 12:40:26 -0500 Subject: [PATCH 1/8] basically done with requests. need to check content Co-Authored-By: Claude Sonnet 4.6 --- tfbpshiny/app.css | 11 + tfbpshiny/app.py | 25 +- tfbpshiny/components.py | 3 +- tfbpshiny/modules/binding/queries.py | 86 +- tfbpshiny/modules/binding/server/workspace.py | 616 ++++-- tfbpshiny/modules/binding/ui.py | 49 +- tfbpshiny/modules/comparison/queries.py | 25 +- .../modules/comparison/server/workspace.py | 1668 ++++++++++------- tfbpshiny/modules/comparison/ui.py | 127 +- tfbpshiny/modules/perturbation/queries.py | 79 +- .../modules/perturbation/server/workspace.py | 588 ++++-- tfbpshiny/modules/perturbation/ui.py | 46 +- tfbpshiny/utils/correlation_matrix.py | 117 ++ tfbpshiny/utils/topn_matrix.py | 117 ++ tfbpshiny/utils/vdb_init.py | 21 +- 15 files changed, 2388 insertions(+), 1190 deletions(-) create mode 100644 tfbpshiny/utils/correlation_matrix.py create mode 100644 tfbpshiny/utils/topn_matrix.py diff --git a/tfbpshiny/app.css b/tfbpshiny/app.css index 6ee6435..ed0ddac 100644 --- a/tfbpshiny/app.css +++ b/tfbpshiny/app.css @@ -329,3 +329,14 @@ max-width: 300px; text-align: left; } + +/* --- Pending-changes banner in binding/perturbation sidebars ------------- */ +.pending-banner { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + font-size: 0.85rem; + color: #664d03; +} diff --git a/tfbpshiny/app.py b/tfbpshiny/app.py index 38b04a6..3266f2e 100644 --- a/tfbpshiny/app.py +++ b/tfbpshiny/app.py @@ -72,12 +72,21 @@ ui.nav_panel("Dataset selection", ui.output_ui("selection_status"), _selection_ui), ui.nav_panel("Binding", ui.output_ui("binding_status"), _binding_ui), ui.nav_panel("Perturbation", ui.output_ui("perturbation_status"), _perturbation_ui), - ui.nav_panel("Comparison", ui.output_ui("comparison_status"), _comparison_ui), + ui.nav_panel( + "Binding/Perturbation Comparisons", + ui.output_ui("comparison_status"), + _comparison_ui, + ), ui.nav_spacer(), ui.nav_control(github_badge()), title="TF Binding & Perturbation Explorer", id="main_nav", - fillable=["Dataset selection", "Binding", "Perturbation", "Comparison"], + fillable=[ + "Dataset selection", + "Binding", + "Perturbation", + "Binding/Perturbation Comparisons", + ], navbar_options=ui.navbar_options(bg="#722F37", theme="dark"), header=ui.tags.head( ui.tags.script(src="plotly-3.5.0.min.js"), @@ -157,15 +166,9 @@ async def _init_task(config: str, token: str | None) -> Any: _init_task.invoke(virtualdb_config, hf_token) _preparing_ui = ui.div( - { - "style": "display:flex; align-items:center; justify-content:center;" - " padding: 2rem; color:#888; text-align:center;" - }, - ui.p( - "Preparing datasets. " - "This typically takes less than 5 seconds. " - "Thank you for your patience..." - ), + {"class": "pending-banner"}, + "Datasets loading. This typically takes less than 5 seconds. " + "Thank you for your patience.", ) def _status_panel(ready_content: ui.Tag | None = None) -> ui.Tag: diff --git a/tfbpshiny/components.py b/tfbpshiny/components.py index 99acc66..9776258 100644 --- a/tfbpshiny/components.py +++ b/tfbpshiny/components.py @@ -369,7 +369,8 @@ def matrix_cell( :param kind: One of ``"empty"``, ``"diagonal"``, or ``"interactive"``. :param button: A ``matrix_cell_button`` element. Required for ``"diagonal"`` and ``"interactive"``; ignored for ``"empty"``. - :param active: Marks the committed regulator filter pair. + :param active: Marks the selected item (e.g. committed regulator filter pair + or currently selected correlation pair). :param pending: Marks a queued regulator filter pair not yet committed. """ diff --git a/tfbpshiny/modules/binding/queries.py b/tfbpshiny/modules/binding/queries.py index feb02cd..9ed0a0b 100644 --- a/tfbpshiny/modules/binding/queries.py +++ b/tfbpshiny/modules/binding/queries.py @@ -7,52 +7,88 @@ import pandas as pd from labretriever import VirtualDB -# Map of db_name -> (effect_col, pvalue_col). -# pvalue_col is empty string for datasets that have no pvalue column. +# Map of db_name -> (effect_col, pvalue_col, log10p_col, neglog10p_col). +# Empty string means the column does not exist in that dataset. +# log10p_col: precomputed log10(pval) column (positive, not yet negated). +# neglog10p_col: precomputed -log10(pval) column (already negated). # TODO: this information should be moved to virtualdb config. -# it will mean that there will need t be a way to differentiate between -# metadata and full data fields -# Better: expose the role from the datacard through vdb. for quantitative measure -# fields, allow user to specify which field to use for what -# use vdb config to set defaults -DATASET_COLUMNS: dict[str, tuple[str, str]] = { - "callingcards": ("callingcards_enrichment", "poisson_pval"), - "harbison": ("effect", "pvalue"), - "rossi": ("enrichment", "poisson_pval"), - "chec_m2025": ("enrichment", "poisson_pval"), +DATASET_COLUMNS: dict[str, tuple[str, str, str, str]] = { + "callingcards": ("callingcards_enrichment", "poisson_pval", "", ""), + "harbison": ("effect", "pvalue", "", ""), + "rossi": ("enrichment", "poisson_pval", "log_poisson_pval", ""), + "chec_m2025": ("enrichment", "poisson_pval", "log_poisson_pval", ""), } +#: P-values below this floor are capped before -log10 is applied. +LOG10P_FLOOR = 1e-10 + def get_measurement_column( - db_name: str, which_measurement_field: Literal["effect", "pvalue"] + db_name: str, which_measurement_field: Literal["effect", "pvalue", "log10pval"] ) -> str: """ - The user can choose to use either the "effect" column or "pvalue" column for their - analysis. the effect/pvalue are mapped to db_name in BINDING_DATASET_COLUMNS. given - a db_name and. + Return the column to use for a dataset given the user's preference. + + For ``"log10pval"``, returns the most direct available column: + ``neglog10p_col`` > ``log10p_col`` > ``pvalue_col``. Falls back to the + effect column when no p-value variant exists. - If ``which_measurement_field`` is ``"pvalue"`` but the dataset has no pvalue column, - falls back to the effect column. + For ``"pvalue"``, returns ``pvalue_col`` when non-empty, else ``effect_col``. :param db_name: Dataset name (key in ``DATASET_COLUMNS``). - :param which_measurement_field: ``"effect"`` or ``"pvalue"``. + :param which_measurement_field: ``"effect"``, ``"pvalue"``, or ``"log10pval"``. :return: Column name to use in queries. :raises ValueError: If ``which_measurement_field`` is invalid. - :raises KeyError: If ``db_name`` is not in ``DATASET_COLUMNS`` + :raises KeyError: If ``db_name`` is not in ``DATASET_COLUMNS``. """ - if which_measurement_field not in ("effect", "pvalue"): + if which_measurement_field not in ("effect", "pvalue", "log10pval"): raise ValueError(f"Invalid measurement field: {which_measurement_field}") try: - effect_col, pvalue_col = DATASET_COLUMNS[db_name] + effect_col, pvalue_col, log10p_col, neglog10p_col = DATASET_COLUMNS[db_name] except KeyError as exc: raise KeyError(f"Unknown dataset name: {db_name}") from exc - if which_measurement_field == "pvalue" and pvalue_col: - return pvalue_col + if which_measurement_field == "log10pval": + return neglog10p_col or log10p_col or pvalue_col or effect_col + if which_measurement_field == "pvalue": + return pvalue_col or effect_col return effect_col +def get_log10p_source( + db_name: str, +) -> Literal["neglog10p", "log10p", "pval", "none"]: + """ + Return which source column provides the -log10(pval) value for a dataset. + + The caller uses this to determine what Python-side transform is needed after + the query returns: + + - ``"neglog10p"``: column is already ``-log10(pval)`` — apply upper cap only. + - ``"log10p"``: negate the column, then apply upper cap. + - ``"pval"``: apply ``-log10(clip(lower=LOG10P_FLOOR))``. + - ``"none"``: no p-value variant exists; -log10(pval) is unavailable. + + :param db_name: Dataset name (key in ``DATASET_COLUMNS``). + :return: Source indicator string. + + :raises KeyError: If ``db_name`` is not in ``DATASET_COLUMNS``. + + """ + try: + _, pvalue_col, log10p_col, neglog10p_col = DATASET_COLUMNS[db_name] + except KeyError as exc: + raise KeyError(f"Unknown dataset name: {db_name}") from exc + if neglog10p_col: + return "neglog10p" + if log10p_col: + return "log10p" + if pvalue_col: + return "pval" + return "none" + + def binding_data_query( db_name: str, col: str, @@ -472,7 +508,9 @@ def regulator_scatter_sql( __all__ = [ "DATASET_COLUMNS", + "LOG10P_FLOOR", "get_measurement_column", + "get_log10p_source", "binding_data_query", "_corr_pair_sql_impl", "corr_pair_sql", diff --git a/tfbpshiny/modules/binding/server/workspace.py b/tfbpshiny/modules/binding/server/workspace.py index d56b421..d5fa9a0 100644 --- a/tfbpshiny/modules/binding/server/workspace.py +++ b/tfbpshiny/modules/binding/server/workspace.py @@ -5,6 +5,7 @@ from logging import Logger from typing import Any, Literal +import numpy as np import pandas as pd import plotly.graph_objects as go from labretriever import VirtualDB @@ -18,10 +19,13 @@ from shinywidgets import output_widget, render_plotly from tfbpshiny.modules.binding.queries import ( + LOG10P_FLOOR, corr_all_pairs_sql, + get_log10p_source, get_measurement_column, regulator_scatter_sql, ) +from tfbpshiny.utils.correlation_matrix import build_correlation_matrix_ui from tfbpshiny.utils.perf import perf, reset_render_counts from tfbpshiny.utils.vdb_init import AppDatasets, get_regulator_display_name @@ -38,8 +42,9 @@ def binding_workspace_server( active_tab: reactive.Calc_[str] | None = None, ) -> None: """ - Render the binding correlation workspace: pairwise box plots and per-regulator - scatter plots, gated on an explicit Execute Analysis button. + Render the binding correlation workspace: correlation matrix, pair distribution + box plot, and per-regulator scatter plots, gated on an explicit Execute Analysis + button. :param active_binding_datasets: Reactive calc returning the list of active binding dataset names from the select-datasets module. @@ -66,25 +71,25 @@ def binding_workspace_server( # Currently selected regulator locus tag — shared across box and scatter renders. selected_reg: reactive.Value[str] = reactive.value("") - # Tracks how many box-plot pairs have completed their render_plotly call. - # Reset to 0 when Execute is clicked; incremented (under reactive.isolate) inside - # each _box_plot render so analysis_status can show a "building plots" message - # during the render phase that follows the task phase. - _boxes_rendered: reactive.Value[int] = reactive.value(0) - _boxes_expected: reactive.Value[int] = reactive.value(0) + # pending_pairs: toggled immediately by matrix cell clicks; drives the matrix + # highlight only. committed_pairs: set when Execute Analysis is clicked; + # drives box plots and scatter plots. Separating them means clicking cells is + # instant (no expensive renders triggered) and Execute gates all heavy work. + pending_pairs: reactive.Value[list[tuple[str, str]]] = reactive.value([]) + committed_pairs: reactive.Value[list[tuple[str, str]]] = reactive.value([]) - # Scatter render-phase tracking — same pattern as boxes but also resets when - # selected_reg changes so the "preparing" message reappears for each new regulator. + # Scatter render-phase tracking — resets when selected_reg changes so the + # "preparing" message reappears for each new regulator. # _scatter_epoch is bumped on each reset; each _scatter_plot closure captures the # epoch at render-start so stale completions from a previous reg don't count. _scatter_epoch: reactive.Value[int] = reactive.value(0) _scatter_rendered: reactive.Value[int] = reactive.value(0) _scatter_expected: reactive.Value[int] = reactive.value(0) - # Plain dicts mutated by render closures and _update_all_highlights. - # Not reactive — intentionally updated in-place to avoid triggering re-renders. - _box_widgets: dict[tuple[str, str], go.FigureWidget | None] = {} - _box_data: dict[tuple[str, str], dict] = {} + # Dicts keyed by pair for the active box FigureWidgets and their backing data. + # Mutated in-place by the box render factory and _highlight_one. + _pair_box_widgets: dict[tuple[str, str], go.FigureWidget | None] = {} + _pair_box_data: dict[tuple[str, str], dict] = {} # Stable pair list — updated only when the active dataset set actually changes. _active_pairs: reactive.Value[list[tuple[str, str]]] = reactive.value([]) @@ -111,6 +116,7 @@ def _snapshot_current() -> tuple: input.corr_type(), included, filters_repr, + tuple(sorted(pending_pairs())), ) @reactive.effect @@ -151,10 +157,15 @@ def execute_pending_style() -> ui.Tag: current = _snapshot_current() last = _last_run_snapshot() has_pending = (last is None) or (current != last) - if has_pending: - return ui.span() btn_id = session.ns("execute_analysis") - return ui.tags.style(f"#{btn_id} {{ opacity: 0.35; }}") + if has_pending: + return ui.div( + {"class": "pending-banner"}, + "Analysis options have changed. Click Execute Analysis to apply.", + ) + return ui.tags.style( + f"#{btn_id} {{ opacity: 0.35; pointer-events: none; cursor: not-allowed; }}" + ) # --- Execute Analysis task -------------------------------------------------- @@ -163,6 +174,7 @@ def execute_pending_style() -> ui.Tag: async def _run_analysis( pairs: list[tuple[str, str]], col_map: dict[str, str], + col_preference: str, filters: dict, method: str, ) -> dict: @@ -171,10 +183,12 @@ async def _run_analysis( :param pairs: Dataset pairs to compute. :param col_map: Mapping from db_name to measurement column name. + :param col_preference: User's column preference (``"effect"``, ``"pvalue"``, + or ``"log10pval"``). :param filters: Active dataset filters at execute time. :param method: Correlation method (``"pearson"`` or ``"spearman"``). - :returns: Dict with keys ``corr_data``, ``pairs``, ``col_map``, ``method``, - and ``filters``. + :returns: Dict with keys ``corr_data``, ``pairs``, ``col_map``, + ``col_preference``, ``method``, and ``filters``. """ empty_cols = [ @@ -190,6 +204,7 @@ async def _run_analysis( "corr_data": {}, "pairs": [], "col_map": col_map, + "col_preference": col_preference, "method": method, "filters": filters, } @@ -219,6 +234,7 @@ async def _run_analysis( "corr_data": corr_data, "pairs": pairs, "col_map": col_map, + "col_preference": col_preference, "method": method, "filters": filters, } @@ -236,7 +252,7 @@ def _on_execute() -> None: with perf(session.id, "binding.workspace", "_on_execute"): pairs = _active_pairs() method = input.corr_type() - preference: Literal["effect", "pvalue"] = ( + preference: Literal["effect", "pvalue", "log10pval"] = ( input.col_preference() # type: ignore[assignment] ) filters = dataset_filters() @@ -253,25 +269,43 @@ def _on_execute() -> None: for pair in pairs for db in pair } - _boxes_rendered.set(0) - _boxes_expected.set(len(pairs)) # Scatter counters are reset here so analysis_status immediately shows # "preparing" on the scatter tab; _init_selected_reg or # _reset_scatter_epoch will set _scatter_expected once the task succeeds. _scatter_rendered.set(0) _scatter_expected.set(0) - _run_analysis.invoke(pairs, col_map, filters, method) - # Capture snapshot so _update_pending_indicator dims the button. + # Commit the pending pair selection so box plots and scatters update. + with reactive.isolate(): + committed_pairs.set(list(pending_pairs())) + _run_analysis.invoke(pairs, col_map, preference, filters, method) + # Capture snapshot so execute_pending_style dims the button. _last_run_snapshot.set(_snapshot_current()) - # --- Eager regulator initialization and scatter epoch tracking --------------- + # --- Helpers ---------------------------------------------------------------- + + def _visible_scatter_count( + pairs: list[tuple[str, str]], sel: list[tuple[str, str]] + ) -> int: + """ + Return the number of active pairs that will have scatter slots emitted, matching + the filter logic in ``scatter_container``. + + :param pairs: Active pairs from the task result. + :param sel: Currently selected pairs. + + """ + if not sel: + return len(pairs) + sel_dbs: set[str] = {db for p in sel for db in p} + return sum(1 for p in pairs if set(p) <= sel_dbs) + + # --- Eager regulator and pair initialization -------------------------------- @reactive.effect def _init_selected_reg() -> None: """ Set ``selected_reg`` as soon as the task succeeds so scatter plots start - computing in parallel with box-plot renders rather than waiting for the - ``regulator_selector`` UI render to fire. + computing immediately after the matrix renders. Preserves the current selection if it is still valid in the new result; falls back to the alphabetically first entry otherwise. When the selection @@ -306,18 +340,45 @@ def _init_selected_reg() -> None: # Reg is unchanged; manually bump the epoch so the "preparing" message # fires and scatter plots start counting from zero for this run. with reactive.isolate(): + sel = committed_pairs() _scatter_epoch.set(_scatter_epoch() + 1) _scatter_rendered.set(0) - _scatter_expected.set(len(result["pairs"])) + _scatter_expected.set(_visible_scatter_count(result["pairs"], sel)) + + @reactive.effect + def _init_selected_pairs() -> None: + """ + Seed ``pending_pairs`` and ``committed_pairs`` with the first active pair when + the task succeeds and nothing is currently selected, and prune stale pairs that + are no longer in the result. + + :trigger _run_analysis.status: fires when the task transitions to success. + + """ + if _run_analysis.status() != "success": + return + pairs = _run_analysis.result()["pairs"] + pairs_set = set(pairs) + with reactive.isolate(): + cur_pending = pending_pairs() + cur_committed = committed_pairs() + valid_pending = [p for p in cur_pending if p in pairs_set] + valid_committed = [p for p in cur_committed if p in pairs_set] + if valid_pending != cur_pending: + pending_pairs.set(valid_pending if valid_pending else pairs[:1]) + if valid_committed != cur_committed: + committed_pairs.set(valid_committed if valid_committed else pairs[:1]) @reactive.effect def _reset_scatter_epoch() -> None: """ - Bump the scatter render epoch whenever ``selected_reg`` changes. + Bump the scatter render epoch whenever ``selected_reg`` changes so stale scatter + renders from the previous regulator don't increment the current epoch's counter. - Only acts when the task has already succeeded. Reading ``_run_analysis`` - under ``reactive.isolate`` means this effect fires only on ``selected_reg`` - changes, not on task-status transitions. + ``committed_pairs`` is read under ``reactive.isolate`` so that + ``_init_selected_pairs`` setting it on task success does not re-trigger + this effect and spuriously bump the epoch after renders have already + started counting. :trigger selected_reg: fires when the user selects a different regulator. @@ -329,25 +390,19 @@ def _reset_scatter_epoch() -> None: if _run_analysis.status() != "success": return result = _run_analysis.result() + sel = committed_pairs() _scatter_epoch.set(_scatter_epoch() + 1) _scatter_rendered.set(0) - _scatter_expected.set(len(result["pairs"])) + _scatter_expected.set(_visible_scatter_count(result["pairs"], sel)) # --- Status render ---------------------------------------------------------- @render.ui def analysis_status() -> ui.Tag: """ - User feedback spanning both phases: task computation and plot rendering. - - Phase 1 (task running): shown while ``_run_analysis`` is off-thread. - Phase 2 (rendering): shown after the task succeeds but before all - ``render_plotly`` closures have completed and returned their FigureWidgets. - Each closure increments ``_boxes_rendered`` under ``reactive.isolate`` so - this render re-fires without the box plots re-running. + User feedback while the task is running or has errored. :trigger _run_analysis.status: re-renders when the task state changes. - :trigger _boxes_rendered: re-renders as each box plot finishes building. """ status = _run_analysis.status() @@ -364,11 +419,6 @@ def analysis_status() -> ui.Tag: {"class": "empty-state"}, ui.p(f"Error: {_run_analysis.error()}"), ) - if status == "success" and _boxes_rendered() < _boxes_expected(): - return ui.div( - {"class": "empty-state"}, - ui.p("Building visualizations, please wait..."), - ) return ui.span() # --- Dataset selection sidebar render --------------------------------------- @@ -393,36 +443,36 @@ def dataset_selection() -> ui.Tag: # --- Box plot helpers ------------------------------------------------------- - def _highlight_one( - fig: go.FigureWidget, - pair: tuple[str, str], - reg: str, - ) -> None: + def _highlight_one(pair: tuple[str, str], reg: str) -> None: """ - Mutate trace 1 of ``fig`` in-place to highlight ``reg``'s points. + Mutate trace 1 of the box FigureWidget for ``pair`` in-place to highlight + ``reg``'s point. Sends a ``_py2js_restyle`` delta to the client via ``batch_update``; the box trace (trace 0) is never touched, so no full figure re-render occurs. - :param fig: The FigureWidget to update. - :param pair: ``(db_a, db_b)`` key into ``_box_data``. + :param pair: Dataset pair key into ``_pair_box_widgets`` / ``_pair_box_data``. :param reg: Regulator locus tag to highlight, or ``""`` to clear. """ - data = _box_data.get(pair) - if data is None or not reg: - sel_x: list = [] - sel_y: list = [] - sel_hover: list = [] - else: - all_x = data["all_x"] - all_y = data["all_y"] - all_tags = data["all_tags"] - all_hover = data["all_hover"] - idx = [i for i, t in enumerate(all_tags) if t == reg] - sel_x = [all_x[i] for i in idx] - sel_y = [all_y[i] for i in idx] - sel_hover = [all_hover[i] for i in idx] + fig = _pair_box_widgets.get(pair) + data = _pair_box_data.get(pair) + if fig is None: + return + if not reg or data is None: + with fig.batch_update(): + fig.data[1].x = [] + fig.data[1].y = [] + fig.data[1].hovertext = [] + return + all_x = data["all_x"] + all_y = data["all_y"] + all_tags = data["all_tags"] + all_hover = data["all_hover"] + idx = [i for i, t in enumerate(all_tags) if t == reg] + sel_x = [all_x[i] for i in idx] + sel_y = [all_y[i] for i in idx] + sel_hover = [all_hover[i] for i in idx] with fig.batch_update(): fig.data[1].x = sel_x fig.data[1].y = sel_y @@ -434,16 +484,49 @@ def _update_all_highlights() -> None: In-place update of the highlight trace in every live box FigureWidget. Fires when ``selected_reg`` changes. Never calls any render function; - only the delta for trace 1 is sent to the client. + only the delta for trace 1 is sent to each widget. :trigger selected_reg: fires when the user clicks a point or picks from dropdown. """ reg = selected_reg() - for pair, fig in _box_widgets.items(): + for pair, fig in _pair_box_widgets.items(): if fig is not None: - _highlight_one(fig, pair, reg) + _highlight_one(pair, reg) + + def _apply_log10p_transform( + series: pd.Series, + db_name: str, + col: str, + display: str, + ) -> tuple[pd.Series, str]: + """ + Apply the -log10 transform appropriate for the dataset's p-value source. + + Returns the transformed series and an axis label string. + + :param series: Raw column values from the query. + :param db_name: Dataset name (used to look up the source type). + :param col: Column name (used in the fallback axis label). + :param display: Dataset display name for the axis label. + + """ + cap = -np.log10(LOG10P_FLOOR) # = 10 + source = get_log10p_source(db_name) + if source == "neglog10p": + transformed = series.clip(upper=cap) + label = f"{display}: -log10(p)" + elif source == "log10p": + transformed = (-series).clip(upper=cap) + label = f"{display}: -log10(p)" + elif source == "pval": + transformed = -np.log10(series.clip(lower=LOG10P_FLOOR)) + label = f"{display}: -log10(p)" + else: + transformed = series + label = f"{display}: {col}" + return transformed, label # --- All possible pairs (fixed at init) ------------------------------------ @@ -458,17 +541,20 @@ def _update_all_highlights() -> None: ) ) - # --- Box plot container ---------------------------------------------------- + # --- Correlation matrix ---------------------------------------------------- + @output(suspend_when_hidden=False) @render.ui - def box_plot_container() -> ui.Tag: + def corr_matrix_container() -> ui.Tag: """ - Flex container of one ``output_widget`` slot per active pair. + N×N correlation matrix table showing median r per dataset pair. - Only re-renders when the task result changes. Each slot is filled by its own - per-pair ``render_plotly`` registered below. + Calls :func:`~tfbpshiny.utils.correlation_matrix.build_correlation_matrix_ui` + to produce the table tag. Re-renders when the task result changes or when + ``selected_pair`` changes (to move the active-cell highlight). :trigger _run_analysis.status: re-renders when the task completes. + :trigger selected_pair: re-renders to update the active-cell highlight. """ status = _run_analysis.status() @@ -491,63 +577,148 @@ def box_plot_container() -> ui.Tag: ui.p("No active binding dataset pairs. Select at least two datasets."), ) - slots = [ - ui.div( - output_widget(f"box_{db_a}__{db_b}"), - style="flex: 0 0 auto;", + with reactive.isolate(): + active_datasets = active_binding_datasets() + + return build_correlation_matrix_ui( + all_possible_pairs=_all_possible_pairs, + active_pairs=pairs, + active_datasets=sorted(active_datasets), + corr_data=result["corr_data"], + display_names=display_names, + selected_pairs=set(pending_pairs()), + ) + + # --- Cell click factory — pre-register one toggle effect per possible pair - + + def _make_cell_click_effect(db_a: str, db_b: str) -> None: + """ + Pre-register the reactive effect that toggles ``(db_a, db_b)`` in + ``selected_pairs`` when the corresponding matrix cell button is clicked. + + The button ID ``corrpair_{db_a}__{db_b}`` matches the ID emitted by + :func:`~tfbpshiny.utils.correlation_matrix.build_correlation_matrix_ui`. + + :param db_a: First dataset name (canonical order from ``_all_possible_pairs``). + :param db_b: Second dataset name. + + """ + btn_id = f"corrpair_{db_a}__{db_b}" + pair = (db_a, db_b) + + @reactive.effect + @reactive.event(input[btn_id]) + def _on_cell_click() -> None: + with reactive.isolate(): + cur = list(pending_pairs()) + if pair in cur: + cur.remove(pair) + else: + cur.append(pair) + pending_pairs.set(cur) + + for _db_a, _db_b in _all_possible_pairs: + _make_cell_click_effect(_db_a, _db_b) + + # --- Pair distribution box plots ------------------------------------------- + + @output(suspend_when_hidden=False) + @render.ui + def pair_box_status() -> ui.Tag: + """ + Shown when the task has succeeded but no pairs are selected yet. + + :trigger _run_analysis.status: re-renders when the task completes. :trigger + selected_pairs: re-renders when the selection changes. + + """ + if _run_analysis.status() != "success": + return ui.span() + if not committed_pairs(): + return ui.div( + {"class": "empty-state"}, + ui.p( + "Select cells in the Correlation Matrix and click Execute Analysis " + "to view their distributions." + ), ) + return ui.span() + + @output(suspend_when_hidden=False) + @render.ui + def pair_box_container() -> ui.Tag: + """ + Flex container of one ``output_widget`` slot per selected pair. + + Each slot is matched by a ``render_plotly`` registered by + ``_make_pair_box_render`` at server start. + + :trigger selected_pairs: re-renders when the selection changes. + :trigger _run_analysis.status: re-renders on task completion. + + """ + if _run_analysis.status() != "success": + return ui.span() + pairs = committed_pairs() + if not pairs: + return ui.span() + result = _run_analysis.result() + active_pair_set = set(result["pairs"]) + slots = [ + ui.div(output_widget(f"pair_box_{db_a}__{db_b}"), style="flex: 0 0 auto;") for db_a, db_b in _all_possible_pairs - if (db_a, db_b) in pairs + if (db_a, db_b) in active_pair_set and (db_a, db_b) in set(pairs) ] + if not slots: + return ui.span() return ui.div( *slots, style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;", ) - # --- Per-pair box plot renders --------------------------------------------- - - def _make_box_render(db_a: str, db_b: str) -> None: + def _make_pair_box_render(db_a: str, db_b: str) -> None: """ Register a ``render_plotly`` for one dataset pair's box plot. - The returned FigureWidget is stored in ``_box_widgets`` so - ``_update_all_highlights`` can mutate it in-place when the selected - regulator changes without triggering a re-render. + The FigureWidget is stored in ``_pair_box_widgets`` so + ``_update_all_highlights`` can mutate it in-place when ``selected_reg`` + changes without triggering a re-render. :param db_a: First dataset name. :param db_b: Second dataset name. """ + pair = (db_a, db_b) - @output(id=f"box_{db_a}__{db_b}") + @output(id=f"pair_box_{db_a}__{db_b}") @render_plotly - def _box_plot() -> go.FigureWidget: + def _pair_box() -> go.FigureWidget: """ - Box + jittered-points plot of per-regulator correlations for one pair. + Box + jittered-points for one selected pair's per-regulator correlations. - Returns an empty FigureWidget when the task has not succeeded or this - pair is not in the current result. On success, stores references in - ``_box_widgets`` and ``_box_data`` for in-place highlight updates. + Two-trace pattern: trace 0 is the box, trace 1 is the highlight overlay + updated in-place by ``_update_all_highlights``. - :trigger _run_analysis.status: re-renders when the task completes. + :trigger selected_pairs: re-renders when this pair enters the selection. + :trigger _run_analysis.status: re-renders on task completion. """ - - def _count_rendered() -> None: - """Increment the render counter without creating a reactive dep.""" - with reactive.isolate(): - _boxes_rendered.set(_boxes_rendered() + 1) - if _run_analysis.status() != "success": + _pair_box_widgets[pair] = None + return go.FigureWidget() + + with reactive.isolate(): + sel = committed_pairs() + if pair not in sel: + _pair_box_widgets[pair] = None return go.FigureWidget() result = _run_analysis.result() - if (db_a, db_b) not in result["pairs"]: - _box_widgets[(db_a, db_b)] = None - _count_rendered() + if pair not in result["pairs"]: + _pair_box_widgets[pair] = None return go.FigureWidget() - df = result["corr_data"].get((db_a, db_b), pd.DataFrame()) + df = result["corr_data"].get(pair, pd.DataFrame()) method = result["method"] label_a = display_names.get(db_a, db_a) label_b = display_names.get(db_b, db_b) @@ -585,7 +756,7 @@ def _count_rendered() -> None: showlegend=False, ) ) - # Trace 1: highlight overlay — populated by _highlight_one in-place. + # Trace 1: highlight overlay — mutated in-place by _highlight_one. fig.add_trace( go.Scatter( x=[], @@ -605,131 +776,180 @@ def _count_rendered() -> None: yaxis=dict(title=f"{method.capitalize()} r", range=[-1, 1]), showlegend=False, margin=dict(l=50, r=20, t=100, b=60), - width=380, - height=420, + width=480, + height=460, ) - # Store for in-place mutation. - _box_data[(db_a, db_b)] = { + _pair_box_data[pair] = { "all_x": all_x, "all_y": all_y, "all_tags": all_tags, "all_hover": all_hover, } - _box_widgets[(db_a, db_b)] = fig + _pair_box_widgets[pair] = fig - # Register click handler: sets selected_reg reactive value. def _on_click(trace: Any, points: Any, state: Any) -> None: if not points.point_inds: return - reg = all_tags[points.point_inds[0]] - selected_reg.set(reg) + selected_reg.set(all_tags[points.point_inds[0]]) fig.data[0].on_click(_on_click) - # Pre-populate highlight for any already-selected regulator. with reactive.isolate(): cur = selected_reg() if cur: - _highlight_one(fig, (db_a, db_b), cur) + _highlight_one(pair, cur) - _count_rendered() return fig for _db_a, _db_b in _all_possible_pairs: - _make_box_render(_db_a, _db_b) + _make_pair_box_render(_db_a, _db_b) - # --- Regulator selector ---------------------------------------------------- + # --- Regulator selectors (one per tab, linked via selected_reg) ------------ - @render.ui - def scatter_status() -> ui.Tag: + def _reg_selector_choices() -> dict[str, str] | None: """ - Status message on the Scatter tab while plots are being built. + Build the sorted choices dict for the regulator selectize inputs. - Covers two cases: the render phase after a fresh Execute (scatters computing - in parallel with box renders), and regulator changes by the user. - - :trigger _scatter_rendered: re-renders as each scatter plot finishes. - :trigger _scatter_expected: re-renders when expected count is set. + Returns ``None`` when the task has not succeeded or no regulators exist. """ if _run_analysis.status() != "success": - return ui.span() - if _scatter_rendered() < _scatter_expected(): - return ui.div( - {"class": "empty-state"}, - ui.p("Preparing visualizations, please wait..."), - ) - return ui.span() + return None + all_regs: set[str] = set() + for df in _run_analysis.result()["corr_data"].values(): + if not df.empty: + all_regs |= set(df["regulator_locus_tag"].dropna().unique()) + if not all_regs: + return None + choices = {r: sym_map.get(r, r) for r in all_regs} + return dict(sorted(choices.items(), key=lambda kv: kv[1].lower())) - @render.ui - def regulator_selector() -> ui.Tag: + def _reg_selector_tag(input_id: str) -> ui.Tag: """ - Dropdown of regulators present in at least one pair's correlation data. + Render a regulator selectize input with the given ``input_id``. - Initial selection is managed by ``_init_selected_reg`` so this render only - reflects the current value; it does not write to ``selected_reg``. + Returns ``ui.span()`` when choices are unavailable. - :trigger _run_analysis.status: re-renders when the task completes. - :trigger selected_reg: re-renders to reflect the current selection. + :param input_id: Shiny input ID for this selectize instance. """ - if _run_analysis.status() != "success": + choices = _reg_selector_choices() + if choices is None: return ui.span() - - result = _run_analysis.result() - corr_data: dict[tuple[str, str], pd.DataFrame] = result["corr_data"] - - all_regs: set[str] = set() - for df in corr_data.values(): - if not df.empty: - all_regs |= set(df["regulator_locus_tag"].dropna().unique()) - - if not all_regs: - return ui.span() - - choices = {r: sym_map.get(r, r) for r in all_regs} - choices = dict(sorted(choices.items(), key=lambda kv: kv[1].lower())) - with reactive.isolate(): cur = selected_reg() default = cur if cur in choices else next(iter(choices), "") if not default: return ui.span() - return ui.input_selectize( - "selected_regulator_dropdown", - "Regulator", - choices=choices, - selected=default, + input_id, "Regulator", choices=choices, selected=default ) + @output(suspend_when_hidden=False) + @render.ui + def regulator_selector_box() -> ui.Tag: + """ + Regulator dropdown on the Pair Distribution tab. + + :trigger _run_analysis.status: re-renders when the task completes. :trigger + selected_reg: re-renders to reflect the current selection. + + """ + return _reg_selector_tag("selected_regulator_box") + + @output(suspend_when_hidden=False) + @render.ui + def regulator_selector_scatter() -> ui.Tag: + """ + Regulator dropdown on the Gene Scatter tab. + + :trigger _run_analysis.status: re-renders when the task completes. :trigger + selected_reg: re-renders to reflect the current selection. + + """ + return _reg_selector_tag("selected_regulator_scatter") + + @reactive.effect + @reactive.event(input.selected_regulator_box) + def _sync_box_dropdown() -> None: + """ + Propagate the Pair Distribution dropdown selection to ``selected_reg``. + + :trigger input.selected_regulator_box: fires when the user picks a regulator. + + """ + try: + val = str(input.selected_regulator_box()) + except Exception: + return + with reactive.isolate(): + if val != selected_reg(): + selected_reg.set(val) + @reactive.effect - @reactive.event(input.selected_regulator_dropdown) - def _sync_dropdown_to_reg() -> None: + @reactive.event(input.selected_regulator_scatter) + def _sync_scatter_dropdown() -> None: """ - Propagate dropdown selection to ``selected_reg``. + Propagate the Gene Scatter dropdown selection to ``selected_reg``. - :trigger input.selected_regulator_dropdown: fires when the user picks a + :trigger input.selected_regulator_scatter: fires when the user picks a regulator. """ try: - val = str(input.selected_regulator_dropdown()) + val = str(input.selected_regulator_scatter()) except Exception: return with reactive.isolate(): if val != selected_reg(): selected_reg.set(val) - # --- Scatter plots --------------------------------------------------------- + @reactive.effect + def _sync_reg_to_dropdowns() -> None: + """ + Push ``selected_reg`` changes back into both selectize inputs so they stay in + sync regardless of which one (or a box-plot click) triggered the change. + + :trigger selected_reg: fires whenever the selected regulator changes. + """ + reg = selected_reg() + if not reg: + return + ui.update_selectize("selected_regulator_box", selected=reg, session=session) + ui.update_selectize("selected_regulator_scatter", selected=reg, session=session) + + # --- Scatter tab status and plots ------------------------------------------ + + @output(suspend_when_hidden=False) + @render.ui + def scatter_status() -> ui.Tag: + """ + Status message on the Gene Scatter tab while plots are being built. + + :trigger _scatter_rendered: re-renders as each scatter plot finishes. :trigger + _scatter_expected: re-renders when expected count is set. + + """ + if _run_analysis.status() != "success": + return ui.span() + if _scatter_rendered() < _scatter_expected(): + return ui.div( + {"class": "empty-state"}, + ui.p("Preparing visualizations, please wait..."), + ) + return ui.span() + + @output(suspend_when_hidden=False) @render.ui def scatter_container() -> ui.Tag: """ - Flex container with one output slot per currently active pair. + Flex container with one slot per active pair that shares a dataset with the + currently selected pair. When no pair is selected all active pairs are shown. - :trigger _run_analysis.status: re-renders when the task completes. + :trigger _run_analysis.status: re-renders when the task completes. :trigger + selected_pair: re-renders when the selected pair changes. """ if _run_analysis.status() != "success": @@ -740,10 +960,22 @@ def scatter_container() -> ui.Tag: if not pairs: return ui.span() + # Filter to pairs where both datasets appear in the committed selection. + sel = committed_pairs() + if sel: + sel_dbs: set[str] = {db for p in sel for db in p} + visible = [p for p in pairs if set(p) <= sel_dbs] + else: + visible = pairs + + if not visible: + return ui.span() + + visible_set = set(visible) slots = [ ui.output_ui(f"scatter_{db_a}__{db_b}") for db_a, db_b in _all_possible_pairs - if (db_a, db_b) in pairs + if (db_a, db_b) in visible_set ] return ui.div( ui.output_ui("scatter_missing_note"), @@ -756,13 +988,16 @@ def scatter_container() -> ui.Tag: ), ) + @output(suspend_when_hidden=False) @render.ui def scatter_missing_note() -> ui.Tag: """ - Warning listing datasets where the selected regulator was not found. + Warning listing datasets where the selected regulator was not found, scoped to + the visible pairs only. :trigger selected_reg: re-renders when the regulator changes. :trigger - _run_analysis.status: re-renders when the task completes. + _run_analysis.status: re-renders when the task completes. :trigger + committed_pairs: re-renders when the pair selection changes. """ if _run_analysis.status() != "success": @@ -772,12 +1007,19 @@ def scatter_missing_note() -> ui.Tag: return ui.span() result = _run_analysis.result() - corr_data = result["corr_data"] pairs: list[tuple[str, str]] = result["pairs"] + corr_data = result["corr_data"] + + sel = committed_pairs() + if sel: + sel_dbs: set[str] = {db for p in sel for db in p} + visible = [p for p in pairs if set(p) <= sel_dbs] + else: + visible = pairs failed: set[str] = set() succeeded: set[str] = set() - for db_a, db_b in pairs: + for db_a, db_b in visible: df = corr_data.get((db_a, db_b)) has_reg = ( df is not None @@ -885,15 +1127,31 @@ def _strip_reg(f: dict | None) -> dict | None: _count_scatter() return ui.span() + col_preference = result.get("col_preference", "effect") la = display_names.get(db_a, db_a) lb = display_names.get(db_b, db_b) - r = merged["_val_a"].corr(merged["_val_b"]) + + val_a = merged["_val_a"].copy() + val_b = merged["_val_b"].copy() + if col_preference == "log10pval" and method != "spearman": + val_a, axis_label_a = _apply_log10p_transform(val_a, db_a, col_a, la) + val_b, axis_label_b = _apply_log10p_transform(val_b, db_b, col_b, lb) + elif col_preference == "log10pval" and method == "spearman": + # Spearman query returns ranks (1 = most significant); the + # -log10 transform does not apply to rank integers. + axis_label_a = f"{la}: rank by p-value" + axis_label_b = f"{lb}: rank by p-value" + else: + axis_label_a = f"{la}: {col_a}" + axis_label_b = f"{lb}: {col_b}" + + r = val_a.corr(val_b) fig = go.Figure() fig.add_trace( go.Scatter( - x=merged["_val_a"], - y=merged["_val_b"], + x=val_a, + y=val_b, mode="markers", marker=dict(size=4, opacity=0.6, color="#4A90D9"), text=merged["target_symbol"], @@ -914,8 +1172,8 @@ def _strip_reg(f: dict | None) -> dict | None: ) fig.update_layout( title=dict(text=f"{la}
vs
{lb}", x=0.5, xanchor="center"), - xaxis_title=f"{la}: {col_a}", - yaxis_title=f"{lb}: {col_b}", + xaxis_title=axis_label_a, + yaxis_title=axis_label_b, margin=dict(l=50, r=20, t=100, b=50), width=400, height=400, diff --git a/tfbpshiny/modules/binding/ui.py b/tfbpshiny/modules/binding/ui.py index f2f19bb..65cbf8e 100644 --- a/tfbpshiny/modules/binding/ui.py +++ b/tfbpshiny/modules/binding/ui.py @@ -25,8 +25,22 @@ def binding_ui() -> ui.Tag: ui.input_radio_buttons( "col_preference", label=None, - choices={"effect": "Effect", "pvalue": "P-value"}, - selected="effect", + choices={ + "log10pval": ui.tooltip( + ui.span("-log10(p-value)"), + "Negative log10 of the p-value. " + "Values below 1e-10 are capped at 10.", + ), + "effect": ui.tooltip( + ui.span("Effect"), + "Raw effect size (e.g. enrichment score).", + ), + "pvalue": ui.tooltip( + ui.span("P-value"), + "Raw p-value. Smaller is more significant.", + ), + }, + selected="log10pval", inline=True, ), sidebar_label("Correlation"), @@ -34,7 +48,7 @@ def binding_ui() -> ui.Tag: "corr_type", label=None, choices={"pearson": "Pearson", "spearman": "Spearman"}, - selected="pearson", + selected="spearman", inline=True, ), id="binding_sidebar", @@ -50,27 +64,36 @@ def binding_ui() -> ui.Tag: "regulators." ), ui.p( - "The Distributions tab shows one box plot per dataset pair. " - "Each point represents the correlation for a single regulator. " - "Click a point to select that regulator and highlight it across " - "all plots." + "The Correlation Matrix tab shows median correlation for each " + "dataset pair. Click a cell to select that pair." ), ui.p( - "The Scatter tab shows per-target binding scores for the selected " - "regulator in each pair. Use the dropdown to change the active " + "The Pair Distribution tab shows the per-regulator correlation " + "distribution for the selected pair. Click a point to select a " + "regulator." + ), + ui.p( + "The Gene Scatter tab shows per-target binding scores for the " + "selected regulator. Use the dropdown to change the active " "regulator." ), ), ui.output_ui("analysis_status"), ui.navset_tab( ui.nav_panel( - "Distributions", - ui.output_ui("box_plot_container"), + "Correlation Matrix", + ui.output_ui("corr_matrix_container"), + ), + ui.nav_panel( + "Pair Distribution", + ui.output_ui("regulator_selector_box"), + ui.output_ui("pair_box_status"), + ui.output_ui("pair_box_container"), ), ui.nav_panel( - "Scatter", + "Gene Scatter", + ui.output_ui("regulator_selector_scatter"), ui.output_ui("scatter_status"), - ui.output_ui("regulator_selector"), ui.output_ui("scatter_container"), ), id="binding_view_tabs", diff --git a/tfbpshiny/modules/comparison/queries.py b/tfbpshiny/modules/comparison/queries.py index 2a55f7e..40ed884 100644 --- a/tfbpshiny/modules/comparison/queries.py +++ b/tfbpshiny/modules/comparison/queries.py @@ -23,11 +23,6 @@ #: Default top-N cutoff DEFAULT_TOP_N = 25 -#: Default effect size threshold (|effect| must exceed this to be "responsive") -DEFAULT_EFFECT_THRESHOLD = 0.0 - -#: Default p-value threshold (pvalue must be below this to be "responsive") -DEFAULT_PVALUE_THRESHOLD = 0.05 # --------------------------------------------------------------------------- # DTO query @@ -161,7 +156,8 @@ def _responsive_expr( :returns: SQL CASE expression string evaluating to 1 or 0. """ - effect_col, pvalue_col = DATASET_COLUMNS.get(perturbation_view, ("", "")) + cols = DATASET_COLUMNS.get(perturbation_view, ("", "")) + effect_col, pvalue_col = cols[0], cols[1] eff_key = f"{param_prefix}_eff_thresh" pval_key = f"{param_prefix}_pval_thresh" @@ -187,8 +183,8 @@ def topn_responsive_ratio( binding_sample_col: str, rank_col: str, top_n: int = DEFAULT_TOP_N, - effect_threshold: float = DEFAULT_EFFECT_THRESHOLD, - pvalue_threshold: float = DEFAULT_PVALUE_THRESHOLD, + effect_threshold: float = 0.0, + pvalue_threshold: float = 0.05, binding_filters: dict[str, Any] | None = None, perturbation_filters: dict[str, Any] | None = None, rank_asc: bool = True, @@ -504,8 +500,7 @@ def topn_all_pairs_sql( pairs: list[tuple[str, str]], filters: dict[str, Any], top_n: int, - effect_threshold: float, - pvalue_threshold: float, + preset: dict[str, tuple[float, float]], ) -> pd.DataFrame: """ Compute top-N responsive ratio for all (binding, perturbation) pairs in one query. @@ -514,12 +509,15 @@ def topn_all_pairs_sql( ``vdb.query()`` call. Each pair is prefixed with ``bp{i}_`` to prevent parameter name collisions. + Responsiveness thresholds are looked up per perturbation dataset from ``preset``. + Use ``"*"`` as a fallback key for datasets not explicitly listed. + :param vdb: VirtualDB instance. :param pairs: List of ``(binding_db, perturbation_db)`` tuples. :param filters: Active filter dict keyed by dataset name. :param top_n: Number of top binding targets per binding sample. - :param effect_threshold: Minimum absolute effect size to count as responsive. - :param pvalue_threshold: Maximum p-value to count as responsive. + :param preset: Per-dataset responsiveness thresholds; see + :data:`~tfbpshiny.utils.vdb_init.DEFAULT_RESPONSIVENESS_PRESETS`. :returns: DataFrame with all columns returned by ``topn_responsive_ratio`` plus ``pair_key`` (``"{b_db}__{p_db}"``). @@ -535,6 +533,9 @@ def topn_all_pairs_sql( p_cfg = PERTURBATION_CONFIGS.get(p_db) if b_cfg is None or p_cfg is None: continue + effect_threshold, pvalue_threshold = preset.get( + p_db, preset.get("*", (0.0, 0.05)) + ) pair_sql, pair_params = topn_responsive_ratio( vdb=vdb, binding_view=b_db, diff --git a/tfbpshiny/modules/comparison/server/workspace.py b/tfbpshiny/modules/comparison/server/workspace.py index 8b9e0db..b4ba7b3 100644 --- a/tfbpshiny/modules/comparison/server/workspace.py +++ b/tfbpshiny/modules/comparison/server/workspace.py @@ -10,11 +10,11 @@ import plotly.graph_objects as go from labretriever import VirtualDB from plotly.io import to_html -from plotly.subplots import make_subplots from shiny import reactive, render, req, ui from shiny.reactive import extended_task from shiny.ui import bind_task_button, input_task_button # noqa: F401 +from tfbpshiny.components import sidebar_label from tfbpshiny.modules.comparison.queries import ( BINDING_BASE_LABEL_MAP, BINDING_CONFIGS, @@ -31,17 +31,17 @@ topn_all_pairs_sql, ) from tfbpshiny.utils.perf import reset_render_counts -from tfbpshiny.utils.vdb_init import get_regulator_display_name +from tfbpshiny.utils.topn_matrix import build_topn_matrix_ui +from tfbpshiny.utils.vdb_init import ( + DEFAULT_RESPONSIVENESS_PRESET, + DEFAULT_RESPONSIVENESS_PRESETS, + get_regulator_display_name, +) # --------------------------------------------------------------------------- -# Color palettes +# Display-order constants # --------------------------------------------------------------------------- -PROMOTER_SET_COLORS: dict[str, str] = { - "Kang": "#4DBBD5", - "Mindel": "#E64B35", -} - _PERT_ORDER = [ "2006 Overexpression", "2006 TFKO", @@ -58,8 +58,67 @@ "2026 Calling Cards", ] -#: All db_names that can appear in the Method Comparison tab. -_METHOD_BASE_DATASETS: frozenset[str] = frozenset(METHOD_BASE_LABEL_MAP) +# Binding datasets that can appear in the Compare Methods tab. +_METHODS_ELIGIBLE: frozenset[str] = frozenset(PEAKS_VARIANT_MAP) + +# Promoter tooltip text. +_PROMOTER_TOOLTIPS: dict[str, str] = { + "Kang": "Promoter defined as 800 bp upstream of TSS (Kang et al. 2014)", + "Mindel": ( + "Promoter defined as the intergenic region between adjacent genes " + "upstream of TSS (Mindel et al. 2025)" + ), +} + + +def _checkbox_group_with_disabled( + input_id: str, + choices: dict[str, str], + selected: list[str], + disabled: set[str], +) -> ui.Tag: + """ + Build a checkbox group identical to ``ui.input_checkbox_group`` but with per-choice + disabled support. + + Replicates Shiny's internal HTML structure (``name=input_id``, + ``value=choice_value``, ``class="shiny-options-group"``), adding the + HTML ``disabled`` attribute to any choice whose value is in ``disabled``. + Disabled choices are also visually dimmed via inline opacity. + + :param input_id: The Shiny input ID (already namespaced by the caller). + :param choices: Ordered ``{value: label}`` mapping. + :param selected: Values that should be checked. + :param disabled: Values that should be disabled (unchecked and non-interactive). + :returns: A ``div.shiny-input-container`` tag matching Shiny's checkbox group. + + """ + option_tags: list[ui.Tag] = [] + for value, label in choices.items(): + is_disabled = value in disabled + inp = ui.tags.input( + type="checkbox", + name=input_id, + value=value, + checked="checked" if (value in selected and not is_disabled) else None, + disabled="disabled" if is_disabled else None, + ) + option_tags.append( + ui.div( + ui.tags.label( + inp, + " ", + ui.span(label), + style="opacity: 0.45;" if is_disabled else None, + ), + class_="checkbox", + ) + ) + return ui.div( + ui.div(*option_tags, class_="shiny-options-group"), + id=input_id, + class_="shiny-input-checkboxgroup shiny-input-container", + ) def comparison_workspace_server( @@ -73,65 +132,115 @@ def comparison_workspace_server( logger: Logger, active_tab: reactive.Calc_[str] | None = None, ) -> None: - """Render the Comparison workspace: Top-N and Promoter Set views.""" + """Render the Comparison workspace: Compare Datasets, Promoters, Methods.""" session.on_flush(lambda: reset_render_counts(session.id)) - # All datasets registered in this VirtualDB instance — checked once at init. _available_datasets: frozenset[str] = frozenset(vdb.get_datasets()) - - # Mindel db_names that actually exist in this instance. _mindel_dbs: frozenset[str] = ( frozenset(PROMOTER_VARIANT_PAIRS.values()) & _available_datasets ) - # Snapshot of sidebar inputs at the time of the last Execute click. + _reg_df = get_regulator_display_name(vdb) + _reg_labels: dict[str, str] = dict( + zip(_reg_df["regulator_locus_tag"], _reg_df["display_name"]) + ) + + display_names: dict[str, str] = { + db: vdb.get_tags(db).get("display_name", db) for db in vdb.get_datasets() + } + + # All primary binding datasets known to this VDB (excludes Mindel/peaks). + _all_primary_binding: list[str] = sorted( + db + for db in vdb.get_datasets() + if db in BINDING_CONFIGS + and db not in _mindel_dbs + and db not in frozenset().union(*PEAKS_VARIANT_MAP.values()) + ) + _all_perturbation: list[str] = sorted( + db for db in vdb.get_datasets() if db in PERTURBATION_CONFIGS + ) + + # Selected row/column in the Compare Datasets matrix. + cd_selected_binding: reactive.Value[str | None] = reactive.value(None) + cd_selected_perturbation: reactive.Value[str | None] = reactive.value(None) + _last_run_snapshot: reactive.Value[tuple | None] = reactive.value(None) - def _snapshot_current() -> tuple: - """Return a hashable representation of the current sidebar inputs.""" - try: - incl_b: tuple = tuple(sorted(input.included_binding() or ())) - except Exception: - incl_b = () - try: - incl_p: tuple = tuple(sorted(input.included_perturbation() or ())) - except Exception: - incl_p = () + # Per-tab result cache: stores the last successful _run_analysis result for + # each inner tab so that switching back to a tab shows the previous results + # without requiring a re-run. + _tab_results: reactive.Value[dict[str, dict]] = reactive.value({}) + + # --------------------------------------------------------------------------- + # Helper: derive active tab + # --------------------------------------------------------------------------- + + def _inner_tab() -> str: try: - incl_ps: tuple = tuple(sorted(input.included_promoter_sets() or ())) + return str(input.comparison_inner_tabs()) except Exception: - incl_ps = () + return "Compare Datasets" + + # --------------------------------------------------------------------------- + # Snapshot + # --------------------------------------------------------------------------- + + def _snapshot_current() -> tuple: + """Return a hashable representation of all sidebar inputs.""" try: filters_repr = repr( sorted((k, repr(v)) for k, v in dataset_filters().items()) ) except Exception: filters_repr = "" + + def _safe(fn: Any) -> Any: + try: + v = fn() + if hasattr(v, "__iter__") and not isinstance(v, str): + return tuple(sorted(v)) + return v + except Exception: + return None + return ( - incl_b, - incl_p, - incl_ps, + _inner_tab(), input.top_n(), - input.effect_threshold(), - input.pvalue_threshold(), + _safe(input.cd_binding_method), + _safe(input.cd_promoter_set), + _safe(input.cd_included_binding), + _safe(input.cd_included_perturbation), + _safe(input.cp_included_binding), + _safe(input.cp_included_perturbation), + _safe(input.cp_included_promoter_sets), + _safe(input.cm_binding_dataset), + _safe(input.cm_included_perturbation), filters_repr, ) + # --------------------------------------------------------------------------- + # Gate tab + # --------------------------------------------------------------------------- + + @reactive.effect + def _gate_tab() -> None: + """Silently block when another top-level tab is active.""" + if active_tab is not None: + req(active_tab() == "Binding/Perturbation Comparisons") + + # --------------------------------------------------------------------------- + # Pending button style + # --------------------------------------------------------------------------- + @render.ui def execute_pending_style() -> ui.Tag: """ - Inject a ``