| |
|
|
| document.addEventListener("DOMContentLoaded", () => { |
| const input = $("#complaint"); |
| const btn = $("#btnClassify"); |
| const spin = $("#spin"); |
| const thr = $("#optThreshold"); |
|
|
| |
| const RING_C = 2 * Math.PI * 37; |
|
|
| thr.addEventListener("input", () => { $("#thrOut").value = (+thr.value).toFixed(2); }); |
|
|
| $$(".chip").forEach((chip) => |
| chip.addEventListener("click", () => { |
| input.value = chip.dataset.sample; |
| input.focus(); |
| }) |
| ); |
|
|
| $("#btnClear").addEventListener("click", () => { |
| input.value = ""; |
| $("#result").classList.add("hidden"); |
| $("#resultEmpty").classList.remove("hidden"); |
| input.focus(); |
| }); |
|
|
| input.addEventListener("keydown", (e) => { |
| if ((e.ctrlKey || e.metaKey) && e.key === "Enter") classify(); |
| }); |
| btn.addEventListener("click", classify); |
|
|
| async function classify() { |
| const text = input.value.trim(); |
| if (!text) { toast("Type a complaint first.", true); input.focus(); return; } |
|
|
| btn.disabled = true; |
| spin.classList.remove("hidden"); |
| try { |
| const data = await api("/api/classify", { |
| method: "POST", |
| body: { |
| text, |
| translate: $("#optTranslate").checked, |
| multi_label: $("#optMulti").checked, |
| save: $("#optSave").checked, |
| threshold: parseFloat(thr.value), |
| }, |
| }); |
| render(data); |
| toast(`Classified as "${data.predicted_label}" in ${data.took_ms} ms`); |
| } catch (err) { |
| toast(err.message, true); |
| } finally { |
| btn.disabled = false; |
| spin.classList.add("hidden"); |
| } |
| } |
|
|
| function render(d) { |
| $("#resultEmpty").classList.add("hidden"); |
| $("#result").classList.remove("hidden"); |
|
|
| $("#rLabel").textContent = d.predicted_label; |
| $("#rTime").textContent = d.took_ms + " ms"; |
| $("#rEngine").textContent = d.engine; |
| $("#rEngine").title = d.engine; |
|
|
| |
| const ring = $("#rRing"); |
| const arc = $(".ring-fg", ring); |
| ring.classList.toggle("low", !d.confident); |
| $("#rConf").textContent = Math.round(d.confidence * 100) + "%"; |
| arc.style.strokeDasharray = RING_C; |
| arc.style.strokeDashoffset = RING_C; |
| requestAnimationFrame(() => |
| requestAnimationFrame(() => { |
| arc.style.strokeDashoffset = RING_C * (1 - Math.min(d.confidence, 1)); |
| }) |
| ); |
|
|
| const t = d.translation; |
| |
| |
| |
| const shaky = t.detection_confidence > 0 && t.detection_confidence < 0.6 ? " · uncertain" : ""; |
| const lang = `${t.source_lang_name} (${t.source_lang})${shaky}`; |
| $("#rLang").textContent = lang + (t.was_translated ? " → EN" : ""); |
| $("#rLang").title = t.was_translated ? `${lang}, translated to English` : lang; |
|
|
| const warn = $("#rWarn"); |
| if (!d.confident) { |
| $("#rWarnText").textContent = |
| `Low confidence — the top score of ${(d.confidence * 100).toFixed(1)}% is below the ` + |
| `${(d.threshold * 100).toFixed(0)}% threshold. Treat this as unclassified, or add a ` + |
| `label that covers this kind of complaint.`; |
| warn.classList.remove("hidden"); |
| } else { |
| warn.classList.add("hidden"); |
| } |
|
|
| $("#rOriginal").textContent = t.original_text; |
| $("#rTranslated").textContent = t.translated_text; |
| const note = $("#rNote"); |
| note.textContent = t.note || ""; |
| note.classList.toggle("hidden", !t.note); |
| $("#rTransBox").open = t.was_translated; |
|
|
| |
| const max = Math.max(...d.scores.map((s) => s.score), 0.0001); |
| $("#rScores").innerHTML = d.scores |
| .map((s, i) => ` |
| <div class="score-row ${i === 0 ? "top" : ""}" style="--h: ${hueOf(s.label)}"> |
| <span class="n" title="${escapeHtml(s.label)}">${escapeHtml(s.label)}</span> |
| <span class="bar"><span data-w="${(s.score / max * 100).toFixed(1)}%"></span></span> |
| <span class="v">${(s.score * 100).toFixed(1)}%</span> |
| </div>`) |
| .join(""); |
|
|
| |
| requestAnimationFrame(() => |
| $$("#rScores .bar > span").forEach((el, i) => { |
| setTimeout(() => { el.style.width = el.dataset.w; }, 40 + i * 35); |
| }) |
| ); |
| } |
| }); |
|
|