| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> |
| <title>radical philicity predictor</title> |
|
|
| <style> |
| :root{ |
| --bg: #ffffff; |
| --card: #ffffff; |
| --muted: #6b7280; |
| --text: #111827; |
| --border: #e5e7eb; |
| --shadow: 0 10px 30px rgba(0,0,0,.06); |
| --shadow2: 0 8px 20px rgba(0,0,0,.05); |
| --radius: 16px; |
| |
| --font-ui: Avenir, "Avenir Next", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| --font-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
| } |
| |
| body { |
| font-family: var(--font-ui); |
| margin: 36px auto; |
| padding: 0 18px; |
| max-width: 1100px; |
| background: var(--bg); |
| color: var(--text); |
| } |
| |
| header{ |
| display:flex; |
| align-items:flex-end; |
| justify-content:space-between; |
| gap:16px; |
| margin-bottom: 18px; |
| } |
| |
| h1 { |
| margin: 0; |
| font-family: Avenir, "Avenir Next", -apple-system, BlinkMacSystemFont, |
| "Segoe UI", Roboto, sans-serif; |
| font-weight: 400; |
| font-size: 28px; |
| letter-spacing: -0.02em; |
| } |
| .sub { color: var(--muted); font-size: 13px; line-height: 1.3; } |
| |
| code { background: #f6f6f6; padding: 2px 6px; border-radius: 8px; } |
| |
| .card { |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 18px; |
| margin-top: 16px; |
| background: var(--card); |
| box-shadow: var(--shadow); |
| } |
| |
| label { display: block; font-weight: 400; margin-top: 8px; font-size: 13px; } |
| input { |
| width: 100%; |
| box-sizing: border-box; |
| padding: 10px 12px; |
| margin-top: 6px; |
| border: 1px solid #d1d5db; |
| border-radius: 12px; |
| font-size: 14px; |
| outline: none; |
| } |
| input:focus { border-color: #9ca3af; box-shadow: 0 0 0 4px rgba(156,163,175,.15); } |
| |
| .controls{ |
| display:block; |
| } |
| |
| .inputRow{ |
| display:flex; |
| gap: 12px; |
| align-items: center; |
| } |
| |
| .titleWrap { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 18px; |
| } |
| |
| .headerLeft, |
| .headerRight { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .header h1 { |
| font-family: Avenir, "Avenir Next", -apple-system, BlinkMacSystemFont, |
| "Segoe UI", Roboto, sans-serif; |
| font-weight: 400; |
| font-size: 28px; |
| letter-spacing: -0.02em; |
| margin: 0; |
| } |
| |
| .logo { |
| height: 60px; |
| width: auto; |
| display: block; |
| } |
| |
| .btnRow{ |
| display:flex; |
| gap: 12px; |
| align-items:center; |
| flex: 0 0 auto; |
| } |
| |
| .inputRow input{ |
| flex: 1 1 auto; |
| min-width: 0; |
| } |
| |
| |
| button{ |
| height: 40px; |
| } |
| |
| |
| button { |
| height: 40px; |
| padding: 0 14px; |
| border: 1px solid #111827; |
| border-radius: 12px; |
| cursor: pointer; |
| font-weight: 400; |
| background: #111827; |
| color: white; |
| box-shadow: var(--shadow2); |
| } |
| button.secondary{ |
| border-color: #d1d5db; |
| background: white; |
| color: #111827; |
| } |
| button:disabled { opacity: 0.6; cursor: not-allowed; } |
| |
| .error { |
| color: #b00020; |
| font-weight: 400; |
| margin-top: 12px; |
| white-space: pre-wrap; |
| } |
| |
| .spinner{ |
| display:inline-block; |
| width: 14px; |
| height: 14px; |
| border: 2px solid rgba(255,255,255,.35); |
| border-top-color: rgba(255,255,255,1); |
| border-radius: 999px; |
| animation: spin .8s linear infinite; |
| vertical-align: -2px; |
| margin-right: 8px; |
| } |
| @keyframes spin { to { transform: rotate(360deg);} } |
| |
| |
| .resultsHeader{ |
| display:flex; |
| align-items:baseline; |
| justify-content:space-between; |
| gap:12px; |
| margin-bottom: 10px; |
| } |
| .pill{ |
| display:inline-flex; |
| align-items:center; |
| gap:8px; |
| border: 1px solid var(--border); |
| border-radius: 999px; |
| padding: 6px 10px; |
| color: var(--muted); |
| font-size: 12px; |
| background:#fafafa; |
| } |
| |
| .twoCol{ |
| display:grid; |
| grid-template-columns: 360px 1fr; |
| gap: 16px; |
| align-items:start; |
| } |
| @media (max-width: 900px){ |
| .twoCol{ grid-template-columns: 1fr; } |
| } |
| |
| .svgWrap{ |
| border: 1px solid var(--border); |
| border-radius: 14px; |
| padding: 10px; |
| background:#fafafa; |
| overflow:hidden; |
| } |
| .svgWrap svg{ |
| width:100%; |
| height:auto; |
| display:block; |
| } |
| |
| .toolbar{ |
| display:flex; |
| flex-wrap:wrap; |
| gap:10px; |
| align-items:center; |
| justify-content:space-between; |
| margin-bottom: 12px; |
| } |
| |
| .toolbar .left, .toolbar .right{ |
| display:flex; |
| gap:10px; |
| align-items:center; |
| flex-wrap:wrap; |
| } |
| |
| select, .search{ |
| height: 36px; |
| border-radius: 12px; |
| border: 1px solid #d1d5db; |
| padding: 0 10px; |
| font-size: 13px; |
| background:white; |
| outline:none; |
| } |
| |
| .grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); |
| gap: 14px; |
| } |
| |
| .radCard{ |
| border: 1px solid var(--border); |
| border-radius: 16px; |
| background:white; |
| box-shadow: var(--shadow2); |
| overflow:hidden; |
| display:flex; |
| flex-direction:column; |
| } |
| |
| .radCard .top{ |
| padding: 10px 10px 0 10px; |
| } |
| .radCard .svgWrap{ |
| margin: 0; |
| border: none; |
| border-radius: 12px; |
| background:#f8fafc; |
| padding: 6px; |
| } |
| |
| .radCard .meta{ |
| padding: 10px 12px 12px 12px; |
| display:flex; |
| flex-direction:column; |
| gap:8px; |
| } |
| |
| .phi{ |
| font-size: 18px; |
| font-weight: 400; |
| letter-spacing:-0.01em; |
| } |
| .smiles{ |
| font-family: var(--font-mono); |
| font-size: 11px; |
| color: #374151; |
| background:#f3f4f6; |
| border-radius: 10px; |
| padding: 8px; |
| overflow:auto; |
| max-height: 70px; |
| } |
| |
| .copyRow{ |
| display:flex; |
| gap:8px; |
| align-items:center; |
| justify-content:space-between; |
| } |
| |
| .miniBtn{ |
| height: 30px; |
| padding: 0 10px; |
| border-radius: 10px; |
| border: 1px solid #d1d5db; |
| background: white; |
| font-weight: 400; |
| cursor: pointer; |
| font-size: 12px; |
| |
| color: #111827; |
| } |
| |
| .hint{ color: var(--muted); font-size: 12px; } |
| </style> |
| </head> |
|
|
| <body> |
| <header class="header"> |
| <div class="headerLeft"> |
| <h1>radical philicity predictor</h1> |
| </div> |
|
|
| <div class="headerRight"> |
| <img src="/static/logo.png" alt="Logo" class="logo" /> |
| <h1>Tekle-Smith Lab</h1> |
| </div> |
| </header> |
|
|
| <div class="card"> |
| <div class="controls"> |
| <div class="inputBlock"> |
| <label for="orig">closed-shell SMILES</label> |
|
|
| <div class="inputRow"> |
| <input id="orig" value="CNCC(CCI)CO" /> |
| <div class="btnRow"> |
| <button id="btn">predict radicals</button> |
| <button id="btnDemo" class="secondary" title="Load a demo SMILES">demo</button> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
|
|
| <div id="err" class="error"></div> |
| </div> |
|
|
| <div id="results" class="card" style="display:none;"> |
| <div class="resultsHeader"> |
| <div> |
| <div style="font-weight:400; font-size:18px;">results</div> |
| <div class="sub" id="summary"></div> |
| </div> |
| <div class="pill" id="countPill"></div> |
| </div> |
|
|
| <div class="twoCol"> |
| <div> |
| <div class="sub" style="margin-bottom:8px;"></div> |
| <div class="svgWrap" id="origSvg"></div> |
| </div> |
|
|
| <div> |
| <div class="toolbar"> |
| <div class="left"> |
| <select id="sort"> |
| <option value="desc">sort: philicity high → low</option> |
| <option value="asc">sort: philicity low → high</option> |
| </select> |
| <input class="search" id="search" placeholder="Filter by radical SMILES substring…" /> |
| </div> |
| <div class="right"> |
| <button class="miniBtn" id="btnDownload">download CSV</button> |
| <button class="miniBtn" id="btnClear">clear</button> |
| </div> |
| </div> |
|
|
| <div class="grid" id="grid"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const btn = document.getElementById("btn"); |
| const btnDemo = document.getElementById("btnDemo"); |
| const err = document.getElementById("err"); |
| |
| const results = document.getElementById("results"); |
| const origSvg = document.getElementById("origSvg"); |
| const grid = document.getElementById("grid"); |
| const summary = document.getElementById("summary"); |
| const countPill = document.getElementById("countPill"); |
| |
| const sortSel = document.getElementById("sort"); |
| const searchBox = document.getElementById("search"); |
| const btnClear = document.getElementById("btnClear"); |
| const btnDownload = document.getElementById("btnDownload"); |
| const statusPill = document.getElementById("statusPill"); |
| |
| let lastRows = []; |
| |
| function setLoading(isLoading) { |
| btn.disabled = isLoading; |
| btn.innerHTML = isLoading ? `<span class="spinner"></span>Predicting…` : "predict radicals"; |
| btnDemo.disabled = isLoading; |
| } |
| |
| function escapeHtml(s) { |
| return String(s) |
| .replaceAll("&","&") |
| .replaceAll("<","<") |
| .replaceAll(">",">") |
| .replaceAll('"',""") |
| .replaceAll("'","'"); |
| } |
| |
| function toCSV(rows) { |
| const header = ["radical_smiles","radical_center","philicity"]; |
| const lines = [header.join(",")]; |
| for (const r of rows) { |
| const vals = [ |
| `"${String(r.radical_smiles).replaceAll('"','""')}"`, |
| r.radical_center, |
| r.philicity |
| ]; |
| lines.push(vals.join(",")); |
| } |
| return lines.join("\n"); |
| } |
| |
| function downloadText(filename, text) { |
| const blob = new Blob([text], {type: "text/plain;charset=utf-8"}); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| URL.revokeObjectURL(url); |
| } |
| |
| function renderGrid() { |
| const q = searchBox.value.trim().toLowerCase(); |
| const order = sortSel.value; |
| |
| let rows = [...lastRows]; |
| |
| if (q) { |
| rows = rows.filter(r => String(r.display_smiles).toLowerCase().includes(q)); |
| } |
| |
| rows.sort((a,b) => order === "desc" ? (b.philicity - a.philicity) : (a.philicity - b.philicity)); |
| |
| grid.innerHTML = ""; |
| for (const r of rows) { |
| const card = document.createElement("div"); |
| card.className = "radCard"; |
| |
| const phi = Number(r.philicity); |
| const phiText = Number.isFinite(phi) ? phi.toFixed(3) : String(r.philicity); |
| |
| card.innerHTML = ` |
| <div class="top"> |
| <div class="svgWrap">${r.svg}</div> |
| </div> |
| <div class="meta"> |
| <div class="copyRow"> |
| <div class="phi">ω = ${phiText} eV</div> |
| <button class="miniBtn" data-copy="${escapeHtml(r.display_smiles)}">Copy</button> |
| </div> |
| <div class="smiles">${escapeHtml(r.display_smiles)}</div> |
| </div> |
| `; |
| |
| grid.appendChild(card); |
| } |
| |
| |
| grid.querySelectorAll("button[data-copy]").forEach(b => { |
| b.addEventListener("click", async () => { |
| const txt = b.getAttribute("data-copy"); |
| try { |
| await navigator.clipboard.writeText(txt); |
| const prev = b.textContent; |
| b.textContent = "Copied!"; |
| setTimeout(() => b.textContent = prev, 800); |
| } catch (e) { |
| alert("Could not copy to clipboard."); |
| } |
| }); |
| }); |
| |
| countPill.textContent = `${rows.length} radicals shown`; |
| } |
| |
| async function predictAll() { |
| err.textContent = ""; |
| results.style.display = "none"; |
| grid.innerHTML = ""; |
| origSvg.innerHTML = ""; |
| summary.textContent = ""; |
| |
| const original_smiles = document.getElementById("orig").value.trim(); |
| if (!original_smiles) { |
| err.textContent = "please enter an original SMILES string."; |
| return; |
| } |
| |
| setLoading(true); |
| try { |
| const resp = await fetch("/predict_all", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ original_smiles }) |
| }); |
| |
| const data = await resp.json(); |
| |
| if (!resp.ok) { |
| err.textContent = data?.detail ?? "Request failed."; |
| return; |
| } |
| |
| |
| results.style.display = "block"; |
| origSvg.innerHTML = data.original_svg || ""; |
| |
| lastRows = (data.radicals || []).map(r => ({ |
| radical_smiles: r.radical_smiles, |
| display_smiles: r.display_smiles || r.radical_smiles, |
| radical_site: r.radical_site ?? r.radical_center, |
| philicity: r.philicity, |
| svg: r.svg |
| })); |
| |
| summary.textContent = `${data.original_smiles} • generated ${data.n_radicals ?? lastRows.length} radicals`; |
| renderGrid(); |
| |
| } catch (e) { |
| err.textContent = String(e); |
| } finally { |
| setLoading(false); |
| } |
| } |
| |
| btn.addEventListener("click", predictAll); |
| |
| btnDemo.addEventListener("click", () => { |
| const demos = [ |
| "CNCC(CCI)CO", |
| "c1ccccc1", |
| "CC(C)C1=CC=CC=C1", |
| "CCN(CC)CCO", |
| "CCOC(=O)C1=CC=CC=C1" |
| ]; |
| document.getElementById("orig").value = demos[Math.floor(Math.random()*demos.length)]; |
| }); |
| |
| sortSel.addEventListener("change", renderGrid); |
| searchBox.addEventListener("input", renderGrid); |
| |
| btnClear.addEventListener("click", () => { |
| err.textContent = ""; |
| results.style.display = "none"; |
| grid.innerHTML = ""; |
| origSvg.innerHTML = ""; |
| summary.textContent = ""; |
| lastRows = []; |
| searchBox.value = ""; |
| }); |
| |
| btnDownload.addEventListener("click", () => { |
| if (!lastRows.length) return; |
| const csv = toCSV(lastRows); |
| downloadText("radicals_philicity.csv", csv); |
| }); |
| |
| |
| checkHealth(); |
| </script> |
| </body> |
| </html> |
|
|