""" Human+ Chart Builder Plotly gauge charts dan bar charts untuk biomarker visualization. Semua chart dark-mode native, sesuai Human+ design system. """ from typing import Optional # ── Color palette (matches ui/theme.py) ────────────────────────────── _COL = { "bg": "#070710", "surface": "#0d0d1a", "elevated": "#131325", "line": "#1f1f38", "text": "#f0f0fa", "muted": "#8b8ba7", "optimal": "#10b981", "warning": "#f59e0b", "danger": "#ef4444", "violet": "#8b5cf6", "cyan": "#22d3ee", } _STATUS_COLOR = { "optimal": _COL["optimal"], "warning": _COL["warning"], "danger": _COL["danger"], "neutral": _COL["muted"], } def build_gauge_chart( name: str, value: float, unit: str, status: str, range_lo: float, range_hi: float, optimal_lo: Optional[float] = None, optimal_hi: Optional[float] = None, height: int = 220, ): """ Build a single Plotly gauge chart untuk satu biomarker. Args: name: biomarker name value: numeric value unit: unit string status: 'optimal'|'warning'|'danger'|'neutral' range_lo / range_hi: full display range for the gauge optimal_lo / optimal_hi: target optimal range (green zone) height: chart height in px Returns: plotly Figure object """ import plotly.graph_objects as go color = _STATUS_COLOR.get(status, _COL["muted"]) # Build green threshold steps steps = [] if optimal_lo is not None and optimal_hi is not None: steps = [ {"range": [range_lo, optimal_lo], "color": "rgba(239,68,68,0.12)"}, {"range": [optimal_lo, optimal_hi], "color": "rgba(16,185,129,0.15)"}, {"range": [optimal_hi, range_hi], "color": "rgba(245,158,11,0.12)"}, ] elif optimal_hi is not None: steps = [ {"range": [range_lo, optimal_hi], "color": "rgba(16,185,129,0.15)"}, {"range": [optimal_hi, range_hi], "color": "rgba(245,158,11,0.12)"}, ] elif optimal_lo is not None: steps = [ {"range": [range_lo, optimal_lo], "color": "rgba(245,158,11,0.12)"}, {"range": [optimal_lo, range_hi], "color": "rgba(16,185,129,0.15)"}, ] fig = go.Figure(go.Indicator( mode="gauge+number", value=value, number={ "suffix": f" {unit}", "font": {"size": 20, "color": color, "family": "DM Sans"}, }, title={ "text": name, "font": {"size": 13, "color": _COL["muted"], "family": "DM Sans"}, }, gauge={ "axis": { "range": [range_lo, range_hi], "tickcolor": _COL["line"], "tickfont": {"color": _COL["muted"], "size": 9, "family": "JetBrains Mono"}, "nticks": 5, }, "bar": {"color": color, "thickness": 0.22}, "bgcolor": _COL["elevated"], "borderwidth": 0, "steps": steps, "threshold": { "line": {"color": color, "width": 2}, "thickness": 0.75, "value": value, }, }, )) fig.update_layout( height=height, margin=dict(t=30, b=10, l=20, r=20), paper_bgcolor=_COL["surface"], plot_bgcolor=_COL["surface"], font={"family": "DM Sans", "color": _COL["text"]}, ) return fig def build_biomarker_bar_chart(biomarkers: list[dict], height: int = 380) -> "go.Figure": """ Build horizontal bar chart untuk semua biomarker sekaligus. Setiap bar dicolor sesuai status (merah/kuning/hijau). Args: biomarkers: list of enriched biomarker dicts dari pdf_processor height: chart height px Returns: plotly Figure """ import plotly.graph_objects as go # Filter yang punya numeric value plottable = [ b for b in biomarkers if b.get("raw_value") is not None and b.get("status") != "neutral" ] if not plottable: return None # Sort: danger first priority = {"danger": 0, "warning": 1, "optimal": 2} plottable = sorted(plottable, key=lambda b: priority.get(b["status"], 3)) names = [b["name"] for b in plottable] values = [b["raw_value"] for b in plottable] colors = [_STATUS_COLOR.get(b["status"], _COL["muted"]) for b in plottable] statuses = [b["status"].upper() for b in plottable] units = [b.get("unit", "") for b in plottable] refs = [b.get("reference", "") for b in plottable] def _fmt_ref(r, u): """Tampilkan reference dengan unit jika ada, fallback ke '-' jika kosong.""" if not r: return "—" # Jika reference sudah mengandung unit atau tanda < >, langsung pakai if any(c in r for c in ["<", ">", "–", "-"]): return f"{r} {u}".strip() return f"{r} {u}".strip() hover_texts = [ f"{n}
" f"Nilai: {v} {u}
" f"Human+ target: {_fmt_ref(r, u)}
" f"Status: {s}" for n, v, u, r, s in zip(names, values, units, refs, statuses) ] fig = go.Figure(go.Bar( x=values, y=names, orientation="h", marker=dict( color=colors, line=dict(width=0), opacity=0.85, ), hovertext=hover_texts, hoverinfo="text", hoverlabel=dict( bgcolor=_COL["elevated"], bordercolor=_COL["line"], font=dict(family="DM Sans", size=12, color=_COL["text"]), ), text=[f"{v} {u}" for v, u in zip(values, units)], textposition="outside", textfont=dict( family="JetBrains Mono", size=10, color=_COL["muted"], ), )) dynamic_height = max(height, len(plottable) * 36 + 80) fig.update_layout( height=dynamic_height, margin=dict(t=16, b=16, l=16, r=80), paper_bgcolor=_COL["bg"], plot_bgcolor=_COL["bg"], font={"family": "DM Sans", "color": _COL["text"]}, xaxis=dict( showgrid=True, gridcolor=_COL["line"], gridwidth=1, tickfont=dict(family="JetBrains Mono", size=9, color=_COL["muted"]), zeroline=False, ), yaxis=dict( tickfont=dict(family="DM Sans", size=11, color=_COL["text"]), categoryorder="array", categoryarray=list(reversed(names)), ), hoverlabel=dict(align="left"), bargap=0.35, ) return fig def build_status_donut(optimal: int, warning: int, danger: int) -> "go.Figure": """ Build a small donut chart untuk summary biomarker status. Digunakan di sidebar atau summary card. """ import plotly.graph_objects as go labels = ["Optimal", "Sub-Optimal", "Perlu Perhatian"] values = [optimal, warning, danger] colors = [_COL["optimal"], _COL["warning"], _COL["danger"]] # Filter zero values filtered = [(l, v, c) for l, v, c in zip(labels, values, colors) if v > 0] if not filtered: return None labels_f, values_f, colors_f = zip(*filtered) fig = go.Figure(go.Pie( labels=labels_f, values=values_f, hole=0.65, marker=dict( colors=colors_f, line=dict(color=_COL["bg"], width=2), ), textinfo="none", hovertemplate="%{label}
%{value} marker", hoverlabel=dict( bgcolor=_COL["elevated"], font=dict(family="DM Sans", size=12, color=_COL["text"]), ), )) total = sum(values_f) fig.update_layout( height=180, margin=dict(t=0, b=0, l=0, r=0), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", showlegend=False, annotations=[{ "text": f"{total}
Marker", "x": 0.5, "y": 0.5, "font_size": 18, "font_family": "DM Sans", "font_color": _COL["text"], "showarrow": False, }], ) return fig # ── Gauge config presets (Human+ biomarker ranges) ─────────────────── # Format: name_lower → (range_lo, range_hi, optimal_lo, optimal_hi) GAUGE_PRESETS: dict[str, tuple] = { "hs-crp": (0, 10, None, 1.0), "homocysteine": (0, 30, None, 8.0), "hba1c": (4.0, 8.0, 4.8, 5.2), "vitamin d": (0, 100, 50, 80), "testosterone": (0, 1200, 600, None), "magnesium": (1.0, 3.5, 2.2, None), "vitamin b12": (0, 1500, 500, None), "ferritin": (0, 300, 50, 150), "apob": (0, 150, None, 80), "triglycerides": (0, 300, None, 90), "hdl": (0, 120, 60, None), "tsh": (0, 6, 0.5, 2.0), "zinc": (0, 200, 90, 120), "fasting insulin": (0, 30, None, 7.0), "fasting glucose": (50, 150, 75, 86), } def get_gauge_preset(name: str) -> Optional[tuple]: """ Return (range_lo, range_hi, optimal_lo, optimal_hi) untuk biomarker. Returns None jika tidak ada preset. """ name_lower = name.lower() for key, preset in GAUGE_PRESETS.items(): if key in name_lower or name_lower in key: return preset return None