inference / static /index.html
user
Add inference space with browser-based emotion flow prediction and training
443fc75
Raw
History Blame Contribute Delete
23.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Human Essence · Inference</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg-primary:#08080d;--bg-secondary:#0e0e16;--bg-tertiary:#16161f;--bg-card:#111119;--text-primary:#e0e0e8;--text-secondary:#6b6b80;--text-muted:#44445a;--accent:#6366f1;--border:#1e1e2e;--border-subtle:#181828}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg-primary);color:var(--text-primary);min-height:100vh}
.container{max-width:1200px;margin:0 auto;padding:16px;display:flex;flex-direction:column;gap:12px}
.header{display:flex;justify-content:space-between;align-items:center;padding:12px 18px;background:var(--bg-secondary);border-radius:12px;border:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;gap:10px}
.header-left{display:flex;align-items:center;gap:10px}
.header-icon{width:32px;height:32px;border-radius:8px;background:linear-gradient(135deg,#6366f1,#8b5cf6);display:flex;align-items:center;justify-content:center;font-size:.9rem}
.header h1{font-size:1.2rem;font-weight:600;background:linear-gradient(135deg,#c7c8ff,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-.02em}
.header-controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.btn{padding:7px 14px;border:none;border-radius:7px;cursor:pointer;font-size:.8rem;font-weight:500;transition:all .2s ease;font-family:inherit;display:flex;align-items:center;gap:5px;white-space:nowrap}
.btn-primary{background:linear-gradient(135deg,#6366f1,#7c3aed);color:#fff}
.btn-primary:hover{background:linear-gradient(135deg,#5558e3,#6d28d9);transform:translateY(-1px)}
.btn-secondary{background:var(--bg-tertiary);color:var(--text-primary);border:1px solid var(--border)}
.btn-secondary:hover{background:#1e1e2e}
.btn-success{background:linear-gradient(135deg,#059669,#10b981);color:#fff}
.btn-success:hover{background:linear-gradient(135deg,#047857,#059669);transform:translateY(-1px)}
.btn-danger{background:rgba(127,29,29,.5);color:#fca5a5;border:1px solid rgba(153,27,27,.5)}
.btn-danger:hover{background:rgba(153,27,27,.6)}
.editor-section{flex:1;display:flex;flex-direction:column;min-height:160px;gap:8px}
.editor-wrapper{flex:1;position:relative;border-radius:14px;border:1px solid var(--border);background:var(--bg-card);overflow:hidden;transition:border-color .4s ease,box-shadow .4s ease}
.editor-wrapper:focus-within{border-color:rgba(99,102,241,.25)}
.editor-stats{position:absolute;bottom:0;left:0;right:0;padding:6px 20px;display:flex;justify-content:space-between;align-items:center;font-size:.72rem;color:var(--text-muted);background:linear-gradient(to top,var(--bg-card) 70%,transparent);z-index:3;pointer-events:none;user-select:none}
#editorOverlay{position:absolute;top:0;left:0;right:0;bottom:0;padding:22px 22px 36px;font-size:1.05rem;line-height:1.75;font-family:inherit;color:var(--text-primary);white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;overflow-y:auto;pointer-events:none;user-select:none;z-index:1;letter-spacing:.01em}
#editorOverlay .word{padding:1px 2px;margin:0 -2px;border-radius:3px;transition:background-color .2s ease}
#editorOverlay .word.has-emotion{border-bottom:1.5px solid currentColor}
.placeholder-text{color:var(--text-muted)!important;font-style:italic;font-weight:300}
#editor{width:100%;height:100%;background:transparent;color:transparent;caret-color:#9194f8;border:none;padding:22px 22px 36px;font-size:1.05rem;line-height:1.75;font-family:inherit;resize:none;outline:none;position:absolute;top:0;left:0;z-index:2;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;letter-spacing:.01em}
#editor::selection{background:rgba(99,102,241,.3)}
#editor::-webkit-scrollbar,#editorOverlay::-webkit-scrollbar{width:5px}
#editor::-webkit-scrollbar-track,#editorOverlay::-webkit-scrollbar-track{background:transparent}
#editor::-webkit-scrollbar-thumb,#editorOverlay::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.loading-bar{display:none;align-items:center;gap:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:7px;font-size:.8rem;color:var(--text-secondary);border:1px solid var(--border-subtle)}
.loading-bar.active{display:flex}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.timeline-section{background:var(--bg-secondary);border-radius:12px;border:1px solid var(--border);padding:14px 18px;flex-shrink:0;display:none}
.timeline-section.active{display:block}
.timeline-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:10px}
.timeline-title{font-size:.9rem;font-weight:600;display:flex;align-items:center;gap:7px;user-select:none}
.timeline-title-icon{font-size:.85rem;opacity:.6}
.timeline-canvas-container{position:relative;background:var(--bg-tertiary);border-radius:8px;overflow:hidden;border:1px solid var(--border-subtle)}
#timelineCanvas{display:block;width:100%}
.legend-bar{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border-subtle)}
.legend-item{display:flex;align-items:center;gap:6px;font-size:.78rem;color:var(--text-secondary);font-weight:500}
.legend-color{width:12px;height:12px;border-radius:4px}
.tooltip{display:none;position:fixed;background:rgba(14,14,22,.95);color:#e8e8f0;padding:6px 11px;border-radius:7px;font-size:.73rem;pointer-events:none;z-index:100;border:1px solid #2a2a3a;box-shadow:0 6px 20px rgba(0,0,0,.5);font-family:'Inter',sans-serif;max-width:260px;line-height:1.4}
.model-badge{font-size:.7rem;padding:3px 8px;border-radius:12px;background:rgba(99,102,241,.1);color:#a5a8ff;border:1px solid rgba(99,102,241,.15)}
.stats-grid{display:none;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px}
.stats-grid.active{display:grid}
.stat-card{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;text-align:center}
.stat-value{font-size:1.3rem;font-weight:700;color:#c7c8ff}
.stat-label{font-size:.68rem;color:var(--text-secondary);margin-top:2px;text-transform:uppercase;letter-spacing:.04em}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<div class="header-icon">&#10022;</div>
<h1>Human Essence &middot; Inference</h1>
</div>
<div class="header-controls">
<span class="model-badge" id="modelStatus">Loading&hellip;</span>
<button class="btn btn-success" onclick="trainModel()" id="trainBtn">&#9632; Train Model</button>
<button class="btn btn-secondary" onclick="clearAll()">&#10005; Clear</button>
</div>
</div>
<div class="stats-grid" id="statsGrid"></div>
<div class="editor-section">
<div class="editor-wrapper" id="editorWrapper">
<div id="editorOverlay"><span class="placeholder-text">Type or paste text here&hellip;</span></div>
<textarea id="editor"></textarea>
</div>
<div class="loading-bar" id="loadingBar">
<div class="spinner"></div>
<span>Running inference&hellip;</span>
</div>
</div>
<div class="timeline-section" id="timelineSection">
<div class="timeline-header">
<div class="timeline-title">
<span class="timeline-title-icon">&#9670;</span>
Emotional Flow
</div>
</div>
<div class="timeline-canvas-container">
<canvas id="timelineCanvas"></canvas>
</div>
<div class="legend-bar" id="legendBar"></div>
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<script>
var modelData = null;
var loaded = false;
var palette = [
'#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316',
'#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7',
'#14b8a6','#f472b6','#fb923c','#a3e635','#818cf8',
'#c084fc','#f9a8d4','#fdba74','#bef264','#67e8f9',
'#7dd3fc','#c4b5fd','#f0abfc','#d4d4d8'
];
var labelColor = {};
function colorFor(label) {
if (!labelColor[label]) {
labelColor[label] = palette[Object.keys(labelColor).length % palette.length];
}
return labelColor[label];
}
function hexToRgb(hex) {
var m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (m) return {r:parseInt(m[1],16),g:parseInt(m[2],16),b:parseInt(m[3],16)};
return {r:0,g:0,b:0};
}
function tokenize(text) {
return text.match(/\S+/g) || [];
}
function tokenFeatures(tokens, i) {
var token = tokens[i];
var lower = token.toLowerCase();
var clean = lower.replace(/[^a-z0-9']+/g, '');
var prev = i > 0 ? tokens[i-1].toLowerCase() : '<START>';
var next = i + 1 < tokens.length ? tokens[i+1].toLowerCase() : '<END>';
var n = Math.max(tokens.length - 1, 1);
return {
'word': lower,
'clean_word': clean || '<PUNCT>',
'prev_word': prev,
'next_word': next,
'prefix2': clean.slice(0,2),
'prefix3': clean.slice(0,3),
'suffix2': clean.slice(-2),
'suffix3': clean.slice(-3),
'word_len': token.length,
'position_norm': i / n,
'position_sin': Math.sin((i / n) * Math.PI),
'is_first': i === 0 ? 1 : 0,
'is_last': i === tokens.length - 1 ? 1 : 0,
'is_title': /^[A-Z]/.test(token) && /[a-z]/.test(token) ? 1 : 0,
'is_upper': token === token.toUpperCase() && /[A-Z]/.test(token) ? 1 : 0,
'has_digit': /\d/.test(token) ? 1 : 0,
'has_punct': /[^a-zA-Z0-9]/.test(token) ? 1 : 0,
'ends_period': token.endsWith('.') ? 1 : 0,
'ends_question': token.endsWith('?') ? 1 : 0,
'ends_exclaim': token.endsWith('!') ? 1 : 0,
'contains_ai': /ai/.test(clean) ? 1 : 0
};
}
function predict(text) {
if (!loaded) return null;
var tokens = tokenize(text);
if (tokens.length === 0) return {tokens:[], predictions:[], labels:[]};
var vocab = modelData.vocab;
var nFeatures = modelData.n_features;
var nLabels = modelData.labels.length;
var coefs = modelData.coefs;
var intercepts = modelData.intercepts;
var X = [];
for (var i = 0; i < tokens.length; i++) {
var feats = tokenFeatures(tokens, i);
var vec = new Float64Array(nFeatures);
var keys = Object.keys(feats);
for (var ki = 0; ki < keys.length; ki++) {
var key = keys[ki];
var val = feats[key];
var vocabKey = (typeof val === 'string') ? (key + '=' + val) : key;
var col = vocab[vocabKey];
if (col !== undefined) vec[col] = typeof val === 'number' ? val : 1.0;
}
X.push(vec);
}
var predictions = new Array(tokens.length);
for (i = 0; i < tokens.length; i++) {
var preds = new Float64Array(nLabels);
for (var j = 0; j < nLabels; j++) {
var val = intercepts[j];
var row = X[i];
var c = coefs[j];
for (var k = 0; k < nFeatures; k++) {
if (row[k] !== 0) val += row[k] * c[k];
}
preds[j] = Math.max(0, Math.min(1, val));
}
predictions[i] = preds;
}
return {tokens:tokens, predictions:predictions, labels:modelData.labels};
}
function toFlow(result, threshold) {
if (threshold === undefined) threshold = 0.15;
var flow = [];
var nTokens = result.tokens.length;
for (var j = 0; j < result.labels.length; j++) {
var label = result.labels[j];
var values = [];
for (var i = 0; i < nTokens; i++) values.push(result.predictions[i][j]);
var active = values.map(function(v) { return v >= threshold; });
if (!active.some(function(a) { return a; })) continue;
var curves = [];
i = 0;
while (i < nTokens) {
if (!active[i]) { i++; continue; }
var start = i;
while (i + 1 < nTokens && active[i + 1]) i++;
var end = i;
var segment = values.slice(start, end + 1);
var peak = Math.max.apply(null, segment);
curves.push({start_word:start,end_word:end,start_intensity:Math.round(values[start]*10000)/10000,peak_intensity:Math.round(peak*10000)/10000,end_intensity:Math.round(values[end]*10000)/10000});
i++;
}
if (curves.length) flow.push({label:label,curves:curves});
}
return flow;
}
function updateOverlay(result, flow) {
var overlay = document.getElementById('editorOverlay');
var editor = document.getElementById('editor');
var text = editor.value;
var tokens = result.tokens;
var nTokens = tokens.length;
var emotionData = {};
if (flow.length > 0) {
for (var i = 0; i < nTokens; i++) {
var active = [];
for (var fi = 0; fi < flow.length; fi++) {
var emo = flow[fi];
var lbl = emo.label;
var col = colorFor(lbl);
for (var ci = 0; ci < emo.curves.length; ci++) {
var curve = emo.curves[ci];
if (i >= curve.start_word && i <= curve.end_word) {
var intensity = curve.peak_intensity;
if (curve.start_word === curve.end_word) {active.push({label:lbl,color:col,intensity:intensity});}
else if (i === curve.start_word) {active.push({label:lbl,color:col,intensity:curve.start_intensity});}
else if (i === curve.end_word) {active.push({label:lbl,color:col,intensity:curve.end_intensity});}
else {active.push({label:lbl,color:col,intensity:intensity});}
}
}
}
if (active.length > 0) emotionData[i] = active;
}
}
var words = tokens;
var html = '';
var lastIdx = 0;
for (i = 0; i < words.length; i++) {
var w = words[i];
var idx = text.indexOf(w, lastIdx);
if (idx > lastIdx) {
var gap = text.slice(lastIdx, idx).replace(/ /g,'&nbsp;').replace(/\n/g,'<br>').replace(/\t/g,'&nbsp;&nbsp;&nbsp;&nbsp;');
html += gap;
}
var emos = emotionData[i];
var style = '';
var cls = 'word';
if (emos && emos.length) {
var r=0,g=0,b=0,total=0;
for (var ei = 0; ei < emos.length; ei++) {
var rgb = hexToRgb(emos[ei].color);
r += rgb.r * emos[ei].intensity;
g += rgb.g * emos[ei].intensity;
b += rgb.b * emos[ei].intensity;
total += emos[ei].intensity;
}
if (total > 0) {
r = Math.round(r/total); g = Math.round(g/total); b = Math.round(b/total);
var maxI = 0;
for (ei = 0; ei < emos.length; ei++) {if (emos[ei].intensity > maxI) maxI = emos[ei].intensity;}
var bgA = Math.max(0.06, maxI * 0.25);
style += 'background:rgba('+r+','+g+','+b+','+bgA+');';
var blend = Math.min(0.45, maxI * 0.4);
style += 'color:rgb('+Math.round(224*(1-blend)+r*blend)+','+Math.round(224*(1-blend)+g*blend)+','+Math.round(232*(1-blend)+b*blend)+');';
style += 'border-bottom-color:rgba('+r+','+g+','+b+','+Math.min(0.5,maxI*0.4)+');';
cls += ' has-emotion';
}
}
var escaped = w.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
html += '<span class="'+cls+'" style="'+style+'">'+escaped+'</span>';
lastIdx = idx + w.length;
}
var trailing = text.slice(lastIdx).replace(/ /g,'&nbsp;').replace(/\n/g,'<br>').replace(/\t/g,'&nbsp;&nbsp;&nbsp;&nbsp;');
html += trailing;
overlay.innerHTML = html;
}
var canvas = document.getElementById('timelineCanvas');
var ctx = canvas.getContext('2d');
var tooltip = document.getElementById('tooltip');
function resizeCanvas() {
var c = canvas.parentElement;
canvas.width = c.clientWidth;
canvas.height = 175;
}
function wordToX(wi, total) {
var pad = 35, w = canvas.width - pad*2;
return pad + (wi/total)*w;
}
function intensityToY(i) {
var pad = 22, h = canvas.height - pad*2;
return pad + h - i*h;
}
function drawTimeline(result, flow) {
resizeCanvas();
ctx.clearRect(0, 0, canvas.width, canvas.height);
var total = result.tokens.length || 1;
for (var i = 0; i <= 1; i += 0.25) {
var y = intensityToY(i);
ctx.strokeStyle = i === 0 ? '#1e1e2e' : '#141420';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(35, y); ctx.lineTo(canvas.width-10, y); ctx.stroke();
ctx.fillStyle = '#2e2e42';
ctx.font = '9px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText((i*100).toFixed(0)+'%', 31, y+3);
}
var step = Math.max(1, Math.floor(total/20));
for (i = 0; i <= total; i += step) {
var x = wordToX(i, total);
ctx.strokeStyle = '#111120';
ctx.beginPath(); ctx.moveTo(x,22); ctx.lineTo(x,canvas.height-22); ctx.stroke();
}
for (var fi = 0; fi < flow.length; fi++) {
var emo = flow[fi];
var color = colorFor(emo.label);
var rgb = hexToRgb(color);
var sorted = emo.curves.slice().sort(function(a,b) { return a.start_word - b.start_word; });
if (!sorted.length) continue;
ctx.beginPath();
var fX = wordToX(sorted[0].start_word, total);
var fY = intensityToY(sorted[0].peak_intensity);
ctx.moveTo(fX, canvas.height-22);
ctx.lineTo(fX, fY);
for (var si = 1; si < sorted.length; si++) {
var sx = wordToX(sorted[si].start_word, total);
var sy = intensityToY(sorted[si].peak_intensity);
var ppx = wordToX(sorted[si-1].start_word, total);
var ppy = intensityToY(sorted[si-1].peak_intensity);
ctx.bezierCurveTo((ppx+sx)/2, ppy, (ppx+sx)/2, sy, sx, sy);
}
var lX = wordToX(sorted[sorted.length-1].start_word, total);
ctx.lineTo(lX, canvas.height-22);
ctx.closePath();
var grad = ctx.createLinearGradient(0,22,0,canvas.height-22);
grad.addColorStop(0, 'rgba('+rgb.r+','+rgb.g+','+rgb.b+',0.1)');
grad.addColorStop(1, 'rgba('+rgb.r+','+rgb.g+','+rgb.b+',0)');
ctx.fillStyle = grad; ctx.fill();
ctx.beginPath();
for (si = 0; si < sorted.length; si++) {
sx = wordToX(sorted[si].start_word, total);
sy = intensityToY(sorted[si].peak_intensity);
if (si === 0) ctx.moveTo(sx, sy);
else { ppx = wordToX(sorted[si-1].start_word, total); ppy = intensityToY(sorted[si-1].peak_intensity); ctx.bezierCurveTo((ppx+sx)/2, ppy, (ppx+sx)/2, sy, sx, sy); }
}
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke();
for (si = 0; si < sorted.length; si++) {
sx = wordToX(sorted[si].start_word, total);
sy = intensityToY(sorted[si].peak_intensity);
ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2);
ctx.fillStyle = color; ctx.fill();
ctx.beginPath(); ctx.arc(sx, sy, 2, 0, Math.PI*2);
ctx.fillStyle = '#fff'; ctx.fill();
}
}
}
function updateLegend(flow) {
var bar = document.getElementById('legendBar');
var html = '';
for (var i = 0; i < flow.length; i++) {
var emo = flow[i];
html += '<div class="legend-item"><span class="legend-color" style="background:'+colorFor(emo.label)+'"></span>'+emo.label+'</div>';
}
bar.innerHTML = html;
}
function runInference() {
var text = document.getElementById('editor').value.trim();
if (!text) { document.getElementById('timelineSection').classList.remove('active'); return; }
if (!loaded) return;
var loadingBar = document.getElementById('loadingBar');
loadingBar.classList.add('active');
setTimeout(function() {
try {
var result = predict(text);
if (!result) return;
var flow = toFlow(result, 0.15);
updateOverlay(result, flow);
drawTimeline(result, flow);
updateLegend(flow);
document.getElementById('timelineSection').classList.add('active');
} finally { loadingBar.classList.remove('active'); }
}, 10);
}
function clearAll() {
document.getElementById('editor').value = '';
document.getElementById('editorOverlay').innerHTML = '<span class="placeholder-text">Type or paste text here&hellip;</span>';
document.getElementById('timelineSection').classList.remove('active');
}
function debounce(fn, ms) {
var timer = null;
return function() {
var args = arguments, self = this;
if (timer) clearTimeout(timer);
timer = setTimeout(function() { fn.apply(self, args); }, ms);
};
}
function showStats(info) {
var grid = document.getElementById('statsGrid');
grid.className = 'stats-grid active';
var items = [
{label:'Rows', value:info.total_rows},
{label:'Usable', value:info.usable_rows},
{label:'Words', value:info.word_examples},
{label:'Curves', value:info.curve_points},
{label:'Emotions', value:info.labels ? info.labels.length : 0},
{label:'MAE', value:info.train_mae ? info.train_mae.toFixed(4) : '-'},
{label:'RMSE', value:info.train_rmse ? info.train_rmse.toFixed(4) : '-'}
];
grid.innerHTML = items.map(function(item) {
return '<div class="stat-card"><div class="stat-value">'+item.value+'</div><div class="stat-label">'+item.label+'</div></div>';
}).join('');
}
function loadModel() {
document.getElementById('modelStatus').textContent = 'Loading&hellip;';
fetch('/api/model_data')
.then(function(res) {
if (!res.ok) throw new Error('No model yet');
return res.json();
})
.then(function(data) {
modelData = data;
loaded = true;
document.getElementById('modelStatus').textContent = '\u2713 '+modelData.labels.length+' emotions';
var ta = document.getElementById('editor');
ta.addEventListener('input', debounce(runInference, 400));
ta.focus();
if (ta.value.trim()) runInference();
})
.catch(function() {
document.getElementById('modelStatus').textContent = 'Need training';
document.getElementById('modelStatus').style.color = '#fbbf24';
var ta = document.getElementById('editor');
ta.addEventListener('input', debounce(runInference, 400));
});
fetch('/api/stats')
.then(function(res) { return res.json(); })
.then(function(stats) {
if (stats.trained) showStats(stats);
})
.catch(function() {});
}
function trainModel() {
var btn = document.getElementById('trainBtn');
btn.disabled = true;
btn.textContent = '\u25A0 Training&hellip;';
document.getElementById('modelStatus').textContent = 'Training&hellip;';
fetch('/api/train', {method:'POST'})
.then(function(res) {
if (!res.ok) throw new Error('Training failed');
return res.json();
})
.then(function(data) {
showStats(data.training_info);
document.getElementById('modelStatus').textContent = '\u2713 '+data.training_info.labels.length+' emotions';
document.getElementById('modelStatus').style.color = '';
// Reload model data
return fetch('/api/model_data');
})
.then(function(res) { return res.json(); })
.then(function(data) {
modelData = data;
loaded = true;
btn.textContent = '\u25A0 Train Model';
btn.disabled = false;
var ta = document.getElementById('editor');
if (ta.value.trim()) runInference();
})
.catch(function(e) {
document.getElementById('modelStatus').textContent = '\u2715 Train error';
document.getElementById('modelStatus').style.color = '#fca5a5';
btn.textContent = '\u25A0 Train Model';
btn.disabled = false;
console.error(e);
});
}
document.getElementById('editor').addEventListener('scroll', function() {
var overlay = document.getElementById('editorOverlay');
overlay.scrollTop = this.scrollTop;
overlay.scrollLeft = this.scrollLeft;
});
window.addEventListener('resize', function() {
if (loaded && document.getElementById('timelineSection').classList.contains('active')) {
var text = document.getElementById('editor').value.trim();
if (text) { var result = predict(text); if (result) drawTimeline(result, toFlow(result, 0.15)); }
}
});
loadModel();
</script>
</body>
</html>