Source code for ExposoGraph.viewer_dash

"""Advanced Dash Cytoscape viewer for ExposoGraph."""

from __future__ import annotations

import copy
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, TypeVar, cast

from .branding import APP_NAME
from .config import GraphVisibility
from .cytoscape_adapter import (
    CytoscapeBundle,
    ViewerLayoutMode,
    build_cytoscape_bundle,
    load_cytoscape_bundle,
    normalize_viewer_layout_mode,
    viewer_layout,
)
from .engine import GraphEngine
from .models import KnowledgeGraph

cyto: Any
ALL: Any
DashApp: Any
DashInput: Any
DashOutput: Any
DashState: Any
ctx: Any
dcc: Any
html: Any
no_update: Any
_DASH_IMPORT_ERROR: ImportError | None
_CallbackFunc = TypeVar("_CallbackFunc", bound=Callable[..., Any])

try:  # pragma: no cover - exercised through runtime integration
    import dash_cytoscape as _cyto
    from dash import (
        ALL as _ALL,
        Dash as _DashApp,
        Input as _DashInput,
        Output as _DashOutput,
        State as _DashState,
        ctx as _ctx,
        dcc as _dcc,
        html as _html,
        no_update as _no_update,
    )
except ImportError as exc:  # pragma: no cover - tested via helper raising
    cyto = cast(Any, None)
    ALL = cast(Any, None)
    DashApp = cast(Any, None)
    DashInput = cast(Any, None)
    DashOutput = cast(Any, None)
    DashState = cast(Any, None)
    ctx = cast(Any, None)
    dcc = cast(Any, None)
    html = cast(Any, None)
    no_update = cast(Any, None)
    _DASH_IMPORT_ERROR = exc
else:
    cyto = _cyto
    ALL = _ALL
    DashApp = _DashApp
    DashInput = _DashInput
    DashOutput = _DashOutput
    DashState = _DashState
    ctx = _ctx
    dcc = _dcc
    html = _html
    no_update = _no_update
    _DASH_IMPORT_ERROR = None


