// ClearDDI — front-end logic // Prototype académique d'aide à la décision — non destiné à un usage clinique réel. const API = ""; const $ = (sel) => document.querySelector(sel); const drugAInput = $("#drugA"); const drugBInput = $("#drugB"); const analyzeBtn = $("#analyzeBtn"); const swapBtn = $("#swapBtn"); const errorBanner = $("#errorBanner"); const resultsEl = $("#results"); const suggestionsA = $("#suggestionsA"); const suggestionsB = $("#suggestionsB"); // ── Autocomplétion légère (suggestion uniquement, ne bloque rien) ─────── function setupAutocomplete(input, box) { let debounceTimer = null; let activeIndex = -1; input.addEventListener("input", () => { const q = input.value.trim(); clearTimeout(debounceTimer); if (q.length < 2) { box.classList.remove("open"); box.innerHTML = ""; return; } debounceTimer = setTimeout(async () => { try { const res = await fetch(`${API}/api/search?q=${encodeURIComponent(q)}`); if (!res.ok) return; const data = await res.json(); renderSuggestions(data.matches || []); } catch (e) { /* silencieux : l'autocomplétion est un confort, pas une dépendance */ } }, 180); }); function renderSuggestions(matches) { activeIndex = -1; if (!matches.length) { box.classList.remove("open"); box.innerHTML = ""; return; } box.innerHTML = matches .map((m, i) => `
${capitalize(m)}
`) .join(""); box.classList.add("open"); box.querySelectorAll(".suggestion-item").forEach((el) => { el.addEventListener("mousedown", (e) => { e.preventDefault(); input.value = capitalize(el.textContent); box.classList.remove("open"); box.innerHTML = ""; }); }); } input.addEventListener("blur", () => { setTimeout(() => box.classList.remove("open"), 100); }); } function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); } setupAutocomplete(drugAInput, suggestionsA); setupAutocomplete(drugBInput, suggestionsB); // ── Exemples cliquables ────────────────────────────────────────────────── document.querySelectorAll(".sample-chip").forEach((chip) => { chip.addEventListener("click", () => { drugAInput.value = capitalize(chip.dataset.a); drugBInput.value = capitalize(chip.dataset.b); runAnalysis(); }); }); // ── Inversion A/B ───────────────────────────────────────────────────── swapBtn.addEventListener("click", () => { const tmp = drugAInput.value; drugAInput.value = drugBInput.value; drugBInput.value = tmp; }); // ── Lancement de l'analyse ──────────────────────────────────────────── analyzeBtn.addEventListener("click", runAnalysis); [drugAInput, drugBInput].forEach((inp) => inp.addEventListener("keydown", (e) => { if (e.key === "Enter") runAnalysis(); }) ); async function runAnalysis() { const a = drugAInput.value.trim(); const b = drugBInput.value.trim(); hideError(); if (!a || !b) { showError("Veuillez saisir le nom des deux médicaments."); return; } setLoading(true); resultsEl.hidden = true; try { const res = await fetch(`${API}/api/analyze`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ drug_a: a, drug_b: b }), }); const data = await res.json(); if (res.status === 404 && data.error === "not_found") { const which = data.which === "A" ? a : b; showError(`Le médicament « ${which} » n'a pas été trouvé dans la base de référence.`); return; } if (!res.ok) { showError("Une erreur est survenue pendant l'analyse. Veuillez réessayer."); return; } renderResults(data); } catch (e) { showError("Impossible de contacter le serveur d'analyse."); } finally { setLoading(false); } } function setLoading(isLoading) { analyzeBtn.disabled = isLoading; analyzeBtn.querySelector(".btn-label").textContent = isLoading ? "Analyse en cours…" : "Lancer l'analyse"; } function showError(msg) { errorBanner.textContent = msg; errorBanner.hidden = false; } function hideError() { errorBanner.hidden = true; } // ── Rendu des résultats ────────────────────────────────────────────── function riskClass(level) { return level === "élevé" ? "eleve" : level === "modéré" ? "modere" : "low"; } function renderResults(data) { const { drug_a, drug_b, prediction, clinical_type, side_effects, fragments, reasons, model_metrics } = data; const rc = riskClass(prediction.risk_level); const probaPct = Math.round(prediction.probability * 100); const confPct = Math.round(prediction.confidence * 100); const gaugeCircumference = 2 * Math.PI * 70; const gaugeOffset = gaugeCircumference * (1 - prediction.probability); const gaugeColor = rc === "eleve" ? "var(--risk-high)" : rc === "modere" ? "var(--risk-mod)" : "var(--risk-low)"; const headline = prediction.interaction_predicted ? `Interaction ${clinical_type.label.toLowerCase()} probable entre ces deux substances.` : `Aucune interaction significative détectée par le modèle entre ces deux substances.`; resultsEl.innerHTML = `

