fix: smart tooltips that flip above/below based on viewport position

Replace static CSS ::after tooltips with JS-powered smart tooltips.
Tooltips now detect available space above/below and flip accordingly,
and clamp horizontally to viewport bounds. Hide on scroll.
This commit is contained in:
Rene Fichtmueller 2026-04-02 13:03:51 +02:00
parent 59a3e97207
commit a893817dc7

View File

@ -378,25 +378,27 @@
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
/* === TOOLTIPS === */
.tip { position: relative; cursor: help; }
.tip::after {
content: attr(data-tip);
position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
/* Smart tooltip — positioned by JS, see initSmartTooltips() */
.tip { cursor: help; }
#smart-tip {
position: fixed; z-index: 9999; pointer-events: none;
background: var(--surface-dark); color: #e0e0e0;
border: 1px solid rgba(255,255,255,0.1);
padding: 0.6rem 0.85rem; border-radius: var(--radius-md);
border: 1px solid rgba(255,255,255,0.12);
padding: 0.55rem 0.85rem; border-radius: var(--radius-md);
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
white-space: normal; width: max-content; max-width: 300px;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
opacity: 0; transition: opacity 0.15s;
}
.tip::before {
content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%);
border: 6px solid transparent; border-top-color: var(--surface-dark);
opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501;
#smart-tip.visible { opacity: 1; }
#smart-tip-arrow {
position: fixed; z-index: 9998; pointer-events: none;
width: 0; height: 0;
opacity: 0; transition: opacity 0.15s;
}
.tip:hover::after, .tip:hover::before { opacity: 1; }
th.tip::after { left: 0; transform: none; }
#smart-tip-arrow.visible { opacity: 1; }
#smart-tip-arrow.up { border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid var(--surface-dark); }
#smart-tip-arrow.down { border-left: 6px solid transparent; border-right: 6px solid transparent; border-bottom: 6px solid var(--surface-dark); }
/* === DETAIL PANEL === */
.panel {
@ -766,6 +768,7 @@
<div class="tab" data-tab="finder">Finder</div>
<div class="tab" data-tab="blog">Blog Engine</div>
<div class="tab" data-tab="procurement">Procurement Intelligence</div>
<div class="tab" data-tab="crawlers">🕷 Crawler Intelligence</div>
</div>
<div class="main">
@ -1071,6 +1074,75 @@
</div><!-- end tab-procurement -->
<!-- CRAWLER INTELLIGENCE -->
<div id="tab-crawlers" class="hidden fade-in">
<h2 style="margin-bottom:1.25rem;font-size:1.1rem;font-weight:700">🕷 Crawler Intelligence</h2>
<!-- Summary Cards -->
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem">
<div class="stat-card">
<div class="stat-icon blue">📦</div>
<div class="stat-label">Transceivers in DB</div>
<div class="stat-val" id="cr-transceivers"></div>
</div>
<div class="stat-card">
<div class="stat-icon green">💶</div>
<div class="stat-label">Price Records</div>
<div class="stat-val" id="cr-prices"></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">🏪</div>
<div class="stat-label">Vendors Tracked</div>
<div class="stat-val" id="cr-vendors"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">📰</div>
<div class="stat-label">News Articles</div>
<div class="stat-val" id="cr-news"></div>
</div>
</div>
<div class="grid mb" style="grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:2rem">
<div class="stat-card">
<div class="stat-icon green">🧠</div>
<div class="stat-label">KB Entries</div>
<div class="stat-val" id="cr-kb"></div>
</div>
<div class="stat-card">
<div class="stat-icon blue">💾</div>
<div class="stat-label">DB Size</div>
<div class="stat-val" id="cr-dbsize"></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div class="stat-label">Active Scrapers</div>
<div class="stat-val" id="cr-active"></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">🕐</div>
<div class="stat-label">Last Price Update</div>
<div class="stat-val" style="font-size:0.8rem" id="cr-lastprice"></div>
</div>
</div>
<!-- Scraper Status List -->
<div style="margin-bottom:2rem">
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:1rem;color:var(--text-bright)">Scraper Status</h3>
<div id="cr-scraper-list"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
<!-- LLM Hot Topics -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem">
<div>
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:0.75rem;color:var(--text-bright)">🔥 LLM Hot Topics</h3>
<div id="cr-topics"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
<div>
<h3 style="font-size:0.9rem;font-weight:700;margin-bottom:0.75rem;color:var(--text-bright)">📚 Knowledge Base (Learned)</h3>
<div id="cr-kb-entries"><div style="color:var(--text-dim)">Loading…</div></div>
</div>
</div>
</div><!-- end tab-crawlers -->
</div>
</div><!-- .app -->
@ -1410,6 +1482,7 @@ function goToTab(tabName) {
if (tabName === 'news') loadNews();
if (tabName === 'blog') loadBlogDrafts();
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
if (tabName === 'crawlers') loadCrawlerStatus();
if (tabName === 'procurement') loadProcurement();
}
@ -3451,6 +3524,175 @@ async function loadProcLifecycle() {
// INIT
loadOverview();
loadChangelog();
// ── CRAWLER INTELLIGENCE ────────────────────────────────────────────
async function loadCrawlerStatus() {
var status = null;
var insights = null;
try {
var r = await fetch('/api/scrapers/status', { headers: { 'Authorization': 'Bearer ' + token } });
status = await r.json();
} catch(e) {}
try {
var r2 = await fetch('/api/scrapers/llm-insights', { headers: { 'Authorization': 'Bearer ' + token } });
insights = await r2.json();
} catch(e) {}
// DB summary cards
var db = (status && status.database) || {};
var sc = (status && status.scrapers) || {};
var pr = (status && status.pricing) || {};
document.getElementById('cr-transceivers').textContent = db.transceivers != null ? db.transceivers.toLocaleString() : '—';
document.getElementById('cr-prices').textContent = pr.total_prices != null ? pr.total_prices.toLocaleString() : '—';
document.getElementById('cr-vendors').textContent = db.vendors != null ? db.vendors.toLocaleString() : '—';
document.getElementById('cr-news').textContent = db.news_articles != null ? db.news_articles.toLocaleString() : '—';
document.getElementById('cr-kb').textContent = db.knowledge_base_entries != null ? db.knowledge_base_entries.toLocaleString() : '—';
document.getElementById('cr-dbsize').textContent = db.size || '—';
document.getElementById('cr-active').textContent = sc.active != null ? sc.active + ' / ' + sc.total : '—';
document.getElementById('cr-lastprice').textContent = pr.last_update ? new Date(pr.last_update).toLocaleString('de-DE') : '—';
// Scraper list
var list = (sc.list || []);
var categories = ['vendor','pricing','intelligence'];
var catLabel = { vendor: '🏪 Vendor Scrapers', pricing: '💶 Pricing Scrapers', intelligence: '🧠 Intelligence Scrapers' };
var html = '';
for (var cat of categories) {
var items = list.filter(function(s) { return s.category === cat; });
if (!items.length) continue;
html += '<div style="margin-bottom:1.5rem"><div style="font-size:0.75rem;font-weight:700;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-bottom:0.6rem">' + catLabel[cat] + '</div>';
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.5rem">';
for (var s of items) {
var dot = s.status === 'active' ? '#22c55e' : '#64748b';
var lastRun = s.lastRun ? new Date(s.lastRun).toLocaleString('de-DE') : 'Never';
var firstSeen = s.firstSeen ? new Date(s.firstSeen).toLocaleDateString('de-DE') : '—';
html += '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;display:flex;gap:0.75rem;align-items:flex-start">'
+ '<div style="width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-top:4px;flex-shrink:0;box-shadow:0 0 6px ' + dot + '"></div>'
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-weight:700;font-size:0.82rem;color:var(--text-bright)">' + esc(s.label) + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:2px">'
+ (s.records ? '<span style="color:var(--blue);font-weight:600">' + s.records.toLocaleString() + ' records</span> · ' : '<span style="color:#64748b">0 records</span> · ')
+ 'Last: ' + lastRun
+ '</div>'
+ '<div style="font-size:0.68rem;color:var(--text-dim)">First seen: ' + firstSeen + '</div>'
+ '</div></div>';
}
html += '</div></div>';
}
document.getElementById('cr-scraper-list').innerHTML = html || '<div style="color:var(--text-dim)">No scraper data available.</div>';
// LLM Insights — Hot Topics
var topics = (insights && insights.hotTopics) || [];
var topicsHtml = topics.length ? topics.map(function(t) {
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;margin-bottom:0.5rem">'
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem">'
+ '<div style="font-weight:700;font-size:0.82rem;color:var(--text-bright);flex:1">' + esc(t.title || '') + '</div>'
+ (t.trend_score != null ? '<div style="font-size:0.7rem;background:rgba(59,130,246,0.15);color:var(--blue);border-radius:4px;padding:2px 6px;white-space:nowrap">Score: ' + t.trend_score + '</div>' : '')
+ '</div>'
+ (t.summary ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:4px;line-height:1.5">' + esc(t.summary.substring(0,200)) + (t.summary.length > 200 ? '…' : '') + '</div>' : '')
+ '<div style="font-size:0.68rem;color:var(--text-dim);margin-top:4px">' + esc(t.source || '') + (t.published_at ? ' · ' + new Date(t.published_at).toLocaleDateString('de-DE') : '') + '</div>'
+ '</div>';
}).join('') : '<div style="color:var(--text-dim);padding:1rem">No LLM insights yet — run scrapers first.</div>';
document.getElementById('cr-topics').innerHTML = topicsHtml;
// Knowledge Base entries
var kb = (insights && insights.knowledgeBase) || [];
var kbHtml = kb.length ? '<table style="width:100%;border-collapse:collapse;font-size:0.78rem"><thead><tr style="background:var(--surface2)">'
+ '<th style="padding:0.5rem;text-align:left;color:var(--text-dim)">Title</th>'
+ '<th style="padding:0.5rem;text-align:left;color:var(--text-dim)">Category</th>'
+ '<th style="padding:0.5rem;text-align:right;color:var(--text-dim)">Confidence</th>'
+ '</tr></thead><tbody>'
+ kb.map(function(k) {
return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:0.5rem;color:var(--text-bright)">' + esc(k.title || '') + '</td>'
+ '<td style="padding:0.5rem;color:var(--text-dim)">' + esc(k.category || '—') + '</td>'
+ '<td style="padding:0.5rem;text-align:right;color:var(--blue)">' + (k.confidence_score != null ? k.confidence_score + '%' : '—') + '</td>'
+ '</tr>';
}).join('')
+ '</tbody></table>'
: '<div style="color:var(--text-dim);padding:1rem">Knowledge base is empty — crawler LLM hasn\'t learned yet.</div>';
document.getElementById('cr-kb-entries').innerHTML = kbHtml;
}
/* ── Smart Tooltips ─────────────────────────────────────────────────────── */
function initSmartTooltips() {
var tip = document.createElement('div');
tip.id = 'smart-tip';
document.body.appendChild(tip);
var arrow = document.createElement('div');
arrow.id = 'smart-tip-arrow';
document.body.appendChild(arrow);
var hideTimer = null;
function showTip(el) {
var text = el.getAttribute('data-tip');
if (!text) return;
clearTimeout(hideTimer);
tip.textContent = text;
tip.classList.remove('visible');
arrow.classList.remove('visible', 'up', 'down');
// Measure after next frame so width/height are known
requestAnimationFrame(function() {
var rect = el.getBoundingClientRect();
var tw = tip.offsetWidth;
var th = tip.offsetHeight;
var vw = window.innerWidth;
var vh = window.innerHeight;
var GAP = 10;
// Prefer above, flip below if not enough space
var spaceAbove = rect.top;
var spaceBelow = vh - rect.bottom;
var showBelow = spaceAbove < th + GAP + 20 && spaceBelow > th + GAP + 20;
// Horizontal: center on element, clamp to viewport
var left = rect.left + rect.width / 2 - tw / 2;
left = Math.max(8, Math.min(left, vw - tw - 8));
var top;
if (showBelow) {
top = rect.bottom + GAP;
arrow.style.top = (rect.bottom + 2) + 'px';
arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px';
arrow.className = 'down visible';
} else {
top = rect.top - th - GAP;
arrow.style.top = (rect.top - GAP + 2) + 'px';
arrow.style.left = (rect.left + rect.width / 2 - 6) + 'px';
arrow.className = 'up visible';
}
tip.style.left = left + 'px';
tip.style.top = top + 'px';
tip.classList.add('visible');
});
}
function hideTip() {
tip.classList.remove('visible');
arrow.classList.remove('visible');
}
// Delegate: attach once to body, handle all .tip elements
document.body.addEventListener('mouseenter', function(e) {
var el = e.target.closest ? e.target.closest('[data-tip]') : null;
if (el) showTip(el);
}, true);
document.body.addEventListener('mouseleave', function(e) {
var el = e.target.closest ? e.target.closest('[data-tip]') : null;
if (el) hideTimer = setTimeout(hideTip, 100);
}, true);
// Hide on scroll so tooltip doesn't drift
document.addEventListener('scroll', hideTip, true);
}
document.addEventListener('DOMContentLoaded', initSmartTooltips);
</script>
<script src="/dashboard/hot-topics.js"></script>
</body>