HumanPlusX / src /core /chart_builder.py
FajarHidaa's picture
Upload 3 files
c99d3a1 verified
Raw History Blame Contribute Delete
9.76 kB
"""
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"<b>{n}</b><br>"
f"Nilai: <b>{v} {u}</b><br>"
f"Human+ target: {_fmt_ref(r, u)}<br>"
f"Status: <b>{s}</b>"
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="<b>%{label}</b><br>%{value} marker<extra></extra>",
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"<b>{total}</b><br><span style='font-size:10px'>Marker</span>",
"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