[docs] @dataclass(frozen=True) class ViewerState: elements: list[dict[str, Any]] layout: dict[str, Any] visible_node_count: int visible_edge_count: int node_type_counts: dict[str, int] edge_type_counts: dict[str, int]
_SIDEBAR_STYLE = { "background": "#121a27", "border": "1px solid rgba(124, 154, 185, 0.22)", "borderRadius": "18px", "padding": "18px", "display": "flex", "flexDirection": "column", "gap": "16px", "minWidth": "320px", } _CARD_STYLE = { "background": "rgba(18, 26, 39, 0.84)", "border": "1px solid rgba(124, 154, 185, 0.18)", "borderRadius": "14px", "padding": "12px 14px", } _PANEL_TITLE_STYLE = { "fontSize": "0.76rem", "fontWeight": 700, "letterSpacing": "0.08em", "textTransform": "uppercase", "color": "#8da3bc", "marginBottom": "8px", } _INPUT_STYLE = { "width": "100%", "padding": "10px 12px", "borderRadius": "10px", "border": "1px solid rgba(124, 154, 185, 0.25)", "background": "#0a121f", "color": "#e6edf7", } _ACTION_BUTTON_STYLE = { "border": "1px solid rgba(124, 154, 185, 0.22)", "background": "linear-gradient(180deg, rgba(15, 25, 41, 0.96), rgba(9, 15, 27, 0.98))", "color": "#e6edf7", "borderRadius": "10px", "padding": "9px 12px", "fontSize": "0.84rem", "fontWeight": 600, "cursor": "pointer", "boxShadow": "inset 0 1px 0 rgba(255,255,255,0.04)", } _HOVER_TOOLTIP_STYLE = { "position": "absolute", "top": "18px", "left": "18px", "zIndex": 60, "maxWidth": "320px", "padding": "12px 14px", "borderRadius": "14px", "border": "1px solid rgba(124, 154, 185, 0.18)", "background": "linear-gradient(180deg, rgba(13, 21, 34, 0.96), rgba(8, 14, 24, 0.98))", "boxShadow": "0 18px 34px rgba(0, 0, 0, 0.24)", "pointerEvents": "none", } _GENOTYPE_PROFILES: dict[str, dict[str, float]] = { "normal": {}, "bap_high_risk": {"CYP1A1": 1.3, "GSTM1": 0.0}, "nat2_slow": {"NAT2": 0.5}, "cyp2e1_high": {"CYP2E1": 1.3}, } _GENOTYPE_MESSAGES: dict[str, str] = { "normal": "Baseline profile: no genotype overrides applied.", "bap_high_risk": ( "BaP high-risk: CYP1A1 activation edges become bolder and GSTM1 detoxification " "edges fade toward an absent-function state." ), "nat2_slow": ( "NAT2 slow acetylator: NAT2-linked activation and detoxification edges are " "dimmed to reflect reduced catalytic capacity." ), "cyp2e1_high": ( "CYP2E1 high activity: CYP2E1-driven activation edges become bolder to show " "stronger bioactivation context." ), } _ACTIVITY_EDGE_TYPES = {"ACTIVATES", "DETOXIFIES", "REPAIRS"} def _genotype_node_background_opacity(score: float) -> float: if score == 0.0: return 0.18 if score > 1.0: return 1.0 if score < 1.0: return 0.5 return 0.94 def _genotype_edge_visual_style( score: float | None, edge_type: str, default_color: str, ) -> dict[str, Any]: if score is None or edge_type not in _ACTIVITY_EDGE_TYPES: return { "color": default_color, "opacity": 0.72, "target_arrow_shape": "triangle", } base_width = 3 if score == 0.0: return { "color": "#b9c1cb", "width": 1, "opacity": 0.16, "target_arrow_shape": "none", } if score > 1.0: return { "color": default_color, "width": max(4, min(8, round(base_width * (1 + (score - 1.0) * 4)))), "opacity": 0.96, "target_arrow_shape": "triangle", } return { "color": default_color, "width": max(1, min(8, round(base_width * score))), "opacity": 0.22, "target_arrow_shape": "triangle", } def _apply_genotype_profile( elements: list[dict[str, Any]], profile_key: str, ) -> list[dict[str, Any]]: """Apply genotype-aware borders, opacity, and edge emphasis to Cytoscape elements.""" overrides = _GENOTYPE_PROFILES.get(profile_key or "normal", {}) if not overrides: return elements from .cytoscape_adapter import _activity_indicator updated: list[dict[str, Any]] = [] # Build a lookup of source node activity overrides node_activity: dict[str, float] = {} for element in elements: data = element.get("data", {}) node_id = data.get("id") if data.get("kind") == "node" and node_id and node_id in overrides: node_activity[node_id] = overrides[node_id] for element in elements: data = element.get("data", {}) el = copy.deepcopy(element) el_data = el["data"] if el_data.get("kind") == "node" and el_data.get("id") in overrides: score = overrides[el_data["id"]] indicator_color, indicator_width = _activity_indicator(score) el_data["activity_border_color"] = indicator_color el_data["activity_border_width"] = indicator_width el_data["background_opacity"] = _genotype_node_background_opacity(score) if el_data.get("kind") == "edge": source_id = el_data.get("source") edge_type = el_data.get("type", "") if source_id in node_activity: style = _genotype_edge_visual_style( node_activity[source_id], edge_type, str(el_data.get("color", "#8ea4bb")), ) el_data["color"] = style["color"] el_data["opacity"] = style["opacity"] el_data["target_arrow_shape"] = style["target_arrow_shape"] if "width" in style: el_data["width"] = style["width"] updated.append(el) return updated def _require_dash() -> None: if _DASH_IMPORT_ERROR is not None: raise ImportError( "Dash viewer support requires `dash` and `dash-cytoscape`. " "Install `ExposoGraph[viewer]` or `pip install dash dash-cytoscape`." ) from _DASH_IMPORT_ERROR def _typed_dash_callback( app: Any, ) -> Callable[..., Callable[[_CallbackFunc], _CallbackFunc]]: return cast(Callable[..., Callable[[_CallbackFunc], _CallbackFunc]], app.callback) def _slug(value: str | None) -> str: if not value: return "unknown" slug = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower()) return slug.strip("-") or "unknown" def _coerce_bundle( source: GraphEngine | KnowledgeGraph | CytoscapeBundle | Mapping[str, Any] | str | Path, *, visibility: GraphVisibility | str = GraphVisibility.ALL, layout_mode: ViewerLayoutMode | str = ViewerLayoutMode.COSE, positions: Mapping[str, Mapping[str, float]] | None = None, ) -> CytoscapeBundle: if isinstance(source, CytoscapeBundle): return source if isinstance(source, Mapping): return CytoscapeBundle.from_dict(source) if isinstance(source, (str, Path)): return load_cytoscape_bundle(source) return build_cytoscape_bundle( source, visibility=visibility, positions=positions, layout_mode=layout_mode, ) def _split_elements(elements: Iterable[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: nodes: list[dict[str, Any]] = [] edges: list[dict[str, Any]] = [] for element in elements: data = element.get("data", {}) if data.get("source") and data.get("target"): edges.append(copy.deepcopy(element)) else: nodes.append(copy.deepcopy(element)) return nodes, edges def _is_edge_element(element: Mapping[str, Any]) -> bool: data = dict(element.get("data", {})) return bool(data.get("source") and data.get("target")) def _element_id(element: Mapping[str, Any]) -> str | None: data = dict(element.get("data", {})) element_id = data.get("id") return str(element_id) if element_id is not None else None def _extract_positions(elements: Iterable[Mapping[str, Any]]) -> dict[str, dict[str, float]]: positions: dict[str, dict[str, float]] = {} for element in elements: if _is_edge_element(element): continue element_id = _element_id(element) position = element.get("position") if ( element_id is None or not isinstance(position, Mapping) or "x" not in position or "y" not in position ): continue positions[element_id] = { "x": float(position["x"]), "y": float(position["y"]), } return positions def _node_classes(element: dict[str, Any]) -> set[str]: classes = element.get("classes", "") return {part for part in str(classes).split() if part} def _set_classes(element: dict[str, Any], classes: set[str]) -> dict[str, Any]: element["classes"] = " ".join(sorted(classes)) return element def _search_blob(data: Mapping[str, Any]) -> str: fields = [ "id", "label", "type", "detail", "group", "iarc", "phase", "role", "reactivity", "source_db", "evidence", "pmid", "tissue", "variant", "phenotype", "origin", "match_status", "canonical_label", "canonical_id", "canonical_predicate", "custom_type", "custom_predicate", "label", ] values = [str(data.get(field, "")) for field in fields if data.get(field)] return " ".join(values).lower() def _toggle_filter_values( current_values: Iterable[str] | None, clicked_value: str, *, available_values: Iterable[str], ) -> list[str]: ordered = [str(value) for value in available_values] current = [str(value) for value in current_values or [] if str(value) in ordered] if clicked_value not in ordered: return current if clicked_value in current: return [value for value in current if value != clicked_value] next_values = [value for value in ordered if value in current] next_values.append(clicked_value) return next_values def _merge_bundle_positions( bundle_data: Mapping[str, Any], graph_elements: Iterable[Mapping[str, Any]], ) -> dict[str, Any] | None: latest_positions = _extract_positions(graph_elements) if not latest_positions: return None existing_positions = { str(node_id): { "x": float(coords["x"]), "y": float(coords["y"]), } for node_id, coords in dict(bundle_data.get("positions", {})).items() if isinstance(coords, Mapping) and "x" in coords and "y" in coords } changed = False merged_positions = dict(existing_positions) for node_id, coords in latest_positions.items(): previous = existing_positions.get(node_id) if previous != coords: merged_positions[node_id] = coords changed = True if not changed: return None merged_bundle = copy.deepcopy(dict(bundle_data)) merged_bundle["positions"] = merged_positions merged_elements = [] for element in merged_bundle.get("elements", []): element_node_id = _element_id(element) if _is_edge_element(element) or element_node_id not in merged_positions: merged_elements.append(element) continue updated_element = copy.deepcopy(element) updated_element["position"] = merged_positions[element_node_id] merged_elements.append(updated_element) merged_bundle["elements"] = merged_elements return merged_bundle def _relevant_group_scope( nodes: list[dict[str, Any]], edges: list[dict[str, Any]], carcinogen_group: str | None, ) -> tuple[set[str], set[str]]: all_node_ids = {str(node["data"]["id"]) for node in nodes} all_edge_ids = {str(edge["data"]["id"]) for edge in edges} if not carcinogen_group: return all_node_ids, all_edge_ids carcinogen_ids = { str(node["data"]["id"]) for node in nodes if node["data"].get("type") == "Carcinogen" and node["data"].get("group") == carcinogen_group } if not carcinogen_ids: return set(), set() relevant_node_ids = set(carcinogen_ids) relevant_edge_ids: set[str] = set() for edge in edges: data = edge["data"] source = str(data.get("source", "")) target = str(data.get("target", "")) if ( source in carcinogen_ids or target in carcinogen_ids or data.get("carcinogen") in carcinogen_ids ): relevant_edge_ids.add(str(data["id"])) relevant_node_ids.update({source, target}) for edge in edges: data = edge["data"] if data.get("type") not in {"FORMS_ADDUCT", "REPAIRS"}: continue source = str(data.get("source", "")) target = str(data.get("target", "")) if source in relevant_node_ids or target in relevant_node_ids: relevant_edge_ids.add(str(data["id"])) relevant_node_ids.update({source, target}) return relevant_node_ids, relevant_edge_ids def _counts_by_type(elements: Iterable[dict[str, Any]], *, edge: bool) -> dict[str, int]: counts: dict[str, int] = {} for element in elements: data = element.get("data", {}) key = str(data.get("type", "Edge" if edge else "Node")) counts[key] = counts.get(key, 0) + 1 return dict(sorted(counts.items()))
[docs] def apply_viewer_filters( bundle: CytoscapeBundle | Mapping[str, Any], *, search_query: str = "", node_types: Iterable[str] | None = None, edge_types: Iterable[str] | None = None, carcinogen_group: str | None = None, layout_mode: ViewerLayoutMode | str | None = None, focus_node_id: str | None = None, focus_edge_id: str | None = None, ) -> ViewerState: resolved_bundle = bundle if isinstance(bundle, CytoscapeBundle) else CytoscapeBundle.from_dict(bundle) nodes, edges = _split_elements(resolved_bundle.elements) allowed_node_types = ( {str(node["data"].get("type", "")) for node in nodes} if node_types is None else set(node_types) ) allowed_edge_types = ( {str(edge["data"].get("type", "")) for edge in edges} if edge_types is None else set(edge_types) ) group_node_ids, group_edge_ids = _relevant_group_scope(nodes, edges, carcinogen_group) visible_nodes = [ node for node in nodes if str(node["data"].get("type", "")) in allowed_node_types and str(node["data"].get("id", "")) in group_node_ids ] visible_node_ids = {str(node["data"]["id"]) for node in visible_nodes} visible_edges = [ edge for edge in edges if str(edge["data"].get("type", "")) in allowed_edge_types and str(edge["data"].get("id", "")) in group_edge_ids and str(edge["data"].get("source", "")) in visible_node_ids and str(edge["data"].get("target", "")) in visible_node_ids ] neighbor_map: dict[str, set[str]] = {node_id: set() for node_id in visible_node_ids} edge_by_id: dict[str, dict[str, Any]] = {} for edge in visible_edges: data = edge["data"] edge_id = str(data["id"]) source = str(data["source"]) target = str(data["target"]) edge_by_id[edge_id] = edge neighbor_map.setdefault(source, set()).add(target) neighbor_map.setdefault(target, set()).add(source) active_node_ids: set[str] = set() active_edge_ids: set[str] = set() query = search_query.strip().lower() if query: matching_nodes = { str(node["data"]["id"]) for node in visible_nodes if query in _search_blob(node["data"]) } matching_edges = { str(edge["data"]["id"]) for edge in visible_edges if query in _search_blob(edge["data"]) } active_node_ids.update(matching_nodes) for edge_id in matching_edges: edge = edge_by_id[edge_id] active_edge_ids.add(edge_id) active_node_ids.update( { str(edge["data"]["source"]), str(edge["data"]["target"]), } ) for node_id in list(active_node_ids): active_node_ids.update(neighbor_map.get(node_id, set())) if focus_edge_id and focus_edge_id in edge_by_id: edge = edge_by_id[focus_edge_id] active_edge_ids.add(focus_edge_id) active_node_ids.update( { str(edge["data"]["source"]), str(edge["data"]["target"]), } ) if focus_node_id and focus_node_id in visible_node_ids: active_node_ids.add(focus_node_id) active_node_ids.update(neighbor_map.get(focus_node_id, set())) should_dim = bool(query or focus_node_id or focus_edge_id) if should_dim and not active_node_ids and not active_edge_ids: active_node_ids = set() active_edge_ids = set() filtered_elements: list[dict[str, Any]] = [] for node in visible_nodes: node_id = str(node["data"]["id"]) classes = _node_classes(node) classes.discard("dimmed") classes.discard("connected") classes.discard("selected") if should_dim and node_id not in active_node_ids: classes.add("dimmed") else: if focus_node_id and node_id in active_node_ids: classes.add("connected") if focus_edge_id and node_id in active_node_ids: classes.add("connected") if focus_node_id and node_id == focus_node_id: classes.add("selected") filtered_elements.append(_set_classes(node, classes)) for edge in visible_edges: edge_id = str(edge["data"]["id"]) source = str(edge["data"]["source"]) target = str(edge["data"]["target"]) classes = _node_classes(edge) classes.discard("dimmed") classes.discard("connected") classes.discard("selected") connected = ( edge_id in active_edge_ids or source in active_node_ids or target in active_node_ids ) if should_dim and not connected: classes.add("dimmed") elif connected and should_dim: classes.add("connected") if focus_edge_id and edge_id == focus_edge_id: classes.add("selected") filtered_elements.append(_set_classes(edge, classes)) return ViewerState( elements=filtered_elements, layout=viewer_layout(layout_mode or resolved_bundle.metadata.get("layout_mode", "cose")), visible_node_count=len(visible_nodes), visible_edge_count=len(visible_edges), node_type_counts=_counts_by_type(visible_nodes, edge=False), edge_type_counts=_counts_by_type(visible_edges, edge=True), )
[docs] def build_detail_payload( bundle: CytoscapeBundle | Mapping[str, Any], selection: Mapping[str, Any] | None, ) -> dict[str, Any]: resolved_bundle = bundle if isinstance(bundle, CytoscapeBundle) else CytoscapeBundle.from_dict(bundle) nodes, edges = _split_elements(resolved_bundle.elements) node_by_id = {str(node["data"]["id"]): node["data"] for node in nodes} edge_by_id = {str(edge["data"]["id"]): edge["data"] for edge in edges} if not selection: return { "title": "Graph Overview", "subtitle": "Advanced Cytoscape viewer", "summary": ( f"{resolved_bundle.metadata.get('node_count', 0)} nodes · " f"{resolved_bundle.metadata.get('edge_count', 0)} edges" ), "fields": [], "connections": [], } kind = str(selection.get("kind", "node")) selected_id = str(selection.get("id", "")) if kind == "edge" and selected_id in edge_by_id: edge = edge_by_id[selected_id] return { "title": edge.get("label") or edge.get("type") or "Edge", "subtitle": f"{edge.get('source')}{edge.get('target')}", "summary": edge.get("type", "Edge"), "fields": [ ("Type", edge.get("type")), ("Source", edge.get("source")), ("Target", edge.get("target")), ("Origin", edge.get("origin")), ("Match", edge.get("match_status")), ("Canonical", edge.get("canonical_predicate")), ("Custom", edge.get("custom_predicate")), ("Carcinogen", edge.get("carcinogen")), ("Source DB", edge.get("source_db")), ("Evidence", edge.get("evidence")), ("PMID", edge.get("pmid")), ], "connections": [], } node = node_by_id.get(selected_id) if node is None: return { "title": "Selection cleared", "subtitle": "", "summary": "Choose a node or edge to inspect it.", "fields": [], "connections": [], } outgoing: list[str] = [] incoming: list[str] = [] for edge in edge_by_id.values(): if edge.get("source") == selected_id: target_label = node_by_id.get(str(edge.get("target")), {}).get("label", edge.get("target")) outgoing.append(f"{edge.get('type')}{target_label}") if edge.get("target") == selected_id: source_label = node_by_id.get(str(edge.get("source")), {}).get("label", edge.get("source")) incoming.append(f"{edge.get('type')}{source_label}") connections = [] if outgoing: connections.append(("Outgoing", outgoing)) if incoming: connections.append(("Incoming", incoming)) return { "title": node.get("label") or selected_id, "subtitle": node.get("type", "Node"), "summary": node.get("detail") or "No detail available.", "fields": [ ("ID", node.get("id")), ("Origin", node.get("origin")), ("Match", node.get("match_status")), ("Canonical", node.get("canonical_label") or node.get("canonical_id")), ("Group", node.get("group")), ("IARC", node.get("iarc")), ("Phase", node.get("phase")), ("Role", node.get("role")), ("Reactivity", node.get("reactivity")), ("Tissue", node.get("tissue")), ("Variant", node.get("variant")), ("Phenotype", node.get("phenotype")), ("Source DB", node.get("source_db")), ("Evidence", node.get("evidence")), ("PMID", node.get("pmid")), ], "connections": connections, }
[docs] def create_dash_viewer_app( source: GraphEngine | KnowledgeGraph | CytoscapeBundle | Mapping[str, Any] | str | Path, *, visibility: GraphVisibility | str = GraphVisibility.ALL, positions: Mapping[str, Mapping[str, float]] | None = None, layout_mode: ViewerLayoutMode | str = ViewerLayoutMode.PRESET, title: str | None = None, ) -> Any: _require_dash() assert ( cyto is not None and DashApp is not None and dcc is not None and html is not None and ALL is not None ) cyto.load_extra_layouts() bundle = _coerce_bundle( source, visibility=visibility, layout_mode=layout_mode, positions=positions, ) app = DashApp(__name__) viewer_title = title or f"{APP_NAME} Advanced Viewer" typed_callback = _typed_dash_callback(app) node_type_options = list(bundle.metadata.get("node_type_counts", {}).keys()) edge_type_options = list(bundle.metadata.get("edge_type_counts", {}).keys()) carcinogen_groups = list(bundle.metadata.get("carcinogen_groups", {}).keys()) app.layout = html.Div( style={ "minHeight": "100vh", "background": ( "radial-gradient(circle at top, rgba(56, 108, 160, 0.28), transparent 38%), " "linear-gradient(180deg, #09111d 0%, #060c15 100%)" ), "color": "#e6edf7", "fontFamily": "Inter, system-ui, sans-serif", "padding": "24px", }, children=[ dcc.Store(id="viewer-bundle-store", data=bundle.to_dict()), dcc.Store(id="viewer-selection-store"), dcc.Download(id="viewer-layout-download"), html.Div( style={"maxWidth": "1700px", "margin": "0 auto"}, children=[ html.Div( style={ "display": "flex", "justifyContent": "space-between", "alignItems": "flex-start", "gap": "22px", "marginBottom": "18px", "padding": "18px 20px", "borderRadius": "18px", "border": "1px solid rgba(124, 154, 185, 0.18)", "background": "linear-gradient(180deg, rgba(18, 26, 39, 0.82), rgba(9, 15, 27, 0.92))", }, children=[ html.Div( children=[ html.H1( viewer_title, style={"margin": "0", "fontSize": "2rem", "letterSpacing": "-0.02em"}, ), html.P( "Advanced Dash Cytoscape viewer for exploratory and validated ExposoGraph outputs.", style={"margin": "8px 0 0", "color": "#9fb1c8", "maxWidth": "48rem", "lineHeight": 1.5}, ), ] ), html.Div( id="viewer-stats", style={ "flex": "1 1 auto", }, ), ], ), html.Div( style={ "display": "grid", "gridTemplateColumns": "330px minmax(0, 1fr)", "gap": "18px", "alignItems": "start", }, children=[ html.Aside( style=_SIDEBAR_STYLE, children=[ html.Div( style=_CARD_STYLE, children=[ html.Div("Search", style=_PANEL_TITLE_STYLE), dcc.Input( id="viewer-search", type="text", placeholder="label, id, detail, PMID, tissue...", value="", debounce=True, style=_INPUT_STYLE, ), html.Div( "Matches expand to immediate neighbors, similar to the manuscript viewer.", style={"marginTop": "8px", "color": "#8da3bc", "fontSize": "0.8rem", "lineHeight": 1.45}, ), ], ), html.Div( style=_CARD_STYLE, children=[ html.Div("Filters", style=_PANEL_TITLE_STYLE), html.Div("Node types", style={"fontSize": "0.82rem", "color": "#9fb1c8", "marginBottom": "6px"}), dcc.Dropdown( id="viewer-node-types", options=[{"label": value, "value": value} for value in node_type_options], value=node_type_options, multi=True, clearable=False, style={"borderRadius": "10px"}, ), html.Div("Edge types", style={"fontSize": "0.82rem", "color": "#9fb1c8", "marginTop": "12px", "marginBottom": "6px"}), dcc.Dropdown( id="viewer-edge-types", options=[{"label": value, "value": value} for value in edge_type_options], value=edge_type_options, multi=True, clearable=False, style={"borderRadius": "10px"}, ), html.Div("Carcinogen class", style={"fontSize": "0.82rem", "color": "#9fb1c8", "marginTop": "12px", "marginBottom": "6px"}), dcc.Dropdown( id="viewer-carcinogen-group", options=[{"label": "All", "value": ""}] + [ {"label": value.replace("_", " "), "value": value} for value in carcinogen_groups ], value="", clearable=False, style={"borderRadius": "10px"}, ), html.Div("Layout", style={"fontSize": "0.82rem", "color": "#9fb1c8", "marginTop": "12px", "marginBottom": "6px"}), dcc.Dropdown( id="viewer-layout-mode", options=[ {"label": "Force", "value": ViewerLayoutMode.COSE.value}, {"label": "Hierarchy", "value": ViewerLayoutMode.BREADTHFIRST.value}, {"label": "Circle", "value": ViewerLayoutMode.CIRCLE.value}, {"label": "Saved Preset", "value": ViewerLayoutMode.PRESET.value}, ], value=normalize_viewer_layout_mode(layout_mode).value, clearable=False, style={"borderRadius": "10px"}, ), html.Div("Genotype profile", style={"fontSize": "0.82rem", "color": "#9fb1c8", "marginTop": "12px", "marginBottom": "6px"}), dcc.Dropdown( id="viewer-genotype-profile", options=[ {"label": "Normal (all activity = 1.0)", "value": "normal"}, {"label": "CYP1A1*2B / GSTM1-null", "value": "bap_high_risk"}, {"label": "NAT2 slow acetylator", "value": "nat2_slow"}, {"label": "CYP2E1 c2/c2 high activity", "value": "cyp2e1_high"}, ], value="normal", clearable=False, style={"borderRadius": "10px"}, ), html.Div( _GENOTYPE_MESSAGES["normal"], id="viewer-genotype-feedback", style={ "fontSize": "0.78rem", "color": "#9fb1c8", "marginTop": "6px", "lineHeight": "1.35", }, ), ], ), html.Div(id="viewer-node-legend", style=_CARD_STYLE), html.Div(id="viewer-edge-legend", style=_CARD_STYLE), html.Div(id="viewer-detail", style=_CARD_STYLE), ], ), html.Div( style={ "position": "relative", "background": "rgba(18, 26, 39, 0.84)", "border": "1px solid rgba(124, 154, 185, 0.18)", "borderRadius": "18px", "padding": "12px", "boxShadow": "0 18px 44px rgba(0, 0, 0, 0.22)", }, children=[ html.Div( style={ "display": "flex", "justifyContent": "space-between", "gap": "10px", "alignItems": "center", "marginBottom": "8px", "flexWrap": "wrap", }, children=[ html.Div( [ html.Div("Interactive Graph", style=_PANEL_TITLE_STYLE), html.Div( "Hover to inspect neighborhoods. Click a node or edge to pin details.", style={"color": "#9fb1c8", "fontSize": "0.92rem"}, ), ], ), html.Div( style={"display": "flex", "gap": "8px", "flexWrap": "wrap"}, children=[ html.Button("Reset Focus", id="viewer-reset-focus", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("+", id="viewer-zoom-in", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("−", id="viewer-zoom-out", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("Reset View", id="viewer-zoom-reset", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("PNG", id="viewer-export-png", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("SVG", id="viewer-export-svg", n_clicks=0, style=_ACTION_BUTTON_STYLE), html.Button("Layout JSON", id="viewer-export-layout", n_clicks=0, style=_ACTION_BUTTON_STYLE), ], ), ], ), html.Div(id="viewer-hover-tooltip", style={**_HOVER_TOOLTIP_STYLE, "display": "none"}), cyto.Cytoscape( id="advanced-viewer-graph", elements=bundle.elements, stylesheet=bundle.stylesheet, layout=bundle.layout, style={ "width": "100%", "height": "78vh", "background": ( "radial-gradient(circle at center, rgba(90, 160, 222, 0.06), transparent 45%), " "linear-gradient(180deg, rgba(9, 17, 29, 0.84), rgba(6, 12, 21, 0.96))" ), "borderRadius": "12px", "border": "1px solid rgba(124, 154, 185, 0.12)", }, userPanningEnabled=True, userZoomingEnabled=True, autoRefreshLayout=False, clearOnUnhover=True, responsive=True, minZoom=0.15, maxZoom=3.5, wheelSensitivity=0.2, boxSelectionEnabled=False, ), html.Div( style={ "display": "flex", "justifyContent": "space-between", "gap": "10px", "alignItems": "center", "marginTop": "8px", "fontSize": "0.82rem", "color": "#8da3bc", "flexWrap": "wrap", }, children=[ html.Div("Search and hover dim non-relevant neighborhoods, mirroring the old manuscript viewer."), html.Div("Dash Cytoscape viewer"), ], ), ], ), ], ), ], ), ], ) @typed_callback( DashOutput("viewer-node-types", "value"), DashOutput("viewer-edge-types", "value"), DashInput({"type": "viewer-legend-toggle", "kind": "node", "value": ALL}, "n_clicks"), DashInput({"type": "viewer-legend-toggle", "kind": "edge", "value": ALL}, "n_clicks"), DashState("viewer-node-types", "value"), DashState("viewer-edge-types", "value"), DashState("viewer-bundle-store", "data"), prevent_initial_call=True, ) def _toggle_legend_filters( _node_clicks: list[int], _edge_clicks: list[int], node_type_values: list[str], edge_type_values: list[str], bundle_data: dict[str, Any], ) -> tuple[Any, Any]: triggered = getattr(ctx, "triggered_id", None) if not isinstance(triggered, dict): return no_update, no_update metadata = bundle_data.get("metadata", {}) if triggered.get("kind") == "node": next_node_values = _toggle_filter_values( node_type_values, str(triggered.get("value", "")), available_values=metadata.get("node_type_counts", {}).keys(), ) return next_node_values, no_update if triggered.get("kind") == "edge": next_edge_values = _toggle_filter_values( edge_type_values, str(triggered.get("value", "")), available_values=metadata.get("edge_type_counts", {}).keys(), ) return no_update, next_edge_values return no_update, no_update @typed_callback( DashOutput("viewer-bundle-store", "data"), DashInput("advanced-viewer-graph", "elements"), DashState("viewer-bundle-store", "data"), prevent_initial_call=True, ) def _persist_positions( graph_elements: list[dict[str, Any]], bundle_data: dict[str, Any], ) -> Any: merged = _merge_bundle_positions(bundle_data, graph_elements) return merged if merged is not None else no_update @typed_callback( DashOutput("advanced-viewer-graph", "elements"), DashOutput("advanced-viewer-graph", "stylesheet"), DashOutput("advanced-viewer-graph", "layout"), DashOutput("viewer-stats", "children"), DashOutput("viewer-node-legend", "children"), DashOutput("viewer-edge-legend", "children"), DashOutput("viewer-genotype-feedback", "children"), DashInput("viewer-bundle-store", "data"), DashInput("viewer-search", "value"), DashInput("viewer-node-types", "value"), DashInput("viewer-edge-types", "value"), DashInput("viewer-carcinogen-group", "value"), DashInput("viewer-layout-mode", "value"), DashInput("viewer-genotype-profile", "value"), DashInput("advanced-viewer-graph", "mouseoverNodeData"), DashInput("advanced-viewer-graph", "mouseoverEdgeData"), DashInput("viewer-selection-store", "data"), ) def _update_graph( bundle_data: dict[str, Any], search_value: str, node_type_values: list[str], edge_type_values: list[str], carcinogen_group_value: str, layout_value: str, genotype_profile: str, hovered_node: dict[str, Any] | None, hovered_edge: dict[str, Any] | None, selection: dict[str, Any] | None, ) -> tuple[Any, Any, Any, Any, Any, Any, Any]: selected_node_id = None selected_edge_id = None if selection: if selection.get("kind") == "node": selected_node_id = str(selection.get("id", "")) elif selection.get("kind") == "edge": selected_edge_id = str(selection.get("id", "")) focus_node_id = selected_node_id or (str(hovered_node.get("id")) if hovered_node else None) focus_edge_id = selected_edge_id or (str(hovered_edge.get("id")) if hovered_edge else None) state = apply_viewer_filters( bundle_data, search_query=search_value or "", node_types=node_type_values, edge_types=edge_type_values, carcinogen_group=carcinogen_group_value or None, layout_mode=layout_value, focus_node_id=focus_node_id, focus_edge_id=focus_edge_id, ) stats = _stats_children( bundle_data.get("metadata", {}), state, layout_value=layout_value or bundle_data.get("metadata", {}).get("layout_mode", "cose"), search_value=search_value or "", carcinogen_group_value=carcinogen_group_value or "", ) node_legend = _legend_children( "Node Types", state.node_type_counts, { "Carcinogen": "#e05565", "Enzyme": "#4f98a3", "Gene": "#3d8b8b", "Metabolite": "#e8945a", "DNA_Adduct": "#a86fdf", "Pathway": "#5591c7", "Tissue": "#c2855a", }, kind="node", active_values=node_type_values, ) edge_legend = _legend_children( "Edge Types", state.edge_type_counts, { "ACTIVATES": "#e05565", "DETOXIFIES": "#6daa45", "TRANSPORTS": "#5591c7", "FORMS_ADDUCT": "#a86fdf", "REPAIRS": "#e8af34", "PATHWAY": "#707a8a", "EXPRESSED_IN": "#c2855a", "INDUCES": "#d4a843", "INHIBITS": "#8b4a6b", "ENCODES": "#3d8b8b", "CUSTOM": "#9ea9bd", }, kind="edge", active_values=edge_type_values, ) final_elements = _apply_genotype_profile(state.elements, genotype_profile) genotype_feedback = _GENOTYPE_MESSAGES.get(genotype_profile or "normal", _GENOTYPE_MESSAGES["normal"]) return ( final_elements, bundle_data["stylesheet"], state.layout, stats, node_legend, edge_legend, genotype_feedback, ) @typed_callback( DashOutput("viewer-selection-store", "data"), DashInput("advanced-viewer-graph", "tapNodeData"), DashInput("advanced-viewer-graph", "tapEdgeData"), DashInput("viewer-reset-focus", "n_clicks"), ) def _update_selection( tap_node: dict[str, Any] | None, tap_edge: dict[str, Any] | None, _reset_clicks: int, ) -> Any: prop_id = "" if getattr(ctx, "triggered", None): prop_id = str(ctx.triggered[0].get("prop_id", "")) selection: dict[str, Any] | None if prop_id == "viewer-reset-focus.n_clicks": selection = None elif prop_id == "advanced-viewer-graph.tapNodeData" and tap_node is not None: selection = {"kind": "node", "id": tap_node.get("id"), "data": tap_node} elif prop_id == "advanced-viewer-graph.tapEdgeData" and tap_edge is not None: selection = {"kind": "edge", "id": tap_edge.get("id"), "data": tap_edge} else: if tap_node is not None: selection = {"kind": "node", "id": tap_node.get("id"), "data": tap_node} elif tap_edge is not None: selection = {"kind": "edge", "id": tap_edge.get("id"), "data": tap_edge} else: selection = None return selection @typed_callback( DashOutput("viewer-detail", "children"), DashInput("viewer-bundle-store", "data"), DashInput("viewer-selection-store", "data"), DashInput("advanced-viewer-graph", "mouseoverNodeData"), DashInput("advanced-viewer-graph", "mouseoverEdgeData"), ) def _render_detail( bundle_data: dict[str, Any], selection: dict[str, Any] | None, hovered_node: dict[str, Any] | None, hovered_edge: dict[str, Any] | None, ) -> Any: context_label = "Overview" focus = selection if selection: context_label = "Pinned Selection" elif hovered_node is not None: focus = {"kind": "node", "id": hovered_node.get("id"), "data": hovered_node} context_label = "Hover Node" elif hovered_edge is not None: focus = {"kind": "edge", "id": hovered_edge.get("id"), "data": hovered_edge} context_label = "Hover Edge" detail_payload = build_detail_payload(bundle_data, focus) return _detail_children(detail_payload, context_label=context_label) @typed_callback( DashOutput("viewer-hover-tooltip", "children"), DashOutput("viewer-hover-tooltip", "style"), DashInput("viewer-bundle-store", "data"), DashInput("advanced-viewer-graph", "mouseoverNodeData"), DashInput("advanced-viewer-graph", "mouseoverEdgeData"), ) def _render_hover_tooltip( bundle_data: dict[str, Any], hovered_node: dict[str, Any] | None, hovered_edge: dict[str, Any] | None, ) -> tuple[Any, Any]: if hovered_node is not None: payload = build_detail_payload( bundle_data, {"kind": "node", "id": hovered_node.get("id"), "data": hovered_node}, ) return _hover_tooltip_children(payload, "Hover Node"), {**_HOVER_TOOLTIP_STYLE, "display": "block"} if hovered_edge is not None: payload = build_detail_payload( bundle_data, {"kind": "edge", "id": hovered_edge.get("id"), "data": hovered_edge}, ) return _hover_tooltip_children(payload, "Hover Edge"), {**_HOVER_TOOLTIP_STYLE, "display": "block"} return [], {**_HOVER_TOOLTIP_STYLE, "display": "none"} @typed_callback( DashOutput("advanced-viewer-graph", "zoom"), DashOutput("advanced-viewer-graph", "pan"), DashInput("viewer-zoom-in", "n_clicks"), DashInput("viewer-zoom-out", "n_clicks"), DashInput("viewer-zoom-reset", "n_clicks"), DashState("advanced-viewer-graph", "zoom"), DashState("advanced-viewer-graph", "pan"), prevent_initial_call=True, ) def _update_zoom( _zoom_in: int, _zoom_out: int, _zoom_reset: int, zoom: float | None, pan: dict[str, float] | None, ) -> tuple[Any, Any]: triggered = getattr(ctx, "triggered_id", None) current_zoom = float(zoom or 1.0) if triggered == "viewer-zoom-in": return min(current_zoom * 1.18, 3.5), pan or {"x": 0, "y": 0} if triggered == "viewer-zoom-out": return max(current_zoom / 1.18, 0.15), pan or {"x": 0, "y": 0} if triggered == "viewer-zoom-reset": return 1.0, {"x": 0, "y": 0} return no_update, no_update @typed_callback( DashOutput("advanced-viewer-graph", "generateImage"), DashInput("viewer-export-png", "n_clicks"), DashInput("viewer-export-svg", "n_clicks"), prevent_initial_call=True, ) def _export_image(_png_clicks: int, _svg_clicks: int) -> Any: triggered = getattr(ctx, "triggered_id", None) if triggered == "viewer-export-png": return {"type": "png", "action": "download", "filename": _slug(viewer_title)} if triggered == "viewer-export-svg": return {"type": "svg", "action": "download", "filename": _slug(viewer_title)} return no_update @typed_callback( DashOutput("viewer-layout-download", "data"), DashInput("viewer-export-layout", "n_clicks"), DashState("viewer-bundle-store", "data"), prevent_initial_call=True, ) def _export_layout(_clicks: int, bundle_data: dict[str, Any]) -> Any: positions = bundle_data.get("positions", {}) return cast(Any, dcc).send_string( json.dumps(positions, indent=2), f"{_slug(viewer_title)}_layout.json", ) return app
[docs] def launch_dash_viewer( source: GraphEngine | KnowledgeGraph | CytoscapeBundle | Mapping[str, Any] | str | Path, *, visibility: GraphVisibility | str = GraphVisibility.ALL, positions: Mapping[str, Mapping[str, float]] | None = None, layout_mode: ViewerLayoutMode | str = ViewerLayoutMode.PRESET, title: str | None = None, host: str = "127.0.0.1", port: int = 8050, debug: bool = False, jupyter_mode: str | None = None, ) -> Any: app = create_dash_viewer_app( source, visibility=visibility, positions=positions, layout_mode=layout_mode, title=title, ) run_kwargs: dict[str, Any] = {"host": host, "port": port, "debug": debug} if jupyter_mode is not None: run_kwargs["jupyter_mode"] = jupyter_mode app.run(**run_kwargs) return app
def _stats_children( metadata: Mapping[str, Any], state: ViewerState, *, layout_value: str, search_value: str, carcinogen_group_value: str, ) -> Any: _require_dash() assert html is not None total_nodes = int(metadata.get("node_count", state.visible_node_count)) total_edges = int(metadata.get("edge_count", state.visible_edge_count)) node_types = len(metadata.get("node_type_counts", {})) edge_types = len(metadata.get("edge_type_counts", {})) chips = [ _pill(f"layout: {layout_value}", accent="#76c3ff"), _pill(f"view: {metadata.get('visibility', 'all')}", accent="#7de3a0"), ] if carcinogen_group_value: chips.append(_pill(f"group: {carcinogen_group_value.replace('_', ' ')}", accent="#e8af34")) if search_value.strip(): chips.append(_pill(f"search: {search_value.strip()}", accent="#a86fdf")) return html.Div( [ html.Div( style={"display": "flex", "gap": "10px", "justifyContent": "flex-end", "flexWrap": "wrap"}, children=[ _stat_card("Nodes", str(state.visible_node_count), f"of {total_nodes}"), _stat_card("Edges", str(state.visible_edge_count), f"of {total_edges}"), _stat_card("Node Types", str(node_types), "classes"), _stat_card("Edge Types", str(edge_types), "relations"), ], ), html.Div( style={"display": "flex", "gap": "8px", "justifyContent": "flex-end", "flexWrap": "wrap", "marginTop": "10px"}, children=chips, ), ] ) def _stat_card(label: str, value: str, hint: str) -> Any: _require_dash() assert html is not None return html.Div( style={ "minWidth": "96px", "padding": "10px 12px", "borderRadius": "12px", "border": "1px solid rgba(124, 154, 185, 0.2)", "background": "linear-gradient(180deg, rgba(18, 26, 39, 0.88), rgba(10, 16, 28, 0.94))", "textAlign": "left", }, children=[ html.Div(label, style={"fontSize": "0.72rem", "color": "#8da3bc", "textTransform": "uppercase", "letterSpacing": "0.08em"}), html.Div(value, style={"fontSize": "1.25rem", "fontWeight": 700, "marginTop": "4px"}), html.Div(hint, style={"fontSize": "0.75rem", "color": "#9fb1c8", "marginTop": "2px"}), ], ) def _pill(label: str, *, accent: str) -> Any: _require_dash() assert html is not None return html.Span( [ html.Span( style={ "display": "inline-block", "width": "8px", "height": "8px", "borderRadius": "999px", "background": accent, "marginRight": "7px", } ), label, ], style={ "display": "inline-flex", "alignItems": "center", "padding": "6px 10px", "borderRadius": "999px", "border": "1px solid rgba(124, 154, 185, 0.16)", "background": "rgba(13, 21, 34, 0.8)", "color": "#b8c7d8", "fontSize": "0.8rem", }, ) def _legend_children( title: str, counts: Mapping[str, int], colors: Mapping[str, str], *, kind: str, active_values: Iterable[str] | None, ) -> Any: _require_dash() assert html is not None if not counts: return html.Div( [html.Div(title, style=_PANEL_TITLE_STYLE), html.Div("No items", style={"color": "#9fb1c8"})] ) active = {str(value) for value in active_values or []} return html.Div( [ html.Div( [ html.Div(title, style=_PANEL_TITLE_STYLE), html.Div( "Click rows to toggle filters", style={"fontSize": "0.76rem", "color": "#7c92ab", "marginBottom": "8px"}, ), ] ), html.Div( [ html.Button( id={"type": "viewer-legend-toggle", "kind": kind, "value": name}, n_clicks=0, style={ "display": "flex", "width": "100%", "justifyContent": "space-between", "alignItems": "center", "gap": "12px", "padding": "8px 10px", "marginBottom": "6px", "borderRadius": "10px", "border": ( f"1px solid {colors.get(name, '#8ea4bb')}" if name in active else "1px solid rgba(124, 154, 185, 0.08)" ), "background": ( "rgba(85, 145, 199, 0.14)" if name in active else "rgba(8, 14, 24, 0.42)" ), "color": "#e6edf7" if name in active else "#cbd7e6", "cursor": "pointer", "textAlign": "left", }, children=[ html.Span( [ html.Span( style={ "display": "inline-block", "width": "10px", "height": "10px", "borderRadius": "999px", "background": colors.get(name, "#8ea4bb"), "marginRight": "8px", } ), html.Span(name, style={"fontWeight": 600 if name in active else 500}), ] ), html.Span(str(count), style={"color": "#9fb1c8", "fontFamily": "monospace"}), ], ) for name, count in counts.items() ] ), ] ) def _hover_tooltip_children(payload: Mapping[str, Any], context_label: str) -> Any: _require_dash() assert html is not None subtitle = payload.get("subtitle", "") summary = payload.get("summary", "") field_rows = [ html.Div( [ html.Span(f"{label}: ", style={"color": "#93a8bf"}), html.Span(str(value)), ], style={"marginTop": "4px", "fontSize": "0.82rem"}, ) for label, value in list(payload.get("fields", []))[:4] if value not in (None, "") ] return html.Div( [ html.Div(context_label, style={**_PANEL_TITLE_STYLE, "marginBottom": "6px"}), html.Div(payload.get("title", ""), style={"fontWeight": 700, "fontSize": "1rem", "lineHeight": 1.25}), html.Div(subtitle, style={"marginTop": "3px", "color": "#7fc4ff", "fontSize": "0.84rem"}), html.Div(summary, style={"marginTop": "8px", "color": "#d4dfeb", "fontSize": "0.82rem", "lineHeight": 1.45}), html.Div(field_rows, style={"marginTop": "6px"}), ] ) def _detail_children(payload: Mapping[str, Any], *, context_label: str = "Overview") -> Any: _require_dash() assert html is not None field_rows = [ html.Div( style={ "display": "grid", "gridTemplateColumns": "96px minmax(0, 1fr)", "gap": "8px", "marginBottom": "6px", "fontSize": "0.9rem", }, children=[ html.Div(label, style={"color": "#9fb1c8"}), html.Div(str(value)), ], ) for label, value in payload.get("fields", []) if value not in (None, "") ] connection_rows = [] for heading, items in payload.get("connections", []): connection_rows.append( html.Div( [ html.Div(heading, style={"fontWeight": 600, "marginTop": "10px", "marginBottom": "6px"}), html.Ul( [html.Li(item, style={"marginBottom": "4px"}) for item in items], style={"paddingLeft": "18px", "margin": "0"}, ), ] ) ) return html.Div( [ html.Div( style={"display": "flex", "justifyContent": "space-between", "gap": "8px", "alignItems": "center", "marginBottom": "10px"}, children=[ html.Div("Inspector", style=_PANEL_TITLE_STYLE), html.Div( context_label, style={ "padding": "4px 8px", "borderRadius": "999px", "background": "rgba(75, 119, 161, 0.16)", "border": "1px solid rgba(118, 195, 255, 0.18)", "fontSize": "0.76rem", "color": "#b7dfff", }, ), ], ), html.Div(payload.get("title", ""), style={"fontSize": "1.08rem", "fontWeight": 700, "lineHeight": 1.25}), html.Div(payload.get("subtitle", ""), style={"color": "#7fc4ff", "marginTop": "4px", "fontSize": "0.88rem"}), html.P(payload.get("summary", ""), style={"color": "#c9d5e5", "lineHeight": 1.55, "marginTop": "10px"}), html.Div(field_rows), html.Div(connection_rows), ] )