sentiment-analyzer / index.html
Xhenifer's picture
Update index.html
600992a verified
Raw
History Blame Contribute Delete
13.3 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sesotho Sentiment Analysis · Wale Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{
--green-deep:#1E6B45;--green:#2F9463;--green-tint:#E5F5EC;
--orange:#E8871E;--orange-deep:#C1690F;--orange-tint:#FCEEDC;
--rust:#B3401A;--rust-tint:#FBE7DD;--tan:#B08331;--tan-tint:#F7EEDC;
--white:#FFFFFF;--paper:#FBFAF7;--ink:#16231C;--ink-soft:#5B6B62;
--line:#DEE5DF;--radius:10px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;-webkit-font-smoothing:antialiased}
body{min-height:100vh}
.hero{background:var(--green-deep);color:var(--white);position:relative;overflow:hidden;padding:2.75rem 1.5rem 2.25rem;}
.hero::before{content:"";position:absolute;inset:0;background:radial-gradient(circle at 85% -10%,rgba(232,135,30,0.18),transparent 55%),radial-gradient(circle at -5% 110%,rgba(31,92,63,0.55),transparent 50%);pointer-events:none;}
.hero-inner{max-width:640px;margin:0 auto;position:relative}
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-family:'JetBrains Mono',monospace;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:var(--orange);margin-bottom:14px;}
.eyebrow::before{content:"";width:6px;height:6px;background:var(--orange);border-radius:1px;transform:rotate(45deg);flex-shrink:0}
.hero h1{font-family:'Fraunces',serif;font-weight:600;font-size:clamp(28px,5vw,36px);line-height:1.15;letter-spacing:-0.01em;margin-bottom:10px;}
.hero p{font-size:14px;line-height:1.65;color:rgba(255,255,255,0.72);max-width:46ch;}
.blanket-strip{height:14px;width:100%;background:repeating-linear-gradient(135deg,var(--orange) 0px,var(--orange) 10px,transparent 10px,transparent 20px),repeating-linear-gradient(45deg,var(--green) 0px,var(--green) 10px,var(--green-deep) 10px,var(--green-deep) 20px);}
.app{padding:2rem 1.5rem 3rem;max-width:640px;margin:0 auto}
.card{background:var(--white);border:1px solid var(--line);border-radius:var(--radius);padding:1.5rem;margin-bottom:1rem;box-shadow:0 1px 2px rgba(21,61,43,0.04);}
.label{font-family:'JetBrains Mono',monospace;font-size:10.5px;font-weight:500;text-transform:uppercase;letter-spacing:0.09em;color:var(--ink-soft);margin-bottom:9px;}
textarea{width:100%;min-height:96px;resize:vertical;font-size:15px;font-family:'Inter',sans-serif;padding:12px 14px;border-radius:8px;border:1.5px solid var(--line);background:var(--paper);color:var(--ink);line-height:1.6;}
textarea:focus{outline:none;border-color:var(--green)}
textarea::placeholder{color:#9AA69E}
.toggle-row{display:flex;align-items:center;gap:12px;margin-top:14px}
.toggle-label{font-size:13px;color:var(--ink-soft)}
.toggle{position:relative;width:40px;height:23px;cursor:pointer;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.slider-t{position:absolute;inset:0;background:#C7D2CB;border-radius:99px;transition:0.2s}
.slider-t::before{content:'';position:absolute;width:17px;height:17px;left:3px;top:3px;background:var(--white);border-radius:50%;transition:0.2s;box-shadow:0 1px 2px rgba(0,0,0,0.2)}
input:checked+.slider-t{background:var(--orange)}
input:checked+.slider-t::before{transform:translateX(17px)}
.examples-row{display:flex;flex-wrap:wrap;gap:7px;margin-top:9px}
.ex-btn{font-family:'JetBrains Mono',monospace;font-size:11.5px;padding:6px 12px;border-radius:99px;border:1px solid var(--line);background:var(--paper);color:var(--ink-soft);cursor:pointer;transition:0.15s;}
.ex-btn:hover{border-color:var(--green);color:var(--green);background:var(--green-tint)}
.analyze-btn{width:100%;padding:12px;font-size:14.5px;font-weight:600;border-radius:8px;border:none;background:var(--orange);color:var(--white);cursor:pointer;transition:0.15s;margin-top:16px;letter-spacing:0.01em;}
.analyze-btn:hover{background:var(--orange-deep)}
.analyze-btn:disabled{opacity:0.55;cursor:not-allowed}
.loading-msg{font-size:12.5px;color:var(--green-deep);margin-top:12px;padding:10px 12px;background:var(--green-tint);border-radius:8px;border:1px solid #CFE3D8;display:none;line-height:1.5;}
.converter-note{font-size:12.5px;color:var(--green-deep);margin-top:12px;padding:10px 12px;background:var(--green-tint);border-radius:8px;border:1px solid #CFE3D8;display:none;line-height:1.5;}
.error-msg{font-size:13px;color:var(--rust);padding:10px 12px;background:var(--rust-tint);border-radius:8px;border:1px solid #F0C5AE;margin-top:12px;display:none;line-height:1.5;}
.result-card{display:none}
.sentiment-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.1rem}
.sentiment-badge{display:inline-flex;align-items:center;gap:8px;padding:7px 16px;border-radius:99px;font-size:14.5px;font-weight:600;}
.sentiment-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sentiment-meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--ink-soft)}
.bar-row{display:flex;align-items:center;gap:12px;margin-bottom:10px}
.bar-label{font-size:12.5px;color:var(--ink-soft);width:66px;flex-shrink:0}
.bar-track{flex:1;height:7px;background:#EEF1EE;border-radius:99px;overflow:hidden}
.bar-fill{height:100%;border-radius:99px;transition:width 0.6s cubic-bezier(.4,0,.2,1);width:0}
.bar-pct{font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ink-soft);width:40px;text-align:right;flex-shrink:0}
.about{font-size:12px;color:var(--ink-soft);line-height:1.7;border-top:1px solid var(--line);padding-top:1.1rem;margin-top:0.75rem;}
.about strong{color:var(--ink)}
.about a{color:var(--green);text-decoration:none;border-bottom:1px solid #C7D9CD}
.about a:hover{color:var(--orange-deep);border-color:var(--orange)}
.spinner{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,0.4);border-top-color:var(--white);border-radius:50%;animation:spin 0.7s linear infinite;vertical-align:-2px;margin-right:7px;}
@keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
<header class="hero">
<div class="hero-inner">
<div class="eyebrow">Wale Lab · R2I Fellowship</div>
<h1>Sesotho Sentiment Analysis</h1>
<p>Classify Sesotho news headlines as positive, negative, or neutral. Model runs entirely in your browser using Transformers.js.</p>
</div>
</header>
<div class="blanket-strip" aria-hidden="true"></div>
<main class="app">
<div class="card">
<div class="label">Headline</div>
<textarea id="inputText" placeholder="Ngola sehlooho sa Sesotho mona... (type a Sesotho headline)"></textarea>
<div class="toggle-row">
<label class="toggle" title="Apply South African orthography converter">
<input type="checkbox" id="orthoToggle">
<span class="slider-t"></span>
</label>
<span class="toggle-label">Apply South African orthography converter</span>
</div>
<div class="label" style="margin-top:18px;margin-bottom:7px">Examples</div>
<div class="examples-row">
<div class="examples-row">
<button class="ex-btn" data-text="MOTHO O KHAOTSOE BOTONA LE MENOANA A NTSE A PHELA">Negative</button>
<button class="ex-btn" data-text="TONAKHOLO O LOKELA HO BUSA LILEMO TSE HLANO">Positive</button>
<button class="ex-btn" data-text="TSEBISO E POTLAKILENG">Neutral</button>
</div>
</div>
<button class="analyze-btn" id="analyzeBtn" disabled>Loading model...</button>
<div class="loading-msg" id="loadingMsg">Loading model into browser for the first time. This may take 30 to 60 seconds...</div>
<div class="converter-note" id="converterNote"></div>
<div class="error-msg" id="errorMsg"></div>
</div>
<div class="card result-card" id="resultCard">
<div class="sentiment-row">
<div>
<div class="label" style="margin-bottom:6px">Prediction</div>
<div id="sentimentBadge" class="sentiment-badge"></div>
</div>
<div class="sentiment-meta" id="sentimentMeta"></div>
</div>
<div id="bars"></div>
</div>
<div class="about">
<strong>Research.</strong> Sentiment analysis on South African Sesotho, evaluating cross-orthography transfer from Lesotho-standard Sesotho.<br>
<strong>Model.</strong> <a href="https://huggingface.co/Xhenifer/sesotho-sentiment-afriberta" target="_blank" rel="noopener">Xhenifer/sesotho-sentiment-afriberta</a><br>
<strong>Dataset.</strong> Mokhosi et al. (2024), Zenodo DOI 10.5281/zenodo.10531959<br>
<strong>Cohort.</strong> Wale Lab R2I Fellowship, Cohort 3
</div>
</main>
<script type="module">
import { pipeline, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.2/dist/transformers.min.js';
env.allowRemoteModels = true;
env.allowLocalModels = false;
const LABELS = { 0: "Negative", 1: "Neutral", 2: "Positive" };
const LABEL_STYLES = {
"Positive": { badgeBg:"#E5F5EC", badgeFg:"#1E6B45", dot:"#2F9463", bar:"#2F9463" },
"Negative": { badgeBg:"#FBE7DD", badgeFg:"#B3401A", dot:"#B3401A", bar:"#B3401A" },
"Neutral": { badgeBg:"#F7EEDC", badgeFg:"#B08331", dot:"#B08331", bar:"#B08331" }
};
const CONVERTER_RULES = [
[/k'h/gi,"KH"],[/tš/gi,"TSH"],[/\bch/gi,"TJH"],
[/fsh/gi,"FJ"],[/psh/gi,"PJH"],[/\bkh/gi,"KG"],
[/\bli/gi,"DI"],[/\blu/gi,"DU"],[/'n/gi,"NN"],[/'m/gi,"MM"]
];
function applyConverter(text) {
let out = text, changed = false;
CONVERTER_RULES.forEach(([pat,rep]) => {
const next = out.replace(pat, rep);
if (next !== out) changed = true;
out = next;
});
return { text: out, changed };
}
const loadingMsg = document.getElementById("loadingMsg");
const analyzeBtn = document.getElementById("analyzeBtn");
const errorEl = document.getElementById("errorMsg");
const noteEl = document.getElementById("converterNote");
const resultCard = document.getElementById("resultCard");
loadingMsg.style.display = "block";
let classifier;
try {
classifier = await pipeline(
"text-classification",
"Xhenifer/sesotho-sentiment-afriberta",
{ device: "wasm", dtype: "fp32" }
);
loadingMsg.style.display = "none";
analyzeBtn.disabled = false;
analyzeBtn.textContent = "Analyze sentiment";
} catch(e) {
loadingMsg.style.display = "none";
errorEl.textContent = "Failed to load model: " + e.message;
errorEl.style.display = "block";
console.error(e);
}
document.querySelectorAll(".ex-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.getElementById("inputText").value = btn.dataset.text;
});
});
analyzeBtn.addEventListener("click", async () => {
const raw = document.getElementById("inputText").value.trim();
if (!raw || !classifier) return;
const useConverter = document.getElementById("orthoToggle").checked;
errorEl.style.display = "none";
noteEl.style.display = "none";
resultCard.style.display = "none";
analyzeBtn.disabled = true;
analyzeBtn.innerHTML = '<span class="spinner"></span>Analyzing...';
let inputText = raw;
if (useConverter) {
const res = applyConverter(raw);
inputText = res.text;
noteEl.textContent = res.changed
? `Converter applied: "${raw}" → "${inputText}"`
: "Converter applied, no orthographic changes detected.";
noteEl.style.display = "block";
}
try {
const results = await classifier(inputText, { topk: 3 });
const sorted = [...results].sort((a,b) => b.score - a.score);
const top = sorted[0];
const resolveLabel = l => l.includes("LABEL_")
? LABELS[parseInt(l.replace("LABEL_",""))]
: (LABELS[parseInt(l)] ?? l);
const topLabel = resolveLabel(top.label);
const s = LABEL_STYLES[topLabel] || LABEL_STYLES["Neutral"];
const badge = document.getElementById("sentimentBadge");
badge.innerHTML = `<span class="sentiment-dot" style="background:${s.dot}"></span>${topLabel}`;
badge.style.background = s.badgeBg;
badge.style.color = s.badgeFg;
document.getElementById("sentimentMeta").textContent = `${(top.score*100).toFixed(1)}% confidence`;
document.getElementById("bars").innerHTML = sorted.map(item => {
const lbl = resolveLabel(item.label);
const pct = (item.score * 100).toFixed(1);
const c = LABEL_STYLES[lbl] || LABEL_STYLES["Neutral"];
return `<div class="bar-row">
<span class="bar-label">${lbl}</span>
<div class="bar-track"><div class="bar-fill" style="background:${c.bar}"></div></div>
<span class="bar-pct">${pct}%</span>
</div>`;
}).join("");
resultCard.style.display = "block";
requestAnimationFrame(() => {
document.querySelectorAll(".bar-fill").forEach((el,i) => {
el.style.width = (sorted[i].score * 100).toFixed(1) + "%";
});
});
} catch(e) {
errorEl.textContent = "Error: " + e.message;
errorEl.style.display = "block";
console.error(e);
}
analyzeBtn.disabled = false;
analyzeBtn.textContent = "Analyze sentiment";
});
</script>
</body>
</html>