KevinIsInCoding Claude Opus 4.8 commited on
Commit
3e62ca2
·
unverified ·
1 Parent(s): 680f4eb

feat(landscape): merge trials-by-phase wheel into Therapy Landscape tab (#21)

Browse files

Fold 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>

Files changed (2) hide show
  1. app.py +50 -46
  2. landscape.py +395 -108
app.py CHANGED
@@ -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
- _landscape_classes = landscape_mod.class_names(_landscape)
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 _landscape_select(class_name: str, phases):
177
- """When the class (or phase filter) changes: repopulate therapies and show the first one's detail."""
178
- labels = landscape_mod.therapy_labels(_landscape, class_name, phases)
179
  first = labels[0] if labels else None
180
  return (
 
181
  gr.update(choices=labels, value=first),
182
- landscape_mod.therapy_detail_md(_landscape, class_name, first or ""),
183
- landscape_mod.trials_table_html(_landscape, class_name, first or ""),
184
  )
185
 
186
 
187
- def _phase_change(phases, class_name: str):
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.therapy_detail_md(_landscape, class_name, therapy_label),
196
- landscape_mod.trials_table_html(_landscape, class_name, therapy_label),
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
- "### 🧭 Experimental ALS Therapy Landscape\n"
254
- "Explore experimental therapies by **mechanism class therapy clinical trials** "
255
- "(recruiting & closed). Click a wedge to zoom; use the selectors for trial details."
 
 
 
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
- _init_class = _landscape_classes[0]
263
- _init_labels = landscape_mod.therapy_labels(_landscape, _init_class)
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
- class_dd = gr.Dropdown(
276
- choices=_landscape_classes, value=_init_class,
277
- label="Mechanism class", scale=1,
 
 
 
 
 
 
278
  )
279
- therapy_dd = gr.Dropdown(
280
- choices=_init_labels, value=_init_label,
281
- label="Therapy", scale=1,
 
282
  )
283
 
 
 
 
 
 
 
 
 
 
 
284
  detail_md = gr.Markdown(
285
- landscape_mod.therapy_detail_md(_landscape, _init_class, _init_label or "")
286
  )
287
  trials_html = gr.HTML(
288
- landscape_mod.trials_table_html(_landscape, _init_class, _init_label or "")
289
  )
290
 
291
- class_dd.change(
292
- _landscape_select, inputs=[class_dd, phase_cb],
293
- outputs=[therapy_dd, detail_md, trials_html],
294
- )
295
- therapy_dd.change(
296
- _therapy_select, inputs=[class_dd, therapy_dd],
 
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
 
landscape.py CHANGED
@@ -1,9 +1,12 @@
1
- """Rendering helpers for the Experimental ALS Therapy Landscape tab (v2: multi-label).
2
-
3
- Loads data/landscape/landscape.json and turns it into a Plotly sunburst (mechanism class
4
- primary therapy) plus a detail panel that lists every mechanism a therapy acts through (with role,
5
- confidence, and justification) and a trials table with status badges + NCT links. Therapies whose
6
- mechanism could not be established are shown honestly under "Insufficient evidence".
 
 
 
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
- PHASE_OPTIONS = ["Phase 1", "Phase 2", "Phase 3", "Phase 4", "Not applicable"]
 
33
 
34
 
35
  def _phase_buckets(phase: str) -> set:
36
- """Map a ClinicalTrials.gov phase string (incl. combos like 'PHASE1, PHASE2') to UI buckets."""
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
- if "PHASE4" in p:
46
- b.add("Phase 4")
47
- return b or {"Not applicable"} # NA / Expanded Access / blank
48
-
49
-
50
- def _therapy_matches_phases(t: dict, selected: set | None) -> bool:
51
- """A therapy is shown if any of its trials falls in a selected phase. None/all = whole picture."""
52
- if not selected or len(selected) >= len(PHASE_OPTIONS):
53
- return True
54
- return any(_phase_buckets(tr.get("phase", "")) & selected for tr in t["trials"])
 
 
55
 
56
 
57
  def load_landscape() -> dict | None:
@@ -63,99 +69,380 @@ def load_landscape() -> dict | None:
63
  return None
64
 
65
 
66
- def class_names(landscape: dict | None) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  if not landscape:
68
  return []
69
- names = [c["name"] for c in landscape["classifications"]]
70
- if landscape.get("unclassified"):
71
- names.append(_INSUFFICIENT)
72
- return names
73
 
74
 
75
- def _therapies_of(landscape: dict, class_name: str) -> list[dict]:
76
- if class_name == _INSUFFICIENT:
77
- return landscape.get("unclassified", [])
78
- for c in landscape["classifications"]:
79
- if c["name"] == class_name:
80
- return c["therapies"]
81
- return []
82
 
83
 
84
- def therapy_labels(landscape: dict | None, class_name: str, phases: list | None = None) -> list[str]:
85
- """Dropdown labels, e.g. 'CNM-Au8 [contributing] 5 trials, 3 recruiting'."""
86
- sel = set(phases) if phases else None
87
- labels = []
88
- for t in _therapies_of(landscape or {}, class_name):
89
- if not _therapy_matches_phases(t, sel):
 
 
90
  continue
 
 
 
 
 
 
 
 
 
91
  c = t["trial_counts"]
92
  rec = f", {c['recruiting']} recruiting" if c["recruiting"] else ""
93
- role = t.get("role_here")
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 _therapy_by_label(landscape: dict, class_name: str, label: str) -> dict | None:
100
- name = label.split(" — ")[0].split(" [")[0] if label else ""
101
- for t in _therapies_of(landscape, class_name):
 
 
102
  if t["name"] == name:
103
  return t
104
  return None
105
 
106
 
107
- def build_sunburst(landscape: dict | None, phases: list | None = None):
108
- """Two-ring sunburst: inner = mechanism class, outer = its PRIMARY therapies (sized by trials).
109
- Contributing memberships live in the detail panel; abstentions get an 'Insufficient evidence' wedge.
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 f"*{class_name}* — select a therapy above."
158
- header = f"### {t['name']}\n<sub>{t['modality']} · Target: {t['target']}</sub>"
 
 
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 trials_table_html(landscape: dict | None, class_name: str, therapy_label: str) -> str:
173
- if not landscape or not class_name:
174
  return ""
175
- t = _therapy_by_label(landscape, class_name, therapy_label)
176
  if not t:
177
  return ""
178
  rows = []
179
  for tr in t["trials"]:
180
- color, label = _STATUS_BADGE.get(tr["status_group"], _STATUS_BADGE["other"])
181
  badge = (f'<span style="background:{color};color:#fff;border-radius:10px;'
182
- f'padding:1px 8px;font-size:0.72rem;white-space:nowrap;">{label}</span>')
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 = " &nbsp;|&nbsp; ".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", ""))