""" dashboard/views/explorer.py ──────────────────────────────────────────────────────────────── Entity Explorer page — frequency charts and co-occurrence network. Shows: - Top-N entity frequency bar chart (filterable by type) - Interactive co-occurrence network graph (pyvis in an iframe) - Specialty distribution donut chart - Severity distribution breakdown ──────────────────────────────────────────────────────────────── """ from __future__ import annotations import streamlit as st import streamlit.components.v1 as components from dashboard.api_client import ( get_cooccurrence_pairs, get_stats, get_top_entities, ) _LABEL_COLOURS: dict[str, str] = { "DISEASE": "#e74c3c", "MEDICATION": "#3498db", "PROCEDURE": "#2ecc71", "SYMPTOM": "#f39c12", "ANATOMY": "#9b59b6", } _SEVERITY_COLOURS: dict[str, str] = { "routine": "#2ecc71", "urgent": "#f39c12", "critical": "#e74c3c", } def render() -> None: """Render the Entity Explorer page.""" st.title("📊 Entity Explorer") st.markdown( "Explore the most common clinical entities and how they " "relate to each other across the dataset." ) # ── Sidebar controls ────────────────────────────────────────── with st.sidebar: st.markdown("### Filter") entity_label = st.selectbox( "Entity type", options = ["All", "DISEASE", "MEDICATION", "PROCEDURE", "SYMPTOM", "ANATOMY"], ) top_n = st.slider("Top N entities", min_value=5, max_value=50, value=20) st.markdown("---") st.markdown("### Co-occurrence graph") min_cooccur = st.slider( "Min co-occurrence", min_value=2, max_value=20, value=5 ) graph_limit = st.slider( "Max edges", min_value=20, max_value=200, value=100 ) # ── Top entities chart ──────────────────────────────────────── st.markdown("### Most Frequent Entities") label_filter = None if entity_label == "All" else entity_label entities = get_top_entities(label=label_filter, limit=top_n) if entities: import plotly.graph_objects as go texts = [e["text"] for e in entities] counts = [e["count"] for e in entities] colour = _LABEL_COLOURS.get(label_filter, "#3498db") fig = go.Figure(go.Bar( x = counts, y = texts, orientation = "h", marker_color = colour, text = counts, textposition = "outside", )) fig.update_layout( yaxis = {"autorange": "reversed"}, height = max(300, top_n * 22), margin = {"l": 10, "r": 40, "t": 10, "b": 10}, plot_bgcolor = "white", ) st.plotly_chart(fig, use_container_width=True) else: st.info("No entity data available. Run the ETL pipeline first.") # ── Co-occurrence network ───────────────────────────────────── st.markdown("### Co-occurrence Network") st.caption( "Nodes = entities · Edges = co-appear in the same note · " "Edge thickness = frequency" ) with st.spinner("Building network graph..."): pairs = get_cooccurrence_pairs( label = label_filter or "DISEASE", min_count = min_cooccur, limit = graph_limit, ) if pairs: html = _build_pyvis_html(pairs) if html: components.html(html, height=620, scrolling=False) else: _render_fallback_graph(pairs) else: st.info( "No co-occurrence pairs found. " "Try lowering the minimum co-occurrence threshold." ) # ── Stats overview ──────────────────────────────────────────── st.markdown("---") stats = get_stats() if not stats: st.warning("Could not load dataset statistics.") return col_a, col_b = st.columns(2) with col_a: st.markdown("### Specialty Distribution") top_specs = stats.get("top_specialties", []) if top_specs: import plotly.express as px df_spec = { "specialty": [s["specialty"] for s in top_specs], "count": [s["count"] for s in top_specs], } fig_spec = px.pie( df_spec, names = "specialty", values = "count", hole = 0.4, height = 340, ) fig_spec.update_traces(textposition="inside", textinfo="percent+label") fig_spec.update_layout(showlegend=False, margin={"t": 10, "b": 10}) st.plotly_chart(fig_spec, use_container_width=True) with col_b: st.markdown("### Severity Distribution") sev = stats.get("severity_distribution", {}) if sev: import plotly.graph_objects as go labels = ["routine", "urgent", "critical"] values = [sev.get(lbl, 0) for lbl in labels] colours_list = [_SEVERITY_COLOURS[lbl] for lbl in labels] fig_sev = go.Figure(go.Pie( labels = labels, values = values, hole = 0.4, marker = {"colors": colours_list}, )) fig_sev.update_traces(textposition="inside", textinfo="percent+label") fig_sev.update_layout( showlegend = False, height = 340, margin = {"t": 10, "b": 10}, ) st.plotly_chart(fig_sev, use_container_width=True) def _build_pyvis_html(pairs: list[dict]) -> str | None: """Build an interactive pyvis network from co-occurrence pairs. Args: pairs: List of ``{"source", "target", "weight"}`` dicts. Returns: HTML string for the network, or None if pyvis is unavailable. """ try: import networkx as nx from pyvis.network import Network except ImportError: return None # Build NetworkX graph from the pairs graph = nx.Graph() for pair in pairs: graph.add_edge( pair["source"], pair["target"], weight = pair["weight"], ) # Compute degree centrality for node sizing centrality = nx.degree_centrality(graph) # cdn_resources="in_line" is required here -- pyvis's default HTML # references lib/bindings/utils.js via a relative path, which 404s # when the generated HTML is injected into an iframe via # st.components.v1.html() (no files are actually served alongside # it). That file is what wires up node label rendering, so without # this the graph still draws (nodes/edges load fine from a CDN) but # every label silently fails to render. net = Network( height="600px", bgcolor="#ffffff", font_color="#333333", cdn_resources="in_line", ) for node in graph.nodes(): size = max(10, min(40, int(centrality[node] * 200))) net.add_node( node, label = f"{node} ({graph.degree(node)})", size = size, # vis-network deliberately disables HTML parsing in tooltips # (XSS protection, since entity text comes straight from the # database) -- "
" would render as literal text rather # than a line break. "\n" is the documented plain-text way # to get a real line break instead. title = f"{node}\nconnections: {graph.degree(node)}", ) for src, tgt, attrs in graph.edges(data=True): w = attrs.get("weight", 1) net.add_edge( src, tgt, width = max(1, min(8, w // 2)), title = f"co-occurs in {w} notes", ) net.set_options(""" { "nodes": { "font": { "size": 20, "strokeWidth": 3, "strokeColor": "#ffffff" } }, "physics": { "forceAtlas2Based": { "gravitationalConstant": -60, "centralGravity": 0.005, "springLength": 120, "springConstant": 0.08 }, "solver": "forceAtlas2Based", "minVelocity": 0.75 } } """) html = net.generate_html() # vis-network's default stabilization ("fit": true) auto-zooms to fit # the *entire* graph in view -- with many nodes spread out by the # forceAtlas2 layout, that can shrink everything (including labels, # regardless of the font size set above) down to nearly illegible on # screen. "network" is emitted as an implicit global by pyvis's own # generated script (no var/let/const), so it's safe to reference from # an appended script tag that runs after it. Zooming in relative to # whatever the natural fit-to-view scale turns out to be (rather than # a fixed absolute number) adapts to however large or small the # filtered graph is, instead of being wrong at the opposite extreme. zoom_script = """ """ return html.replace("", zoom_script + "") def _render_fallback_graph(pairs: list[dict]) -> None: """Render a simple Plotly scatter graph when pyvis is unavailable. A basic spring-layout visualisation using networkx + plotly. Not as interactive as pyvis but works without pyvis installed. """ try: import networkx as nx import plotly.graph_objects as go except ImportError: st.warning("Install networkx and plotly for graph visualisation.") return graph = nx.Graph() for pair in pairs: graph.add_edge(pair["source"], pair["target"], weight=pair["weight"]) pos = nx.spring_layout(graph, seed=42) edge_x, edge_y = [], [] for src, tgt in graph.edges(): x0, y0 = pos[src] x1, y1 = pos[tgt] edge_x.extend([x0, x1, None]) edge_y.extend([y0, y1, None]) node_x = [pos[n][0] for n in graph.nodes()] node_y = [pos[n][1] for n in graph.nodes()] node_labels = list(graph.nodes()) fig = go.Figure() fig.add_trace(go.Scatter( x=edge_x, y=edge_y, mode="lines", line={"width": 0.8, "color": "#aaa"}, hoverinfo="none", )) fig.add_trace(go.Scatter( x=node_x, y=node_y, mode="markers+text", text=node_labels, textposition="top center", marker={"size": 10, "color": "#3498db"}, hoverinfo="text", )) fig.update_layout( showlegend=False, height=500, xaxis={"visible": False}, yaxis={"visible": False}, margin={"t": 0, "b": 0}, ) st.plotly_chart(fig, use_container_width=True)