|
1 | 1 | (function () { |
2 | 2 | "use strict"; |
3 | 3 |
|
4 | | - const SCORE_COMPONENTS = [ |
5 | | - { key: "diversity", label: "Diversity" }, |
6 | | - { key: "annotation", label: "Annotation" }, |
7 | | - { key: "functionality", label: "Functionality" }, |
8 | | - { key: "size", label: "Size" }, |
9 | | - ]; |
| 4 | + // Detect repo owner/name from the Pages URL or fall back |
| 5 | + const REPO_RAW_BASE = (function () { |
| 6 | + // GitHub Pages URLs: {owner}.github.io/{repo}/ |
| 7 | + const m = location.hostname.match(/^(.+)\.github\.io$/); |
| 8 | + if (m) { |
| 9 | + const owner = m[1]; |
| 10 | + const repo = location.pathname.split("/")[1] || ""; |
| 11 | + return `https://raw.githubusercontent.com/${owner}/${repo}/main`; |
| 12 | + } |
| 13 | + // Local dev: assume relative paths work (they won't for .sto, but graceful fail) |
| 14 | + return ""; |
| 15 | + })(); |
10 | 16 |
|
11 | 17 | async function fetchJSON(url) { |
12 | 18 | const resp = await fetch(url); |
13 | 19 | if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); |
14 | 20 | return resp.json(); |
15 | 21 | } |
16 | 22 |
|
| 23 | + async function fetchText(url) { |
| 24 | + const resp = await fetch(url); |
| 25 | + if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`); |
| 26 | + return resp.text(); |
| 27 | + } |
| 28 | + |
17 | 29 | function scoreBar(value, max) { |
18 | 30 | const pct = Math.min(100, (value / max) * 100); |
19 | 31 | return `<span class="score-bar"><span class="score-bar-fill" style="width:${pct}%"></span></span>`; |
|
25 | 37 | return `<span class="${cls}">${sym}</span>`; |
26 | 38 | } |
27 | 39 |
|
28 | | - function buildRow(entry, rank) { |
29 | | - const tr = document.createElement("tr"); |
30 | | - tr.innerHTML = ` |
31 | | - <td class="rank">${rank}</td> |
32 | | - <td class="team"><a data-team="${entry.team}">${entry.team}</a></td> |
33 | | - <td class="score-total">${scoreBar(entry.score, 100)}${entry.score.toFixed(1)}</td> |
34 | | - <td class="score-sub hide-mobile">${(entry.scores?.diversity ?? "").toString().slice(0, 4)}</td> |
35 | | - <td class="score-sub hide-mobile">${(entry.scores?.annotation ?? "").toString().slice(0, 4)}</td> |
36 | | - <td class="score-sub hide-mobile">${(entry.scores?.functionality ?? "").toString().slice(0, 4)}</td> |
37 | | - <td class="score-sub hide-mobile">${(entry.scores?.size ?? "").toString().slice(0, 4)}</td> |
38 | | - <td class="count">${entry.n_sequences}</td> |
39 | | - <td class="count">${entry.n_families}</td> |
40 | | - <td class="score-sub"><code>${(entry.commit || "").slice(0, 7)}</code></td> |
41 | | - `; |
42 | | - return tr; |
| 40 | + // ── Stockholm parser (client-side) ────────────────────────────────── |
| 41 | + |
| 42 | + function parseProteinSto(text) { |
| 43 | + const sequences = {}; |
| 44 | + const order = []; |
| 45 | + let triad = ""; |
| 46 | + |
| 47 | + for (const raw of text.split("\n")) { |
| 48 | + const line = raw.trimEnd(); |
| 49 | + if (!line || line.startsWith("# ") || line === "//") continue; |
| 50 | + if (line.startsWith("#=GC catalytic_triad")) { |
| 51 | + triad = line.split(/\s+/).pop(); |
| 52 | + } else if (line.startsWith("#=GC") || line.startsWith("#=GF")) { |
| 53 | + continue; |
| 54 | + } else { |
| 55 | + const parts = line.split(/\s+/); |
| 56 | + if (parts.length >= 2) { |
| 57 | + const id = parts[0].split("/")[0]; // strip /coord suffix |
| 58 | + if (!(id in sequences)) order.push(id); |
| 59 | + sequences[id] = (sequences[id] || "") + parts[1]; |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + return { sequences, order, catalytic_triad: triad }; |
43 | 64 | } |
44 | 65 |
|
45 | | - // Residue coloring by physicochemical property |
| 66 | + // ── Alignment renderer ────────────────────────────────────────────── |
| 67 | + |
46 | 68 | const AA_CLASSES = {}; |
47 | 69 | "AILMFWV".split("").forEach(c => AA_CLASSES[c] = "aa-hydrophobic"); |
48 | 70 | "KR".split("").forEach(c => AA_CLASSES[c] = "aa-positive"); |
|
53 | 75 | "P".split("").forEach(c => AA_CLASSES[c] = "aa-proline"); |
54 | 76 | "HY".split("").forEach(c => AA_CLASSES[c] = "aa-aromatic"); |
55 | 77 |
|
56 | | - function renderAlignment(alignment) { |
57 | | - if (!alignment || !alignment.sequences) return ""; |
| 78 | + function renderAlignment(aln) { |
| 79 | + if (!aln || !aln.order.length) return ""; |
58 | 80 |
|
59 | | - const ids = Object.keys(alignment.sequences); |
60 | | - const seqs = Object.values(alignment.sequences); |
61 | | - const triad = alignment.catalytic_triad || ""; |
| 81 | + const ids = aln.order; |
| 82 | + const seqs = ids.map(id => aln.sequences[id]); |
| 83 | + const triad = aln.catalytic_triad || ""; |
62 | 84 | const len = seqs[0].length; |
63 | 85 | const BLOCK = 80; |
64 | 86 | const pad = Math.max(...ids.map(s => s.length)); |
|
78 | 100 | for (let start = 0; start < len; start += BLOCK) { |
79 | 101 | const end = Math.min(start + BLOCK, len); |
80 | 102 |
|
81 | | - // Sequence rows |
82 | 103 | for (let s = 0; s < ids.length; s++) { |
83 | 104 | const label = ids[s].padEnd(pad + 2); |
84 | 105 | let row = `<span class="aln-label">${label}</span>`; |
|
91 | 112 | html += row + "\n"; |
92 | 113 | } |
93 | 114 |
|
94 | | - // Triad annotation row |
| 115 | + // Triad row |
95 | 116 | let triadRow = " ".repeat(pad + 2); |
96 | 117 | for (let i = start; i < end; i++) { |
97 | 118 | const ch = triad[i] || "."; |
|
122 | 143 | return html; |
123 | 144 | } |
124 | 145 |
|
125 | | - function buildDetailRow(teamData) { |
| 146 | + // ── Detail panel builder ──────────────────────────────────────────── |
| 147 | + |
| 148 | + function buildRow(entry, rank) { |
| 149 | + const tr = document.createElement("tr"); |
| 150 | + tr.innerHTML = ` |
| 151 | + <td class="rank">${rank}</td> |
| 152 | + <td class="team"><a data-team="${entry.team}">${entry.team}</a></td> |
| 153 | + <td class="score-total">${scoreBar(entry.score, 100)}${entry.score.toFixed(1)}</td> |
| 154 | + <td class="score-sub hide-mobile">${(entry.scores?.diversity ?? "").toString().slice(0, 4)}</td> |
| 155 | + <td class="score-sub hide-mobile">${(entry.scores?.annotation ?? "").toString().slice(0, 4)}</td> |
| 156 | + <td class="score-sub hide-mobile">${(entry.scores?.functionality ?? "").toString().slice(0, 4)}</td> |
| 157 | + <td class="score-sub hide-mobile">${(entry.scores?.size ?? "").toString().slice(0, 4)}</td> |
| 158 | + <td class="count">${entry.n_sequences}</td> |
| 159 | + <td class="count">${entry.n_families}</td> |
| 160 | + <td class="score-sub"><code>${(entry.commit || "").slice(0, 7)}</code></td> |
| 161 | + `; |
| 162 | + return tr; |
| 163 | + } |
| 164 | + |
| 165 | + function buildDetailRow(teamData, team) { |
126 | 166 | const tr = document.createElement("tr"); |
127 | 167 | tr.className = "detail-row"; |
128 | 168 | const td = document.createElement("td"); |
|
155 | 195 | } |
156 | 196 | } |
157 | 197 |
|
158 | | - const alignmentHTML = renderAlignment(teamData.alignment); |
| 198 | + // Alignment placeholder — loaded on first expand |
| 199 | + const alnDiv = document.createElement("div"); |
| 200 | + alnDiv.className = "alignment-container"; |
| 201 | + alnDiv.dataset.team = team; |
| 202 | + alnDiv.dataset.loaded = "false"; |
159 | 203 |
|
160 | | - td.innerHTML = `<div class="detail-panel">${checksHTML}${issuesHTML}${alignmentHTML}</div>`; |
| 204 | + td.innerHTML = `<div class="detail-panel">${checksHTML}${issuesHTML}</div>`; |
| 205 | + td.querySelector(".detail-panel").appendChild(alnDiv); |
161 | 206 | tr.appendChild(td); |
162 | 207 | tr.style.display = "none"; |
163 | 208 | return tr; |
164 | 209 | } |
165 | 210 |
|
| 211 | + async function loadAlignment(container) { |
| 212 | + if (container.dataset.loaded === "true") return; |
| 213 | + container.dataset.loaded = "true"; |
| 214 | + |
| 215 | + const team = container.dataset.team; |
| 216 | + if (!REPO_RAW_BASE) { |
| 217 | + container.innerHTML = '<p class="aln-note">Alignment view requires GitHub Pages hosting.</p>'; |
| 218 | + return; |
| 219 | + } |
| 220 | + |
| 221 | + const url = `${REPO_RAW_BASE}/entries/${team}/protein.sto`; |
| 222 | + container.innerHTML = '<p class="aln-note">Loading alignment\u2026</p>'; |
| 223 | + |
| 224 | + try { |
| 225 | + const text = await fetchText(url); |
| 226 | + const aln = parseProteinSto(text); |
| 227 | + container.innerHTML = renderAlignment(aln); |
| 228 | + } catch (e) { |
| 229 | + container.innerHTML = `<p class="aln-note">Could not load alignment: ${e.message}</p>`; |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + // ── Init ──────────────────────────────────────────────────────────── |
| 234 | + |
166 | 235 | async function init() { |
167 | 236 | const tbody = document.getElementById("leaderboard-body"); |
168 | 237 | const updatedEl = document.getElementById("updated"); |
|
186 | 255 | return; |
187 | 256 | } |
188 | 257 |
|
189 | | - // Sort by score descending |
190 | 258 | data.entries.sort((a, b) => b.score - a.score); |
191 | 259 |
|
192 | | - // Load all score files in parallel for detail panels |
193 | 260 | const scoreFiles = {}; |
194 | 261 | await Promise.allSettled( |
195 | 262 | data.entries.map(async (entry) => { |
196 | 263 | try { |
197 | 264 | scoreFiles[entry.team] = await fetchJSON(`scores/${entry.team}.json`); |
198 | 265 | } catch { |
199 | | - /* score file not copied yet, that's OK */ |
| 266 | + /* score file not copied yet */ |
200 | 267 | } |
201 | 268 | }) |
202 | 269 | ); |
|
205 | 272 | const row = buildRow(entry, i + 1); |
206 | 273 | tbody.appendChild(row); |
207 | 274 |
|
208 | | - const detailRow = buildDetailRow(scoreFiles[entry.team] || {}); |
| 275 | + const detailRow = buildDetailRow(scoreFiles[entry.team] || {}, entry.team); |
209 | 276 | tbody.appendChild(detailRow); |
210 | 277 |
|
211 | | - // Toggle detail on team name click |
212 | 278 | row.querySelector("[data-team]").addEventListener("click", () => { |
213 | | - detailRow.style.display = |
214 | | - detailRow.style.display === "none" ? "table-row" : "none"; |
| 279 | + const showing = detailRow.style.display === "none"; |
| 280 | + detailRow.style.display = showing ? "table-row" : "none"; |
| 281 | + if (showing) { |
| 282 | + const container = detailRow.querySelector(".alignment-container"); |
| 283 | + if (container) loadAlignment(container); |
| 284 | + } |
215 | 285 | }); |
| 286 | + |
| 287 | + // Auto-expand the top-ranked entry |
| 288 | + if (i === 0) { |
| 289 | + detailRow.style.display = "table-row"; |
| 290 | + const container = detailRow.querySelector(".alignment-container"); |
| 291 | + if (container) loadAlignment(container); |
| 292 | + } |
216 | 293 | }); |
217 | 294 | } |
218 | 295 |
|
|
0 commit comments