Résultat de la prédiction

${probaPct}% probabilité Score de risque
${escapeHtml(drug_a.name)} × ${escapeHtml(drug_b.name)}
Risque ${prediction.risk_level}

${headline}

Score de confiance du modèle
${prediction.confidence.toFixed(2)}
${moleculeCardA(drug_a)} ${moleculeCardB(drug_b)}

Explicabilité

Raisons possibles de l'alerte

${reasons.map(reasonItem).join("")}

Fragments influents (SHAP)

${fragments.slice(0, 6).map(featRow).join("")}
Lecture : chaque barre représente la contribution d'un bit de fingerprint moléculaire (Morgan, rayon 2) à la prédiction. Le rouge augmente le risque prédit, le teal le réduit.
${fragmentChips(fragments)}

Effets secondaires potentiels associés

${side_effects.map((s) => `${escapeHtml(s)}`).join("")}

Type clinique le plus probable

${clinical_type.ranking.map(rankRow).join("")}
Classement prédit par un second modèle (XGBoost multi-classe) entraîné sur le mécanisme clinique des interactions documentées.

Fiche synthétique pharmacien

Fiche d'alerte — ${escapeHtml(drug_a.name)} × ${escapeHtml(drug_b.name)}

Générée le ${new Date().toLocaleDateString("fr-FR")} · Modèle XGBoost (AUC cold-start ${model_metrics.cold_start_auc?.toFixed(3) ?? "—"})
Niveau de risque
${prediction.risk_level}
Probabilité
${prediction.probability.toFixed(2)}
Confiance modèle
${prediction.confidence.toFixed(2)}
Type clinique
${escapeHtml(clinical_type.label)}
`; resultsEl.hidden = false; resultsEl.scrollIntoView({ behavior: "smooth", block: "start" }); // Charger les structures moléculaires réelles de façon asynchrone (SVG RDKit) loadMoleculeSvg(drug_a.name, "mol-svg-a"); loadMoleculeSvg(drug_b.name, "mol-svg-b"); } function moleculeCardA(drug) { return `
${escapeHtml(drug.name)} (${drug.drugbank_id})
`; } function moleculeCardB(drug) { return `
${escapeHtml(drug.name)} (${drug.drugbank_id})
`; } async function loadMoleculeSvg(name, targetId) { try { const res = await fetch(`${API}/api/molecule_svg?name=${encodeURIComponent(name)}`); if (!res.ok) return; const data = await res.json(); const el = document.getElementById(targetId); if (el && data.svg) el.innerHTML = data.svg; } catch (e) { /* silencieux */ } } function reasonItem(r) { return `
${escapeHtml(r.title)}
${escapeHtml(r.detail)}
`; } function featRow(f) { const pct = Math.min(100, Math.abs(f.shap_value) * 80); const cls = f.shap_value > 0 ? "pos" : "neg"; const sign = f.shap_value > 0 ? "+" : ""; const color = f.shap_value > 0 ? "var(--risk-high)" : "var(--teal)"; const name = `bit_${f.bit}`; return `
${name}
${sign}${f.shap_value.toFixed(2)}
`; } function fragmentChips(fragments) { const withFrag = fragments.filter((f) => f.fragment_smiles); if (!withFrag.length) return ""; return `
${withFrag .map((f) => `${escapeHtml(f.fragment_smiles)}`) .join("")}
`; } function rankRow(r) { const pct = Math.round(r.probability * 100); return `
${escapeHtml(r.type)}
${pct}%
`; } function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str ?? ""; return div.innerHTML; }