Spaces:
Sleeping
feat(landscape): merge trials-by-phase wheel into Therapy Landscape tab (#21)
Browse filesFold the experimental "Trials by Phase" tab and the sunburst-based Therapy
Landscape into a single "Therapy Landscape" tab whose hero is the pipeline
wheel (mechanism sectors × phase rings):
- Filters on one row (Recruitment status, Trial phase, Mechanism); every
control redraws the wheel. Mechanism narrows it to a single sector.
- Trial phase is a multi-select checkbox that drives the geometry: one ring
per checked phase (inner -> outer). Phase 3 and Expanded Access (EAP) share
the outer "Phase 3 / EAP" ring.
- Grey dots mark compounds with no recruiting/active trial; "Others / Multiple"
recolored yellow so grey stays reserved for inactive.
- Mechanism -> Compound drill-down shows pipeline stage, evidence confidence,
and a trials table (status badges + NCT links).
- Remove the dead sunburst path.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +50 -46
- landscape.py +395 -108
|
@@ -101,7 +101,7 @@ _kg_nodes = _graph.number_of_nodes() if _graph else 0
|
|
| 101 |
import landscape as landscape_mod
|
| 102 |
|
| 103 |
_landscape = landscape_mod.load_landscape()
|
| 104 |
-
|
| 105 |
|
| 106 |
# ── Example questions ─────────────────────────────────────────────────────────
|
| 107 |
|
|
@@ -173,27 +173,22 @@ Always verify claims with primary sources before applying to patient care.
|
|
| 173 |
</div>"""
|
| 174 |
|
| 175 |
|
| 176 |
-
def
|
| 177 |
-
"""
|
| 178 |
-
labels = landscape_mod.
|
| 179 |
first = labels[0] if labels else None
|
| 180 |
return (
|
|
|
|
| 181 |
gr.update(choices=labels, value=first),
|
| 182 |
-
landscape_mod.
|
| 183 |
-
landscape_mod.
|
| 184 |
)
|
| 185 |
|
| 186 |
|
| 187 |
-
def
|
| 188 |
-
"""Phase filter: rebuild the sunburst and repopulate the current class's therapies."""
|
| 189 |
-
therapy_update, detail, trials = _landscape_select(class_name, phases)
|
| 190 |
-
return (landscape_mod.build_sunburst(_landscape, phases), therapy_update, detail, trials)
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
def _therapy_select(class_name: str, therapy_label: str):
|
| 194 |
return (
|
| 195 |
-
landscape_mod.
|
| 196 |
-
landscape_mod.
|
| 197 |
)
|
| 198 |
|
| 199 |
|
|
@@ -250,56 +245,65 @@ with gr.Blocks(title="Candle-Fire — ALS Research Intelligence") as demo:
|
|
| 250 |
with gr.Tab("🧭 Therapy Landscape"):
|
| 251 |
with gr.Column(elem_classes="container"):
|
| 252 |
gr.Markdown(
|
| 253 |
-
"### 🧭
|
| 254 |
-
"
|
| 255 |
-
"(
|
|
|
|
|
|
|
|
|
|
| 256 |
)
|
| 257 |
if _landscape is None:
|
| 258 |
gr.Markdown(
|
| 259 |
"*Landscape not built yet — run `uv run python scripts/build_landscape.py`.*"
|
| 260 |
)
|
| 261 |
else:
|
| 262 |
-
|
| 263 |
-
_init_labels = landscape_mod.
|
| 264 |
_init_label = _init_labels[0] if _init_labels else None
|
| 265 |
|
| 266 |
-
sunburst = gr.Plot(landscape_mod.build_sunburst(_landscape), show_label=False)
|
| 267 |
-
|
| 268 |
-
phase_cb = gr.CheckboxGroup(
|
| 269 |
-
choices=landscape_mod.PHASE_OPTIONS, value=landscape_mod.PHASE_OPTIONS,
|
| 270 |
-
label="Filter by trial phase",
|
| 271 |
-
info="Show therapies with a trial in the selected phase(s). All selected = the whole picture.",
|
| 272 |
-
)
|
| 273 |
-
|
| 274 |
with gr.Row():
|
| 275 |
-
|
| 276 |
-
choices=
|
| 277 |
-
label="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
)
|
| 279 |
-
|
| 280 |
-
choices=
|
| 281 |
-
label="
|
|
|
|
| 282 |
)
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
detail_md = gr.Markdown(
|
| 285 |
-
landscape_mod.
|
| 286 |
)
|
| 287 |
trials_html = gr.HTML(
|
| 288 |
-
landscape_mod.
|
| 289 |
)
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
| 297 |
outputs=[detail_md, trials_html],
|
| 298 |
)
|
| 299 |
-
phase_cb.change(
|
| 300 |
-
_phase_change, inputs=[phase_cb, class_dd],
|
| 301 |
-
outputs=[sunburst, therapy_dd, detail_md, trials_html],
|
| 302 |
-
)
|
| 303 |
|
| 304 |
gr.HTML(_DISCLAIMER_MD)
|
| 305 |
|
|
|
|
| 101 |
import landscape as landscape_mod
|
| 102 |
|
| 103 |
_landscape = landscape_mod.load_landscape()
|
| 104 |
+
_mech_options = landscape_mod.mechanism_filter_options(_landscape)
|
| 105 |
|
| 106 |
# ── Example questions ─────────────────────────────────────────────────────────
|
| 107 |
|
|
|
|
| 173 |
</div>"""
|
| 174 |
|
| 175 |
|
| 176 |
+
def _refresh(status: str, phases: list, mech: str):
|
| 177 |
+
"""Any filter changed: redraw the wheel (rings = selected phases, narrowed by mechanism)."""
|
| 178 |
+
labels = landscape_mod.compound_labels(_landscape, mech, status, phases)
|
| 179 |
first = labels[0] if labels else None
|
| 180 |
return (
|
| 181 |
+
landscape_mod.build_pipeline_svg(_landscape, status, phases, mech),
|
| 182 |
gr.update(choices=labels, value=first),
|
| 183 |
+
landscape_mod.compound_detail_md(_landscape, first or ""),
|
| 184 |
+
landscape_mod.compound_trials_html(_landscape, first or ""),
|
| 185 |
)
|
| 186 |
|
| 187 |
|
| 188 |
+
def _compound_change(label: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
return (
|
| 190 |
+
landscape_mod.compound_detail_md(_landscape, label),
|
| 191 |
+
landscape_mod.compound_trials_html(_landscape, label),
|
| 192 |
)
|
| 193 |
|
| 194 |
|
|
|
|
| 245 |
with gr.Tab("🧭 Therapy Landscape"):
|
| 246 |
with gr.Column(elem_classes="container"):
|
| 247 |
gr.Markdown(
|
| 248 |
+
"### 🧭 ALS Therapeutic Pipeline by Clinical Trial Phase\n"
|
| 249 |
+
"Mechanism groups are **sectors**; the three trial phases are **concentric "
|
| 250 |
+
"rings** (inner = Phase 1, outer = Phase 3). Each **dot is a compound** — "
|
| 251 |
+
"hover to see its name; **grey dots** have no recruiting/active trial. Use the "
|
| 252 |
+
"filters to narrow the wheel, and pick a **mechanism** to see its compounds' "
|
| 253 |
+
"pipeline stage, evidence confidence, and trials."
|
| 254 |
)
|
| 255 |
if _landscape is None:
|
| 256 |
gr.Markdown(
|
| 257 |
"*Landscape not built yet — run `uv run python scripts/build_landscape.py`.*"
|
| 258 |
)
|
| 259 |
else:
|
| 260 |
+
_init_mech = landscape_mod.ALL_MECHANISMS
|
| 261 |
+
_init_labels = landscape_mod.compound_labels(_landscape, _init_mech)
|
| 262 |
_init_label = _init_labels[0] if _init_labels else None
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
with gr.Row():
|
| 265 |
+
status_dd = gr.Dropdown(
|
| 266 |
+
choices=landscape_mod.STATUS_FILTER_OPTIONS, value="All trials",
|
| 267 |
+
label="Recruitment status", scale=1,
|
| 268 |
+
info="Filter the wheel to compounds with a recruiting trial (or without one).",
|
| 269 |
+
)
|
| 270 |
+
phase_cb = gr.CheckboxGroup(
|
| 271 |
+
choices=landscape_mod.PHASE_RINGS, value=landscape_mod.PHASE_RINGS,
|
| 272 |
+
label="Trial phase", scale=1,
|
| 273 |
+
info="Each checked phase is drawn as a ring (inner → outer).",
|
| 274 |
)
|
| 275 |
+
mech_dd = gr.Dropdown(
|
| 276 |
+
choices=_mech_options, value=_init_mech,
|
| 277 |
+
label="Mechanism", scale=1,
|
| 278 |
+
info="Narrow the wheel to a single mechanism.",
|
| 279 |
)
|
| 280 |
|
| 281 |
+
wheel_html = gr.HTML(
|
| 282 |
+
landscape_mod.build_pipeline_svg(
|
| 283 |
+
_landscape, "All trials", landscape_mod.PHASE_RINGS, _init_mech)
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
compound_dd = gr.Dropdown(
|
| 287 |
+
choices=_init_labels, value=_init_label,
|
| 288 |
+
label="Compound — pipeline stage, evidence confidence & trials below",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
detail_md = gr.Markdown(
|
| 292 |
+
landscape_mod.compound_detail_md(_landscape, _init_label or "")
|
| 293 |
)
|
| 294 |
trials_html = gr.HTML(
|
| 295 |
+
landscape_mod.compound_trials_html(_landscape, _init_label or "")
|
| 296 |
)
|
| 297 |
|
| 298 |
+
for _f in (status_dd, phase_cb, mech_dd):
|
| 299 |
+
_f.change(
|
| 300 |
+
_refresh, inputs=[status_dd, phase_cb, mech_dd],
|
| 301 |
+
outputs=[wheel_html, compound_dd, detail_md, trials_html],
|
| 302 |
+
)
|
| 303 |
+
compound_dd.change(
|
| 304 |
+
_compound_change, inputs=[compound_dd],
|
| 305 |
outputs=[detail_md, trials_html],
|
| 306 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
gr.HTML(_DISCLAIMER_MD)
|
| 309 |
|
|
@@ -1,9 +1,12 @@
|
|
| 1 |
-
"""Rendering helpers for the Experimental ALS Therapy Landscape tab
|
| 2 |
-
|
| 3 |
-
Loads data/landscape/landscape.json and
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
@@ -14,44 +17,47 @@ from config import LANDSCAPE_PATH
|
|
| 14 |
|
| 15 |
_INSUFFICIENT = "Insufficient evidence"
|
| 16 |
|
| 17 |
-
_CLASS_COLORS = {
|
| 18 |
-
"TDP-43 proteinopathy": "#6C5CE7", "SOD1": "#0984E3", "C9orf72": "#00B894", "FUS": "#00CEC9",
|
| 19 |
-
"Neuroinflammation": "#E17055", "Oxidative stress": "#D63031", "Mitochondrial dysfunction": "#E84393",
|
| 20 |
-
"Glutamate excitotoxicity": "#FDCB6E", "Proteostasis / autophagy": "#A29BFE",
|
| 21 |
-
"RNA metabolism": "#74B9FF", "Neurotrophic / regenerative": "#55EFC4",
|
| 22 |
-
"Symptomatic / Other": "#B2BEC3", _INSUFFICIENT: "#DFE6E9",
|
| 23 |
-
}
|
| 24 |
-
_DEFAULT_COLOR = "#B2BEC3"
|
| 25 |
_STATUS_BADGE = {
|
| 26 |
"recruiting": ("#00B894", "Recruiting"), "active": ("#0984E3", "Active"),
|
| 27 |
"completed": ("#636E72", "Completed"), "terminated": ("#D63031", "Terminated"),
|
| 28 |
"other": ("#B2BEC3", "Unknown"),
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def _phase_buckets(phase: str) -> set:
|
| 36 |
-
"""Map a ClinicalTrials.gov phase string
|
| 37 |
p = (phase or "").upper()
|
| 38 |
b = set()
|
| 39 |
if "PHASE1" in p: # also catches EARLY_PHASE1
|
| 40 |
b.add("Phase 1")
|
| 41 |
if "PHASE2" in p:
|
| 42 |
b.add("Phase 2")
|
| 43 |
-
if "PHASE3" in p:
|
| 44 |
-
b.add("Phase 3")
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def load_landscape() -> dict | None:
|
|
@@ -63,99 +69,380 @@ def load_landscape() -> dict | None:
|
|
| 63 |
return None
|
| 64 |
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if not landscape:
|
| 68 |
return []
|
| 69 |
-
|
| 70 |
-
if
|
| 71 |
-
names.append(_INSUFFICIENT)
|
| 72 |
-
return names
|
| 73 |
|
| 74 |
|
| 75 |
-
def
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
for c in landscape["classifications"]:
|
| 79 |
-
if c["name"] == class_name:
|
| 80 |
-
return c["therapies"]
|
| 81 |
-
return []
|
| 82 |
|
| 83 |
|
| 84 |
-
def
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
for t in
|
| 89 |
-
if
|
|
|
|
|
|
|
| 90 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
c = t["trial_counts"]
|
| 92 |
rec = f", {c['recruiting']} recruiting" if c["recruiting"] else ""
|
| 93 |
-
|
| 94 |
-
tag = f" [{role}]" if (role and class_name != _INSUFFICIENT) else ""
|
| 95 |
-
labels.append(f"{t['name']}{tag} — {c['total']} trial{'s' if c['total'] != 1 else ''}{rec}")
|
| 96 |
return labels
|
| 97 |
|
| 98 |
|
| 99 |
-
def
|
| 100 |
-
name = label.split(" — ")[0]
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
if t["name"] == name:
|
| 103 |
return t
|
| 104 |
return None
|
| 105 |
|
| 106 |
|
| 107 |
-
def
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
import plotly.graph_objects as go
|
| 112 |
-
|
| 113 |
-
sel = set(phases) if phases else None
|
| 114 |
-
|
| 115 |
-
def _keep(t):
|
| 116 |
-
return _therapy_matches_phases(t, sel)
|
| 117 |
-
|
| 118 |
-
ids, labels, parents, values, colors, hover = [], [], [], [], [], []
|
| 119 |
-
|
| 120 |
-
def add_class(name, therapies, color, desc=""):
|
| 121 |
-
if not therapies:
|
| 122 |
-
return
|
| 123 |
-
cid = f"cls::{name}"
|
| 124 |
-
total = sum(t["trial_counts"]["total"] for t in therapies)
|
| 125 |
-
ids.append(cid); labels.append(name); parents.append(""); values.append(total); colors.append(color)
|
| 126 |
-
hover.append(f"<b>{name}</b><br>{len(therapies)} therapies · {total} trials<br>{desc}")
|
| 127 |
-
for t in therapies:
|
| 128 |
-
cnt = t["trial_counts"]
|
| 129 |
-
ids.append(f"th::{name}::{t['name']}"); labels.append(t["name"]); parents.append(cid)
|
| 130 |
-
values.append(cnt["total"]); colors.append(color)
|
| 131 |
-
hover.append(f"<b>{t['name']}</b> ({t['modality']})<br>Target: {t['target']}<br>"
|
| 132 |
-
f"{cnt['total']} trials · {cnt['recruiting']} recruiting · {cnt['completed']} completed")
|
| 133 |
-
|
| 134 |
-
if landscape:
|
| 135 |
-
for c in landscape["classifications"]:
|
| 136 |
-
primaries = [t for t in c["therapies"] if t.get("role_here") == "primary" and _keep(t)]
|
| 137 |
-
add_class(c["name"], primaries, _CLASS_COLORS.get(c["name"], _DEFAULT_COLOR), c.get("description", ""))
|
| 138 |
-
un = [t for t in landscape.get("unclassified", []) if _keep(t)]
|
| 139 |
-
if un:
|
| 140 |
-
add_class(_INSUFFICIENT, un, _CLASS_COLORS[_INSUFFICIENT],
|
| 141 |
-
"Mechanism not established from available evidence")
|
| 142 |
-
|
| 143 |
-
fig = go.Figure(go.Sunburst(
|
| 144 |
-
ids=ids, labels=labels, parents=parents, values=values, branchvalues="total",
|
| 145 |
-
marker=dict(colors=colors), hovertext=hover, hoverinfo="text",
|
| 146 |
-
insidetextorientation="radial", maxdepth=2,
|
| 147 |
-
))
|
| 148 |
-
fig.update_layout(margin=dict(t=10, l=0, r=0, b=0), height=520, paper_bgcolor="rgba(0,0,0,0)")
|
| 149 |
-
return fig
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def therapy_detail_md(landscape: dict | None, class_name: str, therapy_label: str) -> str:
|
| 153 |
-
if not landscape or not class_name:
|
| 154 |
-
return "*Select a mechanism class and therapy to see its mechanisms and trials.*"
|
| 155 |
-
t = _therapy_by_label(landscape, class_name, therapy_label)
|
| 156 |
if not t:
|
| 157 |
-
return
|
| 158 |
-
|
|
|
|
|
|
|
| 159 |
if t.get("aliases"):
|
| 160 |
header += f"\n<sub>Also: {', '.join(t['aliases'][:6])}</sub>"
|
| 161 |
mechs = t.get("mechanisms") or []
|
|
@@ -169,17 +456,17 @@ def therapy_detail_md(landscape: dict | None, class_name: str, therapy_label: st
|
|
| 169 |
return "\n".join(lines)
|
| 170 |
|
| 171 |
|
| 172 |
-
def
|
| 173 |
-
if not landscape
|
| 174 |
return ""
|
| 175 |
-
t =
|
| 176 |
if not t:
|
| 177 |
return ""
|
| 178 |
rows = []
|
| 179 |
for tr in t["trials"]:
|
| 180 |
-
color,
|
| 181 |
badge = (f'<span style="background:{color};color:#fff;border-radius:10px;'
|
| 182 |
-
f'padding:1px 8px;font-size:0.72rem;white-space:nowrap;">{
|
| 183 |
phase = html.escape((tr.get("phase") or "—").replace("PHASE", "Ph"))
|
| 184 |
title = html.escape(tr.get("title", "")[:110])
|
| 185 |
nct = html.escape(tr.get("nct_id", ""))
|
|
|
|
| 1 |
+
"""Rendering helpers for the Experimental ALS Therapy Landscape tab.
|
| 2 |
+
|
| 3 |
+
Loads data/landscape/landscape.json and renders a single "ALS Therapeutic Pipeline by
|
| 4 |
+
Clinical Trial Phase" wheel: mechanism groups are angular SECTORS, the three trial phases
|
| 5 |
+
are concentric RINGS (inner = Phase 1, outer = Phase 3), and each compound is a dot inside
|
| 6 |
+
its sector×ring cell (hover shows its name). Dots go grey when the compound has no
|
| 7 |
+
recruiting/active trial in the current view. Two dropdowns filter the wheel (recruitment
|
| 8 |
+
status + trial phase); a Mechanism dropdown surfaces a group's compounds, each with its
|
| 9 |
+
pipeline stage, mechanism confidence, and a trials table.
|
| 10 |
"""
|
| 11 |
from __future__ import annotations
|
| 12 |
|
|
|
|
| 17 |
|
| 18 |
_INSUFFICIENT = "Insufficient evidence"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
_STATUS_BADGE = {
|
| 21 |
"recruiting": ("#00B894", "Recruiting"), "active": ("#0984E3", "Active"),
|
| 22 |
"completed": ("#636E72", "Completed"), "terminated": ("#D63031", "Terminated"),
|
| 23 |
"other": ("#B2BEC3", "Unknown"),
|
| 24 |
}
|
| 25 |
|
| 26 |
+
# Recruitment-status filter for the pipeline wheel.
|
| 27 |
+
STATUS_FILTER_OPTIONS = ["All trials", "Recruiting", "Not recruiting"]
|
| 28 |
+
# Sentinel for the mechanism filter meaning "don't narrow the wheel to one group".
|
| 29 |
+
ALL_MECHANISMS = "All mechanisms"
|
| 30 |
+
|
| 31 |
+
# Pipeline-wheel rings, inner → outer. Phase 3 and Expanded Access (EAP) share the outer
|
| 32 |
+
# ring. These double as the "Trial phase" checkbox options. (Phase 4 / NA are never placed.)
|
| 33 |
+
PHASE_RINGS = ["Phase 1", "Phase 2", "Phase 3 / EAP"]
|
| 34 |
|
| 35 |
+
# Grey used for compounds with no recruiting/active trial in the current view.
|
| 36 |
+
_INACTIVE_COLOR = "#B8BFC7"
|
| 37 |
|
| 38 |
|
| 39 |
def _phase_buckets(phase: str) -> set:
|
| 40 |
+
"""Map a ClinicalTrials.gov phase string to wheel rings (Phase 3 + Expanded Access merge)."""
|
| 41 |
p = (phase or "").upper()
|
| 42 |
b = set()
|
| 43 |
if "PHASE1" in p: # also catches EARLY_PHASE1
|
| 44 |
b.add("Phase 1")
|
| 45 |
if "PHASE2" in p:
|
| 46 |
b.add("Phase 2")
|
| 47 |
+
if "PHASE3" in p or "EXPANDED" in p or "ACCESS" in p:
|
| 48 |
+
b.add("Phase 3 / EAP")
|
| 49 |
+
return b or {"Not applicable"} # PHASE4 / NA / blank are not placed on the wheel
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _top_ring(trials: list[dict]) -> str | None:
|
| 53 |
+
"""Most advanced wheel ring (PHASE_RINGS order) present across a compound's trials."""
|
| 54 |
+
present: set = set()
|
| 55 |
+
for tr in trials:
|
| 56 |
+
present |= _phase_buckets(tr.get("phase", ""))
|
| 57 |
+
for ring in reversed(PHASE_RINGS):
|
| 58 |
+
if ring in present:
|
| 59 |
+
return ring
|
| 60 |
+
return None
|
| 61 |
|
| 62 |
|
| 63 |
def load_landscape() -> dict | None:
|
|
|
|
| 69 |
return None
|
| 70 |
|
| 71 |
|
| 72 |
+
# ── "ALS Therapeutic Pipeline by Clinical Trial Phase" wheel (inline SVG) ──────
|
| 73 |
+
# Magazine-style wheel: mechanism groups are equal angular SECTORS, the three
|
| 74 |
+
# trial phases are concentric RINGS (inner=Phase 1, outer=Phase 3), and each
|
| 75 |
+
# compound is a dot inside its sector×ring cell (hover shows its name). Driven by
|
| 76 |
+
# real landscape data; the app's 12 mechanism classes are mapped to 8 display groups.
|
| 77 |
+
|
| 78 |
+
def _all_compounds(landscape: dict) -> list[dict]:
|
| 79 |
+
"""Distinct compounds (therapies) across all classes + unclassified, deduped by name.
|
| 80 |
+
|
| 81 |
+
A therapy is multi-label (appears under each class it acts through), but every copy
|
| 82 |
+
carries the same `mechanisms` and `trials`, so keeping the first is sufficient.
|
| 83 |
+
"""
|
| 84 |
+
seen: dict[str, dict] = {}
|
| 85 |
+
for c in landscape.get("classifications", []):
|
| 86 |
+
for t in c.get("therapies", []):
|
| 87 |
+
seen.setdefault(t["name"], t)
|
| 88 |
+
for t in landscape.get("unclassified", []):
|
| 89 |
+
seen.setdefault(t["name"], t)
|
| 90 |
+
return list(seen.values())
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _primary_class(therapy: dict) -> str:
|
| 94 |
+
"""The compound's dominant mechanism class (primary role, else highest confidence)."""
|
| 95 |
+
mechs = therapy.get("mechanisms") or []
|
| 96 |
+
if not mechs:
|
| 97 |
+
return _INSUFFICIENT
|
| 98 |
+
pool = [m for m in mechs if m.get("role") == "primary"] or mechs
|
| 99 |
+
best = max(pool, key=lambda m: m.get("confidence", 0) or 0)
|
| 100 |
+
return best.get("class", _INSUFFICIENT)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _filter_trials(trials: list[dict], status_filter: str, phases: list | None = None) -> list[dict]:
|
| 104 |
+
"""Filter a compound's trials by recruitment status and the selected wheel rings.
|
| 105 |
+
|
| 106 |
+
`phases` is a list of PHASE_RINGS labels (None = all rings). Trials that map only to a
|
| 107 |
+
non-ring bucket (Phase 4 / NA) are always dropped — the wheel is Phase 1–3/EAP only.
|
| 108 |
+
"""
|
| 109 |
+
out = trials
|
| 110 |
+
if status_filter == "Recruiting":
|
| 111 |
+
out = [tr for tr in out if tr.get("status_group") == "recruiting"]
|
| 112 |
+
elif status_filter == "Not recruiting":
|
| 113 |
+
out = [tr for tr in out if tr.get("status_group") != "recruiting"]
|
| 114 |
+
selected = set(phases) if phases else set(PHASE_RINGS)
|
| 115 |
+
out = [tr for tr in out if _phase_buckets(tr.get("phase", "")) & selected]
|
| 116 |
+
return list(out)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _compound_active(trials: list[dict]) -> bool:
|
| 120 |
+
"""True if any trial is recruiting or active-not-recruiting (drives dot coloring)."""
|
| 121 |
+
return any(tr.get("status_group") in ("recruiting", "active") for tr in trials)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Eight display groups (clockwise from top) and their colors, matching the
|
| 125 |
+
# reference infographic. The app's finer 12-class taxonomy maps down to these.
|
| 126 |
+
GROUP_ORDER = [
|
| 127 |
+
"Neuroinflammation / Immunity",
|
| 128 |
+
"RNA / Gene Targeting",
|
| 129 |
+
"Neuroprotection / Cell Survival",
|
| 130 |
+
"Protein Homeostasis / TDP-43 Pathology",
|
| 131 |
+
"Metabolic / Mitochondrial Function",
|
| 132 |
+
"Neuromuscular Function",
|
| 133 |
+
"Stem Cell / Regenerative",
|
| 134 |
+
"Others / Multiple",
|
| 135 |
+
]
|
| 136 |
+
GROUP_COLORS = {
|
| 137 |
+
"Neuroinflammation / Immunity": "#4C6FB1",
|
| 138 |
+
"RNA / Gene Targeting": "#E4586E",
|
| 139 |
+
"Neuroprotection / Cell Survival": "#26B6A6",
|
| 140 |
+
"Protein Homeostasis / TDP-43 Pathology": "#EFAA3A",
|
| 141 |
+
"Metabolic / Mitochondrial Function": "#9B7EC8",
|
| 142 |
+
"Neuromuscular Function": "#EE7B4E",
|
| 143 |
+
"Stem Cell / Regenerative": "#5BB56A",
|
| 144 |
+
"Others / Multiple": "#F4CE14", # yellow (grey is reserved for inactive compounds)
|
| 145 |
+
}
|
| 146 |
+
_CLASS_TO_GROUP = {
|
| 147 |
+
"TDP-43 proteinopathy": "Protein Homeostasis / TDP-43 Pathology",
|
| 148 |
+
"Proteostasis / autophagy": "Protein Homeostasis / TDP-43 Pathology",
|
| 149 |
+
"SOD1": "RNA / Gene Targeting",
|
| 150 |
+
"C9orf72": "RNA / Gene Targeting",
|
| 151 |
+
"FUS": "RNA / Gene Targeting",
|
| 152 |
+
"RNA metabolism": "RNA / Gene Targeting",
|
| 153 |
+
"Neuroinflammation": "Neuroinflammation / Immunity",
|
| 154 |
+
"Oxidative stress": "Neuroprotection / Cell Survival",
|
| 155 |
+
"Glutamate excitotoxicity": "Neuroprotection / Cell Survival",
|
| 156 |
+
"Mitochondrial dysfunction": "Metabolic / Mitochondrial Function",
|
| 157 |
+
"Neurotrophic / regenerative": "Stem Cell / Regenerative",
|
| 158 |
+
"Symptomatic / Other": "Others / Multiple",
|
| 159 |
+
_INSUFFICIENT: "Others / Multiple",
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
_DOT_CAP = 12 # max dots drawn per sector×ring cell (real counts live in hover/summary)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _group_of(therapy: dict) -> str:
|
| 166 |
+
return _CLASS_TO_GROUP.get(_primary_class(therapy), "Others / Multiple")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _pipeline_grid(landscape: dict | None, status_filter: str, phases: list,
|
| 170 |
+
mech_filter: str = ALL_MECHANISMS) -> dict:
|
| 171 |
+
"""{group: {ring: [(compound name, is_active)]}} over the filtered, ring-placed compounds.
|
| 172 |
+
|
| 173 |
+
`phases` is the list of selected PHASE_RINGS; `mech_filter` other than ALL_MECHANISMS
|
| 174 |
+
narrows the wheel to a single mechanism group. Each compound lands in the most advanced
|
| 175 |
+
selected ring it has a trial in.
|
| 176 |
+
"""
|
| 177 |
+
grid = {g: {r: [] for r in PHASE_RINGS} for g in GROUP_ORDER}
|
| 178 |
+
if landscape:
|
| 179 |
+
for t in _all_compounds(landscape):
|
| 180 |
+
group = _group_of(t)
|
| 181 |
+
if mech_filter != ALL_MECHANISMS and group != mech_filter:
|
| 182 |
+
continue
|
| 183 |
+
trials = _filter_trials(t.get("trials", []), status_filter, phases)
|
| 184 |
+
if not trials:
|
| 185 |
+
continue
|
| 186 |
+
ring = _top_ring(trials)
|
| 187 |
+
if not ring:
|
| 188 |
+
continue
|
| 189 |
+
grid[group][ring].append((t["name"], _compound_active(trials)))
|
| 190 |
+
return grid
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _polar_xy(cx: float, cy: float, r: float, ang_deg: float) -> tuple:
|
| 194 |
+
"""Angle 0 = top (12 o'clock), increasing clockwise, in screen coords."""
|
| 195 |
+
import math
|
| 196 |
+
t = math.radians(ang_deg - 90.0)
|
| 197 |
+
return cx + r * math.cos(t), cy + r * math.sin(t)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _annular_sector_path(cx, cy, r_in, r_out, a0, a1) -> str:
|
| 201 |
+
large = 1 if (a1 - a0) % 360 > 180 else 0
|
| 202 |
+
x0o, y0o = _polar_xy(cx, cy, r_out, a0)
|
| 203 |
+
x1o, y1o = _polar_xy(cx, cy, r_out, a1)
|
| 204 |
+
x1i, y1i = _polar_xy(cx, cy, r_in, a1)
|
| 205 |
+
x0i, y0i = _polar_xy(cx, cy, r_in, a0)
|
| 206 |
+
return (f"M {x0o:.1f} {y0o:.1f} A {r_out:.1f} {r_out:.1f} 0 {large} 1 {x1o:.1f} {y1o:.1f} "
|
| 207 |
+
f"L {x1i:.1f} {y1i:.1f} A {r_in:.1f} {r_in:.1f} 0 {large} 0 {x0i:.1f} {y0i:.1f} Z")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _cell_dots(cx, cy, r_in, r_out, a0, a1, cells, col) -> str:
|
| 211 |
+
"""Lay up to _DOT_CAP compound dots on a jittered grid inside one annular cell.
|
| 212 |
+
|
| 213 |
+
`cells` is a list of (name, is_active); active dots take the group `col`, inactive grey.
|
| 214 |
+
"""
|
| 215 |
+
import math
|
| 216 |
+
n = min(len(cells), _DOT_CAP)
|
| 217 |
+
if n == 0:
|
| 218 |
+
return ""
|
| 219 |
+
cols = 4 if n > 6 else max(1, min(n, 3))
|
| 220 |
+
rows = max(1, math.ceil(n / cols))
|
| 221 |
+
rr0, rr1 = r_in + 13, r_out - 13
|
| 222 |
+
aa0, aa1 = a0 + 3.0, a1 - 3.0
|
| 223 |
+
out = []
|
| 224 |
+
for k, (name, active) in enumerate(cells[:n]):
|
| 225 |
+
row, col_i = divmod(k, cols)
|
| 226 |
+
in_row = min(cols, n - row * cols)
|
| 227 |
+
fr = (row + 0.5) / rows
|
| 228 |
+
fa = (col_i + 0.5) / in_row
|
| 229 |
+
jr = ((k * 37) % 7 - 3) * 1.1
|
| 230 |
+
ja = ((k * 53) % 5 - 2) * 0.6
|
| 231 |
+
r = rr0 + fr * (rr1 - rr0) + jr
|
| 232 |
+
a = aa0 + fa * (aa1 - aa0) + ja
|
| 233 |
+
x, y = _polar_xy(cx, cy, r, a)
|
| 234 |
+
fill = col if active else _INACTIVE_COLOR
|
| 235 |
+
out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="5" fill="{fill}" '
|
| 236 |
+
f'stroke="#fff" stroke-width="1.3"><title>{html.escape(name)}</title></circle>')
|
| 237 |
+
return "".join(out)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def build_pipeline_svg(landscape: dict | None, status_filter: str = "All trials",
|
| 241 |
+
phases: list | None = None,
|
| 242 |
+
mech_filter: str = ALL_MECHANISMS) -> str:
|
| 243 |
+
"""Inline-SVG 'ALS Therapeutic Pipeline by Phase' infographic (see section header).
|
| 244 |
+
|
| 245 |
+
`phases` (list of PHASE_RINGS labels; None = all) drives BOTH the filter and the geometry:
|
| 246 |
+
one concentric ring is drawn per selected phase, inner → outer in PHASE_RINGS order.
|
| 247 |
+
"""
|
| 248 |
+
rings = [r for r in PHASE_RINGS if r in set(phases)] if phases else list(PHASE_RINGS)
|
| 249 |
+
grid = _pipeline_grid(landscape, status_filter, rings, mech_filter)
|
| 250 |
+
groups = [g for g in GROUP_ORDER if any(grid[g][r] for r in rings)]
|
| 251 |
+
ph_tot = {r: sum(len(grid[g][r]) for g in groups) for r in rings}
|
| 252 |
+
total = sum(ph_tot.values())
|
| 253 |
+
|
| 254 |
+
if not groups or total == 0 or not rings:
|
| 255 |
+
sel = ", ".join(rings) if rings else "no phases"
|
| 256 |
+
return ('<div style="padding:40px;text-align:center;color:#888;font-size:15px;">'
|
| 257 |
+
f'No trials match “{html.escape(status_filter)} · {html.escape(sel)} · '
|
| 258 |
+
f'{html.escape(mech_filter)}”.</div>')
|
| 259 |
+
|
| 260 |
+
W = 720
|
| 261 |
+
cx = cy = W / 2
|
| 262 |
+
r_hub = 78
|
| 263 |
+
r_out = 338
|
| 264 |
+
nr = len(rings)
|
| 265 |
+
t = (r_out - r_hub) / nr # ring thickness recomputed for the number of selected phases
|
| 266 |
+
n = len(groups)
|
| 267 |
+
seg = 360.0 / n
|
| 268 |
+
gap_a = 1.4
|
| 269 |
+
|
| 270 |
+
mx, my_t, my_b = 116, 46, 70 # margins so perimeter labels aren't clipped
|
| 271 |
+
svg = [f'<svg viewBox="{-mx} {-my_t} {W + 2 * mx} {W + my_t + my_b}" width="100%" '
|
| 272 |
+
f'style="max-width:840px;height:auto;" '
|
| 273 |
+
'font-family="-apple-system,Segoe UI,Roboto,sans-serif">']
|
| 274 |
+
|
| 275 |
+
labels = [] # perimeter category + exemplar labels, drawn after wedges
|
| 276 |
+
for gi, g in enumerate(groups):
|
| 277 |
+
col = GROUP_COLORS[g]
|
| 278 |
+
center = gi * seg # group 0 centered at top
|
| 279 |
+
a0, a1 = center - seg / 2 + gap_a, center + seg / 2 - gap_a
|
| 280 |
+
for i, ring in enumerate(rings):
|
| 281 |
+
ri = r_hub + i * t + 2.5
|
| 282 |
+
ro = r_hub + (i + 1) * t - 2.5
|
| 283 |
+
cells = sorted(grid[g][ring], key=lambda c: c[0])
|
| 284 |
+
cnt = len(cells)
|
| 285 |
+
path = _annular_sector_path(cx, cy, ri, ro, a0, a1)
|
| 286 |
+
fillop = 0.13 + 0.05 * i
|
| 287 |
+
svg.append(f'<path d="{path}" fill="{col}" fill-opacity="{fillop:.2f}" '
|
| 288 |
+
f'stroke="#fff" stroke-width="2"><title>{html.escape(g)} — {html.escape(ring)}: '
|
| 289 |
+
f'{cnt} compound{"s" if cnt != 1 else ""}</title></path>')
|
| 290 |
+
svg.append(_cell_dots(cx, cy, ri, ro, a0, a1, cells, col))
|
| 291 |
+
|
| 292 |
+
# perimeter label: category name (wrapped at " / ") + count + top exemplar
|
| 293 |
+
exemplar = next((sorted(grid[g][r], key=lambda c: c[0])[0][0]
|
| 294 |
+
for r in reversed(rings) if grid[g][r]), "")
|
| 295 |
+
lx, ly = _polar_xy(cx, cy, r_out + 22, center)
|
| 296 |
+
if 15 < center < 165:
|
| 297 |
+
anchor, x = "start", lx + 4
|
| 298 |
+
elif 195 < center < 345:
|
| 299 |
+
anchor, x = "end", lx - 4
|
| 300 |
+
else:
|
| 301 |
+
anchor, x = "middle", lx
|
| 302 |
+
gcnt = sum(len(grid[g][r]) for r in rings)
|
| 303 |
+
parts = [html.escape(s) for s in g.split(" / ")]
|
| 304 |
+
cnt_tspan = f'<tspan font-weight="400" fill="#98a0aa"> ({gcnt})</tspan>'
|
| 305 |
+
common = f'x="{x:.1f}" text-anchor="{anchor}" font-size="12.5" font-weight="700" fill="{col}"'
|
| 306 |
+
if len(parts) >= 2:
|
| 307 |
+
lab = (f'<text {common} y="{ly:.1f}">{parts[0]} /</text>'
|
| 308 |
+
f'<text {common} y="{ly + 14:.1f}">{" / ".join(parts[1:])}{cnt_tspan}</text>')
|
| 309 |
+
yy = ly + 29
|
| 310 |
+
else:
|
| 311 |
+
lab = f'<text {common} y="{ly:.1f}">{parts[0]}{cnt_tspan}</text>'
|
| 312 |
+
yy = ly + 15
|
| 313 |
+
if exemplar:
|
| 314 |
+
lab += (f'<text x="{x:.1f}" y="{yy:.1f}" text-anchor="{anchor}" '
|
| 315 |
+
f'font-size="10.5" fill="#8a929c">e.g. {html.escape(exemplar[:20])}</text>')
|
| 316 |
+
labels.append(lab)
|
| 317 |
+
|
| 318 |
+
svg.extend(labels)
|
| 319 |
+
|
| 320 |
+
# center hub
|
| 321 |
+
svg.append(f'<circle cx="{cx}" cy="{cy}" r="{r_hub - 6}" fill="#fff" stroke="#dde5ef" stroke-width="2"/>')
|
| 322 |
+
svg.append(f'<text x="{cx}" y="{cy - 4}" text-anchor="middle" font-size="27" font-weight="800" '
|
| 323 |
+
f'fill="#2b3a4a" letter-spacing="1">ALS</text>')
|
| 324 |
+
svg.append(f'<text x="{cx}" y="{cy + 15}" text-anchor="middle" font-size="10.5" '
|
| 325 |
+
f'fill="#6b7683">Therapeutic Pipeline</text>')
|
| 326 |
+
svg.append(f'<text x="{cx}" y="{cy + 34}" text-anchor="middle" font-size="15">🧬</text>')
|
| 327 |
+
|
| 328 |
+
# ring labels, stacked at top with a white halo for legibility
|
| 329 |
+
for i, ring in enumerate(rings):
|
| 330 |
+
rmid = r_hub + (i + 0.5) * t
|
| 331 |
+
_, y = _polar_xy(cx, cy, rmid, 0)
|
| 332 |
+
svg.append(f'<text x="{cx}" y="{y - 3:.1f}" text-anchor="middle" font-size="13.5" '
|
| 333 |
+
f'font-weight="800" fill="#3a4a5a" stroke="#fff" stroke-width="3.2" '
|
| 334 |
+
f'paint-order="stroke" style="paint-order:stroke">{html.escape(ring)}</text>')
|
| 335 |
+
svg.append(f'<text x="{cx}" y="{y + 12:.1f}" text-anchor="middle" font-size="11.5" '
|
| 336 |
+
f'font-weight="700" fill="#5a6675" stroke="#fff" stroke-width="3" '
|
| 337 |
+
f'paint-order="stroke" style="paint-order:stroke">({ph_tot[ring]})</text>')
|
| 338 |
+
svg.append("</svg>")
|
| 339 |
+
|
| 340 |
+
# ── side panels (legend + summary) ──
|
| 341 |
+
legend_rows = "".join(
|
| 342 |
+
f'<div style="display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px;color:#3a4453;">'
|
| 343 |
+
f'<span style="width:11px;height:11px;border-radius:50%;background:{GROUP_COLORS[g]};'
|
| 344 |
+
f'flex:0 0 auto;"></span><span>{html.escape(g)}</span>'
|
| 345 |
+
f'<span style="margin-left:auto;color:#98a0aa;">{sum(len(grid[g][r]) for r in rings)}</span></div>'
|
| 346 |
+
for g in groups)
|
| 347 |
+
legend_rows += (
|
| 348 |
+
'<div style="display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px;color:#3a4453;'
|
| 349 |
+
'border-top:1px solid #eef2f6;padding-top:6px;">'
|
| 350 |
+
f'<span style="width:11px;height:11px;border-radius:50%;background:{_INACTIVE_COLOR};'
|
| 351 |
+
'flex:0 0 auto;"></span><span>Inactive — no recruiting/active trial</span></div>')
|
| 352 |
+
legend = (
|
| 353 |
+
'<div style="border:1px solid #e6ebf1;border-radius:12px;padding:12px 14px;background:#fff;">'
|
| 354 |
+
'<div style="font-weight:700;color:#2b3a4a;font-size:13px;margin-bottom:6px;">Mechanism of Action</div>'
|
| 355 |
+
f'{legend_rows}</div>')
|
| 356 |
+
|
| 357 |
+
def _row(lbl, val, sub, strong=False):
|
| 358 |
+
w = "800" if strong else "600"
|
| 359 |
+
return (f'<div style="display:flex;justify-content:space-between;align-items:baseline;'
|
| 360 |
+
f'padding:7px 0;border-top:1px solid #eef2f6;">'
|
| 361 |
+
f'<span style="color:#3a4453;font-weight:{w};font-size:13px;">{lbl}</span>'
|
| 362 |
+
f'<span style="text-align:right;"><b style="font-size:15px;color:#2b3a4a;">{val}</b>'
|
| 363 |
+
f'<span style="display:block;font-size:10.5px;color:#98a0aa;">{sub}</span></span></div>')
|
| 364 |
+
|
| 365 |
+
pct = lambda v: f"{round(100 * v / total)}%" if total else "0%"
|
| 366 |
+
summary = (
|
| 367 |
+
'<div style="border:1px solid #e6ebf1;border-radius:12px;padding:12px 14px;background:#fff;margin-top:12px;">'
|
| 368 |
+
'<div style="font-weight:700;color:#2b3a4a;font-size:13px;text-align:center;margin-bottom:2px;">Pipeline Summary</div>'
|
| 369 |
+
+ "".join(_row(r, ph_tot[r], pct(ph_tot[r])) for r in rings)
|
| 370 |
+
+ _row("Total", total, "Candidates", strong=True)
|
| 371 |
+
+ f'<div style="margin-top:8px;font-size:10.5px;color:#98a0aa;text-align:center;">'
|
| 372 |
+
f'Showing: {html.escape(status_filter)} · {html.escape(mech_filter)}</div></div>')
|
| 373 |
+
|
| 374 |
+
subtitle = " | ".join(
|
| 375 |
+
f'{"Inner" if i == 0 else "Outer" if i == nr - 1 else "Middle"} ring: {html.escape(r)}'
|
| 376 |
+
for i, r in enumerate(rings))
|
| 377 |
+
return (
|
| 378 |
+
'<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;">'
|
| 379 |
+
'<div style="text-align:center;margin-bottom:4px;">'
|
| 380 |
+
'<div style="font-size:21px;font-weight:800;color:#22303f;">ALS Therapeutic Pipeline by Clinical Trial Phase</div>'
|
| 381 |
+
f'<div style="font-size:13px;color:#8a929c;margin-top:2px;">{subtitle}</div></div>'
|
| 382 |
+
'<div style="display:flex;gap:18px;align-items:flex-start;justify-content:center;flex-wrap:wrap;">'
|
| 383 |
+
f'<div style="flex:1 1 460px;min-width:340px;max-width:720px;">{"".join(svg)}</div>'
|
| 384 |
+
f'<div style="flex:0 0 232px;width:232px;">{legend}{summary}</div>'
|
| 385 |
+
'</div></div>')
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ── Mechanism → compound drill-down (drives the detail panel + trials table) ───
|
| 389 |
+
|
| 390 |
+
def group_names(landscape: dict | None) -> list[str]:
|
| 391 |
+
"""Display groups that have at least one compound, in canonical GROUP_ORDER."""
|
| 392 |
if not landscape:
|
| 393 |
return []
|
| 394 |
+
present = {_group_of(t) for t in _all_compounds(landscape)}
|
| 395 |
+
return [g for g in GROUP_ORDER if g in present]
|
|
|
|
|
|
|
| 396 |
|
| 397 |
|
| 398 |
+
def mechanism_filter_options(landscape: dict | None) -> list[str]:
|
| 399 |
+
"""Mechanism dropdown choices: 'All mechanisms' plus every group that has a compound."""
|
| 400 |
+
return [ALL_MECHANISMS] + group_names(landscape)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
|
| 402 |
|
| 403 |
+
def compounds_of_group(landscape: dict | None, group: str,
|
| 404 |
+
status_filter: str = "All trials", phases: list | None = None) -> list[dict]:
|
| 405 |
+
"""Compounds in `group` (or all groups when group is ALL_MECHANISMS) with ≥1 passing trial."""
|
| 406 |
+
out = []
|
| 407 |
+
for t in _all_compounds(landscape or {}):
|
| 408 |
+
if group and group != ALL_MECHANISMS and _group_of(t) != group:
|
| 409 |
+
continue
|
| 410 |
+
if not _filter_trials(t.get("trials", []), status_filter, phases):
|
| 411 |
continue
|
| 412 |
+
out.append(t)
|
| 413 |
+
return out
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def compound_labels(landscape: dict | None, group: str,
|
| 417 |
+
status_filter: str = "All trials", phases: list | None = None) -> list[str]:
|
| 418 |
+
"""Dropdown labels, e.g. 'Tofersen — 3 trials, 1 recruiting'."""
|
| 419 |
+
labels = []
|
| 420 |
+
for t in compounds_of_group(landscape, group, status_filter, phases):
|
| 421 |
c = t["trial_counts"]
|
| 422 |
rec = f", {c['recruiting']} recruiting" if c["recruiting"] else ""
|
| 423 |
+
labels.append(f"{t['name']} — {c['total']} trial{'s' if c['total'] != 1 else ''}{rec}")
|
|
|
|
|
|
|
| 424 |
return labels
|
| 425 |
|
| 426 |
|
| 427 |
+
def _compound_by_label(landscape: dict | None, label: str) -> dict | None:
|
| 428 |
+
name = label.split(" — ")[0] if label else ""
|
| 429 |
+
if not name:
|
| 430 |
+
return None
|
| 431 |
+
for t in _all_compounds(landscape or {}):
|
| 432 |
if t["name"] == name:
|
| 433 |
return t
|
| 434 |
return None
|
| 435 |
|
| 436 |
|
| 437 |
+
def compound_detail_md(landscape: dict | None, label: str) -> str:
|
| 438 |
+
if not landscape:
|
| 439 |
+
return "*Select a compound to see its pipeline stage and mechanisms.*"
|
| 440 |
+
t = _compound_by_label(landscape, label)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
if not t:
|
| 442 |
+
return "*Select a compound above.*"
|
| 443 |
+
stage = _top_ring(t["trials"]) or "Preclinical / not applicable"
|
| 444 |
+
header = (f"### {t['name']}\n<sub>{t['modality']} · Target: {t['target']} · "
|
| 445 |
+
f"Pipeline stage: {stage}</sub>")
|
| 446 |
if t.get("aliases"):
|
| 447 |
header += f"\n<sub>Also: {', '.join(t['aliases'][:6])}</sub>"
|
| 448 |
mechs = t.get("mechanisms") or []
|
|
|
|
| 456 |
return "\n".join(lines)
|
| 457 |
|
| 458 |
|
| 459 |
+
def compound_trials_html(landscape: dict | None, label: str) -> str:
|
| 460 |
+
if not landscape:
|
| 461 |
return ""
|
| 462 |
+
t = _compound_by_label(landscape, label)
|
| 463 |
if not t:
|
| 464 |
return ""
|
| 465 |
rows = []
|
| 466 |
for tr in t["trials"]:
|
| 467 |
+
color, badge_label = _STATUS_BADGE.get(tr["status_group"], _STATUS_BADGE["other"])
|
| 468 |
badge = (f'<span style="background:{color};color:#fff;border-radius:10px;'
|
| 469 |
+
f'padding:1px 8px;font-size:0.72rem;white-space:nowrap;">{badge_label}</span>')
|
| 470 |
phase = html.escape((tr.get("phase") or "—").replace("PHASE", "Ph"))
|
| 471 |
title = html.escape(tr.get("title", "")[:110])
|
| 472 |
nct = html.escape(tr.get("nct_id", ""))
|