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:
parent
fe81b27248
commit
6a89b5468b
@ -378,25 +378,27 @@
|
|||||||
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
|
.hype-fill { height: 100%; border-radius: 3px; transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); }
|
||||||
|
|
||||||
/* === TOOLTIPS === */
|
/* === TOOLTIPS === */
|
||||||
.tip { position: relative; cursor: help; }
|
/* Smart tooltip — positioned by JS, see initSmartTooltips() */
|
||||||
.tip::after {
|
.tip { cursor: help; }
|
||||||
content: attr(data-tip);
|
#smart-tip {
|
||||||
position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
|
position: fixed; z-index: 9999; pointer-events: none;
|
||||||
background: var(--surface-dark); color: #e0e0e0;
|
background: var(--surface-dark); color: #e0e0e0;
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
padding: 0.6rem 0.85rem; border-radius: var(--radius-md);
|
padding: 0.55rem 0.85rem; border-radius: var(--radius-md);
|
||||||
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
|
font-size: 0.72rem; line-height: 1.5; font-weight: 400;
|
||||||
white-space: normal; width: max-content; max-width: 300px;
|
white-space: normal; width: max-content; max-width: 300px;
|
||||||
opacity: 0; pointer-events: none; transition: opacity 0.2s;
|
box-shadow: 0 8px 32px rgba(0,0,0,0.35);
|
||||||
z-index: 500; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
opacity: 0; transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.tip::before {
|
#smart-tip.visible { opacity: 1; }
|
||||||
content: ''; position: absolute; bottom: calc(100% + 5px); left: 50%; transform: translateX(-50%);
|
#smart-tip-arrow {
|
||||||
border: 6px solid transparent; border-top-color: var(--surface-dark);
|
position: fixed; z-index: 9998; pointer-events: none;
|
||||||
opacity: 0; pointer-events: none; transition: opacity 0.2s; z-index: 501;
|
width: 0; height: 0;
|
||||||
|
opacity: 0; transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.tip:hover::after, .tip:hover::before { opacity: 1; }
|
#smart-tip-arrow.visible { opacity: 1; }
|
||||||
th.tip::after { left: 0; transform: none; }
|
#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 === */
|
/* === DETAIL PANEL === */
|
||||||
.panel {
|
.panel {
|
||||||
@ -766,6 +768,7 @@
|
|||||||
<div class="tab" data-tab="finder">Finder</div>
|
<div class="tab" data-tab="finder">Finder</div>
|
||||||
<div class="tab" data-tab="blog">Blog Engine</div>
|
<div class="tab" data-tab="blog">Blog Engine</div>
|
||||||
<div class="tab" data-tab="procurement">Procurement Intelligence</div>
|
<div class="tab" data-tab="procurement">Procurement Intelligence</div>
|
||||||
|
<div class="tab" data-tab="crawlers">🕷 Crawler Intelligence</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
@ -1071,6 +1074,75 @@
|
|||||||
|
|
||||||
</div><!-- end tab-procurement -->
|
</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>
|
||||||
|
|
||||||
</div><!-- .app -->
|
</div><!-- .app -->
|
||||||
@ -1410,6 +1482,7 @@ function goToTab(tabName) {
|
|||||||
if (tabName === 'news') loadNews();
|
if (tabName === 'news') loadNews();
|
||||||
if (tabName === 'blog') loadBlogDrafts();
|
if (tabName === 'blog') loadBlogDrafts();
|
||||||
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
|
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
|
||||||
|
if (tabName === 'crawlers') loadCrawlerStatus();
|
||||||
if (tabName === 'procurement') loadProcurement();
|
if (tabName === 'procurement') loadProcurement();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3451,6 +3524,175 @@ async function loadProcLifecycle() {
|
|||||||
// INIT
|
// INIT
|
||||||
loadOverview();
|
loadOverview();
|
||||||
loadChangelog();
|
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>
|
||||||
<script src="/dashboard/hot-topics.js"></script>
|
<script src="/dashboard/hot-topics.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user