Files
OSINT-Cheat-sheet/Script/SOCMINT-Twitter/templates/analytics.html
T

817 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jieyab89 SOCMINT X — Analytics</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #21253a;
--border: #2a2d3a;
--text: #e8eaf0;
--muted: #8890a4;
--accent: #5865f2;
--accent-bg: #1e2240;
--success: #22c55e;
--success-bg: #052011;
--danger: #ef4444;
--danger-bg: #1c0505;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; min-height: 100vh; }
/* ── Header (same shell as the other 3 pages) ── */
header {
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 100;
}
header h1 { font-size: 14px; font-weight: 600; }
header .sep { color: var(--border); }
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
.hamburger-btn {
margin-left: auto;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--muted);
cursor: pointer;
padding: 5px 9px;
font-size: 16px;
line-height: 1;
transition: all 0.15s;
}
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
.nav-menu {
position: absolute;
top: 100%;
right: 12px;
margin-top: 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 5px;
min-width: 175px;
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
z-index: 200;
display: flex;
flex-direction: column;
gap: 1px;
}
.nav-menu.hidden { display: none; }
.nav-menu a {
padding: 7px 12px;
border-radius: 5px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
transition: all 0.12s;
display: block;
}
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
.back-to-top {
position: fixed;
bottom: 14px;
right: 14px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--surface);
border: 1px solid var(--border);
color: #FFFFFF;
font-size: 17px;
cursor: pointer;
z-index: 251;
box-shadow: 0 4px 14px rgba(0,0,0,0.4);
transition: all 0.15s;
display: none;
align-items: center;
justify-content: center;
}
.back-to-top:hover { border-color: var(--accent); }
.back-to-top.visible { display: flex; }
/* ── Layout ── */
.layout { display: grid; grid-template-columns: 270px 1fr; min-height: calc(100vh - 49px); }
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
/* ── Sidebar (archive picker) ── */
.sidebar { border-right: 1px solid var(--border); background: var(--surface); display: flex; flex-direction: column; }
.sidebar-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.sidebar-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.sidebar-count { font-size: 11px; color: var(--muted); background: var(--bg); padding: 2px 7px; border-radius: 10px; }
.sidebar-search {
width: calc(100% - 32px); margin: 10px 16px 6px; padding: 6px 10px;
background: var(--bg); border: 1px solid var(--border); color: var(--text);
font-family: var(--font); font-size: 12px; border-radius: 6px; transition: border-color 0.15s;
}
.sidebar-search:focus { outline: none; border-color: var(--accent); }
.sidebar-search::placeholder { color: var(--muted); }
.archive-list { overflow-y: auto; flex: 1; padding: 8px; }
.archive-entry { padding: 10px 12px; border-radius: 7px; border: 1px solid transparent; cursor: pointer; transition: all 0.12s; margin-bottom: 4px; }
.archive-entry:hover { background: var(--surface2); border-color: var(--border); }
.archive-entry.active { background: var(--accent-bg); border-color: var(--accent); }
.ae-tool { font-size: 12px; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ae-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.ae-date { font-size: 10.5px; color: var(--muted); opacity: 0.75; margin-top: 2px; }
.ae-stats { font-size: 11px; color: var(--muted); margin-top: 3px; }
.no-archives { padding: 40px 16px; text-align: center; color: var(--muted); font-size: 13px; }
/* ── Main viewer ── */
.viewer { overflow-y: auto; padding: 20px 22px 60px; }
.empty-state { color: var(--muted); font-size: 13px; padding: 80px 20px; text-align: center; }
.section { margin-bottom: 26px; }
.section-title {
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--muted); margin-bottom: 10px; display: flex; align-items: center; gap: 8px;
}
.section-note { font-size: 11px; color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; }
.btn-toolbar {
margin-left: auto;
padding: 5px 12px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 6px;
font-family: var(--font);
font-size: 11px;
font-weight: 500;
text-transform: none;
letter-spacing: 0;
cursor: pointer;
transition: all 0.15s;
}
.btn-toolbar:hover { color: var(--text); border-color: var(--accent); }
/* ── Sentiment breakdown ── */
.sentiment-bar {
display: flex; height: 34px; border-radius: 8px; overflow: hidden;
border: 1px solid var(--border); margin-bottom: 12px;
}
.sentiment-seg { display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--bg); transition: filter 0.15s; }
.sentiment-seg:hover { filter: brightness(1.12); }
.sentiment-seg.seg-pro { background: var(--success); }
.sentiment-seg.seg-neutral { background: var(--muted); }
.sentiment-seg.seg-con { background: var(--danger); }
.sentiment-seg.empty { color: transparent; }
.sentiment-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.stile {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 14px; cursor: pointer; transition: all 0.12s;
}
.stile:hover { border-color: #3a3d50; }
.stile.active { border-color: var(--accent); background: var(--accent-bg); }
.stile-label { font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.stile-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.stile-dot.dot-pro { background: var(--success); }
.stile-dot.dot-neutral { background: var(--muted); }
.stile-dot.dot-con { background: var(--danger); }
.stile-value { font-size: 22px; font-weight: 700; margin-top: 4px; }
.stile-pct { font-size: 11px; color: var(--muted); margin-top: 1px; }
/* ── Cards grid (top users / top engagement) ── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
.user-list { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.user-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid rgba(42,45,58,0.6); }
.user-row:last-child { border-bottom: none; }
.user-rank { font-size: 11px; color: var(--muted); width: 18px; flex-shrink: 0; text-align: right; }
.user-avatar {
width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex-shrink: 0;
border: 1px solid var(--border); background: var(--bg);
}
.user-avatar-fallback { display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: var(--muted); }
.user-identity { flex: 1; min-width: 0; }
.user-name { font-size: 12.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-handle { font-size: 11px; color: var(--muted); }
.user-count { font-size: 12px; color: var(--accent); font-weight: 600; flex-shrink: 0; }
.engagement-list { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.eng-row { padding: 10px 12px; border-bottom: 1px solid rgba(42,45,58,0.6); }
.eng-row:last-child { border-bottom: none; }
.eng-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.eng-handle { font-size: 12px; font-weight: 600; color: var(--text); }
.eng-score { font-size: 11px; color: var(--accent); font-weight: 600; margin-left: auto; white-space: nowrap; }
.eng-text { font-size: 12px; color: var(--text); opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
/* ── Word cloud ── */
.wordcloud {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 18px 20px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px;
}
.wc-word { color: var(--text); font-weight: 600; line-height: 1; white-space: nowrap; cursor: default; transition: color 0.12s; }
.wc-word:hover { color: var(--accent); }
/* ── Sentiment badge (shared by tiles/list) ── */
.sent-badge {
display: inline-block; font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 20px; border: 1px solid; text-transform: uppercase; letter-spacing: 0.03em; flex-shrink: 0;
}
.sent-badge.sent-pro { color: var(--success); border-color: #14532d; background: var(--success-bg); }
.sent-badge.sent-neutral { color: var(--muted); border-color: var(--border); background: var(--bg); }
.sent-badge.sent-con { color: var(--danger); border-color: #7f1d1d; background: var(--danger-bg); }
/* ── Item browser ── */
.item-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.item-search {
padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); color: var(--text);
font-family: var(--font); font-size: 12px; border-radius: 6px; width: 220px;
}
.item-search:focus { outline: none; border-color: var(--accent); }
.item-count { font-size: 11px; color: var(--muted); }
.item-list { display: flex; flex-direction: column; gap: 8px; }
.item-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.item-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.item-author { font-size: 12px; font-weight: 600; color: var(--text); }
.item-score { font-size: 11px; color: var(--muted); margin-left: auto; }
.item-text { font-size: 13px; color: var(--text); word-break: break-word; white-space: pre-wrap; }
.item-matches { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
.match-chip {
font-size: 10px; padding: 1px 7px; border-radius: 10px; border: 1px solid;
}
.match-chip.mc-pro { color: var(--success); border-color: #14532d; background: var(--success-bg); }
.match-chip.mc-con { color: var(--danger); border-color: #7f1d1d; background: var(--danger-bg); }
.spinner {
display: inline-block; width: 13px; height: 13px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: -2px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.method-note {
font-size: 11px; color: var(--muted); line-height: 1.6; background: var(--surface);
border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 22px;
}
/* ── Social network graph ── */
.sna-legend {
display: flex;
align-items: center;
gap: 14px;
font-size: 11px;
color: var(--muted);
margin-bottom: 10px;
flex-wrap: wrap;
}
.sna-legend-item { display: flex; align-items: center; gap: 5px; }
.sna-legend-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.sna-legend-dot.dot-pro { background: var(--success); }
.sna-legend-dot.dot-neutral { background: var(--muted); }
.sna-legend-dot.dot-con { background: var(--danger); }
.sna-wrap {
display: none;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.sna-wrap.visible { display: block; }
.sna-graph {
width: 100%;
height: 480px;
background: var(--bg);
}
.sna-node-info {
display: none;
padding: 12px 16px;
border-top: 1px solid var(--border);
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.sna-node-info.visible { display: flex; }
.sna-node-identity { min-width: 140px; }
.sna-node-name { font-size: 13px; font-weight: 600; color: var(--text); }
.sna-node-handle { font-size: 11px; color: var(--muted); }
.sna-node-stats { display: flex; gap: 6px; flex-wrap: wrap; }
.sna-node-total { font-size: 11px; color: var(--muted); margin-left: auto; }
</style>
</head>
<body>
<header>
<h1>Jieyab89 SOCMINT X</h1>
<span class="sep">/</span>
<span class="sub">Analytics — sentiment clustering, top actors, word frequency</span>
<button class="hamburger-btn" id="navToggle" aria-label="Menu"></button>
<nav id="navMenu" class="nav-menu hidden">
<a href="/">Home</a>
<a href="/graph">Graph</a>
<a href="/archives">Archives</a>
<hr class="nav-divider">
<a href="/analytics" class="current">Analytics</a>
</nav>
</header>
<button id="backToTop" class="back-to-top" aria-label="Back to top" title="Back to top"></button>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-head">
<span class="sidebar-title">Archives</span>
<span class="sidebar-count" id="archiveCount">0</span>
</div>
<input type="text" class="sidebar-search" id="archiveSearch" placeholder="Filter archives...">
<div class="archive-list" id="archiveList">
<div class="no-archives">Loading…</div>
</div>
</aside>
<main class="viewer" id="viewer">
<div class="empty-state">Pick an archive on the left to run sentiment clustering, top-actor, and word-frequency analysis on its raw data.</div>
</main>
</div>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
<script nonce="{{ g.csp_nonce }}">
(function () {
'use strict';
function esc(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]); }
// Same allowlist index.html/graph.html apply to avatar/banner URLs — https
// only, no quote/angle-bracket/whitespace/paren characters. Not strictly
// required for a plain <img src> (no second CSS-parsing pass involved the
// way a background-image url() has), but kept consistent with the other
// two pages rather than being the one spot that skips it.
function isSafeImageUrl(u) {
return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
}
// Hamburger nav
(function () {
const btn = document.getElementById('navToggle');
const menu = document.getElementById('navMenu');
btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('hidden'); });
document.addEventListener('click', () => menu.classList.add('hidden'));
menu.addEventListener('click', e => e.stopPropagation());
})();
// Back to top — the page itself is what scrolls (the sidebar/viewer split
// is a grid column layout, not a fixed-height scroll pane).
(function () {
const btn = document.getElementById('backToTop');
window.addEventListener('scroll', () => {
btn.classList.toggle('visible', window.scrollY > 400);
});
btn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
})();
const archiveList = document.getElementById('archiveList');
const archiveCount = document.getElementById('archiveCount');
const archiveSearch = document.getElementById('archiveSearch');
const viewer = document.getElementById('viewer');
let allArchives = [];
let activeId = null;
let activeData = null; // last /api/analytics/<id> payload
let activeFilter = null; // 'pro' | 'neutral' | 'con' | null (item browser filter)
// ── Sidebar ──────────────────────────────────────────────────────────────────
async function loadSidebar() {
const res = await fetch('/api/archive/list');
const json = await res.json();
if (!json.ok || !json.archives.length) {
allArchives = [];
archiveList.innerHTML = '<div class="no-archives">No archives yet.<br>Save one from the search page, graph, or geo search first.</div>';
archiveCount.textContent = '0';
return;
}
allArchives = json.archives;
renderArchiveList(archiveSearch.value);
}
function queryLabel(q) {
return (q && (q.searchQuery || q.targetUsername || q.targetTweetId || q.targetCommunityId || q.query)) || '';
}
function archiveMatchText(a) {
return [a.tool || '', queryLabel(a.query || {}), a.id || ''].join(' ').toLowerCase();
}
function renderArchiveList(filterText = '') {
const q = filterText.toLowerCase().trim();
const filtered = q ? allArchives.filter(a => archiveMatchText(a).includes(q)) : allArchives;
archiveCount.textContent = q ? `${filtered.length} / ${allArchives.length}` : String(allArchives.length);
if (!filtered.length) {
archiveList.innerHTML = '<div class="no-archives">No archives match your search.</div>';
return;
}
archiveList.innerHTML = filtered.map(entryHtml).join('');
archiveList.querySelectorAll('.archive-entry').forEach(el => {
el.classList.toggle('active', el.dataset.id === activeId);
el.addEventListener('click', () => loadAnalytics(el.dataset.id));
});
}
archiveSearch.addEventListener('input', () => renderArchiveList(archiveSearch.value));
function entryHtml(a) {
const tool = (a.tool || 'unknown').replace(/_/g, ' ');
const date = a.archived_at ? a.archived_at.replace('T', ' ') : '—';
const query = queryLabel(a.query || {});
return `
<div class="archive-entry" data-id="${esc(a.id)}">
<div class="ae-tool">${esc(tool)}</div>
<div class="ae-date">${esc(date)}</div>
${query ? `<div class="ae-meta">${esc(query)}</div>` : ''}
<div class="ae-stats">${a.total_items || 0} items</div>
</div>`;
}
// ── Analysis ──────────────────────────────────────────────────────────────────
async function loadAnalytics(id) {
activeId = id;
activeFilter = null;
archiveList.querySelectorAll('.archive-entry').forEach(el => el.classList.toggle('active', el.dataset.id === id));
viewer.innerHTML = '<div class="empty-state"><span class="spinner"></span> Analyzing…</div>';
try {
const res = await fetch('/api/analytics/' + encodeURIComponent(id));
const json = await res.json();
if (!json.ok) { viewer.innerHTML = `<div class="empty-state">${esc(json.error || 'Analysis failed')}</div>`; return; }
activeData = json;
renderDashboard();
} catch (e) {
viewer.innerHTML = `<div class="empty-state">${esc(String(e))}</div>`;
}
}
const SENT_LABELS = { pro: 'Pro', neutral: 'Neutral', con: 'Con' };
let snaCy = null; // lazily built the first time "View as Graph" is clicked
function renderDashboard() {
// A previous archive's graph instance (if any) is about to lose its
// container to the innerHTML rewrite below — destroy it cleanly first
// rather than leaving a detached Cytoscape instance running.
if (snaCy) { snaCy.destroy(); snaCy = null; }
const d = activeData;
if (!d.total_scored) {
viewer.innerHTML = '<div class="empty-state">This archive has no text content to analyze (e.g. a pure follower/following list, or Wayback/CSE-only records with nothing scraped).</div>';
return;
}
viewer.innerHTML = `
<div class="method-note">
${d.method === 'ml'
? 'Sentiment is scored by a multilingual model (XLM-RoBERTa, fine-tuned on tweets across 8 languages and reasonably capable well beyond those) — works on non-Indonesian text, not just the lexicon fallback below. Still a heuristic, not ground truth: short text, sarcasm and irony all still degrade accuracy. Click a tile below to see which items landed in it and the model\'s confidence per item.'
: 'Sentiment is scored against an Indonesian positive/negative word lexicon with negation handling (e.g. "tidak bagus" flips to con) — a transparent rule-based heuristic, Indonesian-only. Install torch+transformers (see requirements.txt) for multilingual ML-based scoring instead. Click a tile below to see exactly which items landed in it and which words drove each score.'}
${d.total_items - d.total_scored > 0 ? `${d.total_items - d.total_scored} of ${d.total_items} record(s) had no text to score (bare follower/following/user entries) and are excluded below.` : ''}
</div>
<div class="section">
<div class="section-title">Sentiment clustering <span class="section-note">${d.total_scored} scored</span></div>
${sentimentBarHtml(d)}
<div class="sentiment-tiles">
${sentimentTileHtml('pro', d)}
${sentimentTileHtml('neutral', d)}
${sentimentTileHtml('con', d)}
</div>
</div>
<div class="grid-2 section">
<div>
<div class="section-title">Most active accounts <span class="section-note">by items in this archive</span></div>
${topUsersHtml(d.top_users)}
</div>
<div>
<div class="section-title">Most engagement <span class="section-note">replies + retweets + likes</span></div>
${topEngagementHtml(d.top_engagement)}
</div>
</div>
<div class="section">
<div class="section-title">
Social network graph <span class="section-note">accounts, sized by activity, colored by sentiment mix</span>
<button type="button" id="btnSnaGraph" class="btn-toolbar">View as Graph</button>
</div>
<div class="sna-legend">
<span class="sna-legend-item"><span class="sna-legend-dot dot-pro"></span>Pro</span>
<span class="sna-legend-item"><span class="sna-legend-dot dot-neutral"></span>Neutral</span>
<span class="sna-legend-item"><span class="sna-legend-dot dot-con"></span>Con</span>
<span class="sna-legend-item">Node size = post volume · edges = reply relationships found within this archive · click a node for details</span>
</div>
<div class="sna-wrap" id="snaWrap">
<div class="sna-graph" id="snaGraph"></div>
<div class="sna-node-info" id="snaNodeInfo"></div>
</div>
</div>
<div class="section">
<div class="section-title">Word frequency</div>
${wordCloudHtml(d.word_freq)}
</div>
<div class="section">
<div class="item-toolbar">
<div class="section-title" style="margin-bottom:0">Items <span class="item-count" id="itemCount"></span></div>
<input type="text" class="item-search" id="itemSearch" placeholder="Search text or author...">
</div>
<div class="item-list" id="itemList"></div>
</div>
`;
document.querySelectorAll('.stile').forEach(el => {
el.addEventListener('click', () => {
const label = el.dataset.label;
activeFilter = activeFilter === label ? null : label;
document.querySelectorAll('.stile').forEach(t => t.classList.toggle('active', t.dataset.label === activeFilter));
renderItemList();
});
});
document.getElementById('itemSearch').addEventListener('input', renderItemList);
renderItemList();
document.getElementById('btnSnaGraph').addEventListener('click', toggleSnaGraph);
}
// ── Social network graph ─────────────────────────────────────────────────────
// Accounts as nodes — each one's fill is a native Cytoscape pie split by its
// own pro/neutral/con proportions (a Maltego-style at-a-glance indicator:
// mostly-green vs mostly-red vs mixed is readable across the whole graph at
// once, not just one account at a time), sized by how many items in this
// archive that account produced. Edges are reply relationships derived
// entirely from data already loaded in scored_items — no extra request:
// when an item's in_reply_to_tweet_id matches another item's own id within
// this same archive, that's a real reply link between the two authors.
function toggleSnaGraph() {
const wrap = document.getElementById('snaWrap');
const btn = document.getElementById('btnSnaGraph');
const showing = wrap.classList.toggle('visible');
btn.textContent = showing ? 'Hide Graph' : 'View as Graph';
if (showing && !snaCy) buildSnaGraph();
}
function buildSnaGraph() {
const d = activeData;
const accounts = {}; // handle -> { pro, neutral, con, total, name, avatar }
const idToAuthor = {}; // raw item id -> handle, for resolving reply edges
d.scored_items.forEach(it => {
const raw = it.item || {};
const handle = it.author || raw.screen_name || raw.user;
if (!handle) return;
if (!accounts[handle]) {
accounts[handle] = {
pro: 0, neutral: 0, con: 0, total: 0,
name: raw.name || '', avatar: raw.avatar || raw.user_avatar || '',
};
}
accounts[handle][it.label]++;
accounts[handle].total++;
if (raw.id != null) idToAuthor[String(raw.id)] = handle;
});
const handles = Object.keys(accounts);
const graphEl = document.getElementById('snaGraph');
if (!handles.length) {
graphEl.innerHTML = '<div class="empty-state" style="padding:24px">No identifiable accounts to graph.</div>';
return;
}
const seenEdge = new Set();
const edges = [];
d.scored_items.forEach(it => {
const raw = it.item || {};
const replyTo = raw.in_reply_to_tweet_id;
const fromHandle = it.author || raw.screen_name || raw.user;
if (!replyTo || !fromHandle) return;
const toHandle = idToAuthor[String(replyTo)];
if (!toHandle || toHandle === fromHandle) return;
const key = fromHandle + '→' + toHandle;
if (seenEdge.has(key)) return;
seenEdge.add(key);
edges.push({ data: { id: 'e_' + edges.length, source: fromHandle, target: toHandle, label: 'replied to' } });
});
const counts = handles.map(h => accounts[h].total);
const maxCount = Math.max(...counts);
const minCount = Math.min(...counts);
const sameSize = maxCount === minCount;
const nodes = handles.map(h => {
const a = accounts[h];
const pct = n => a.total ? Math.round((n / a.total) * 100) : 0;
return {
data: {
id: h,
label: '@' + h + ' (' + a.total + ')',
name: a.name, avatar: a.avatar,
pro: a.pro, neutral: a.neutral, con: a.con, total: a.total,
proPct: pct(a.pro), neutralPct: pct(a.neutral), conPct: pct(a.con),
},
};
});
const sizeStyle = sameSize
? { width: 42, height: 42 }
: { width: `mapData(total, ${minCount}, ${maxCount}, 26, 74)`, height: `mapData(total, ${minCount}, ${maxCount}, 26, 74)` };
snaCy = cytoscape({
container: graphEl,
elements: { nodes, edges },
minZoom: 0.15,
maxZoom: 3,
style: [
{ selector: 'node', style: Object.assign({
shape: 'ellipse',
label: 'data(label)',
color: '#e8eaf0',
'font-size': '9px',
'min-zoomed-font-size': 6,
'text-valign': 'bottom',
'text-margin-y': '6px',
'text-wrap': 'ellipsis',
'text-max-width': '90px',
'border-width': 2,
'border-color': '#2a2d3a',
'pie-size': '100%',
'pie-1-background-color': '#22c55e',
'pie-1-background-size': 'data(proPct)',
'pie-2-background-color': '#8890a4',
'pie-2-background-size': 'data(neutralPct)',
'pie-3-background-color': '#ef4444',
'pie-3-background-size': 'data(conPct)',
}, sizeStyle) },
{ selector: 'edge', style: {
'line-color': '#FFFFFF',
'line-opacity': 0.45,
width: 1,
'target-arrow-color': '#FFFFFF',
'target-arrow-shape': 'triangle',
'arrow-scale': 0.8,
'curve-style': 'bezier',
label: 'data(label)',
'font-size': '7px',
color: '#8890a4',
'text-background-color': '#0f1117',
'text-background-opacity': 0.85,
'text-background-padding': '2px',
}},
{ selector: 'node:selected', style: {
'border-width': 3,
'border-color': '#5865f2',
}},
],
layout: {
name: 'cose', animate: true, animationDuration: 450,
nodeRepulsion: 9000, idealEdgeLength: 90, gravity: 0.4, numIter: 1000, fit: true, padding: 30,
},
});
snaCy.on('tap', 'node', evt => showSnaNodeInfo(evt.target.data()));
}
function showSnaNodeInfo(nd) {
const panel = document.getElementById('snaNodeInfo');
panel.classList.add('visible');
panel.innerHTML = `
<div class="sna-node-identity">
<div class="sna-node-name">${esc(nd.name || nd.id)}</div>
<div class="sna-node-handle">@${esc(nd.id)}</div>
</div>
<div class="sna-node-stats">
<span class="sent-badge sent-pro">Pro ${nd.pro}</span>
<span class="sent-badge sent-neutral">Neutral ${nd.neutral}</span>
<span class="sent-badge sent-con">Con ${nd.con}</span>
</div>
<div class="sna-node-total">${nd.total} item(s) total</div>
`;
}
function sentimentBarHtml(d) {
const total = d.total_scored || 1;
const segs = ['pro', 'neutral', 'con'].map(label => {
const pct = d.sentiment_pct[label] || 0;
if (!pct) return '';
return `<div class="sentiment-seg seg-${label}" style="flex:${d.sentiment_counts[label]}" title="${SENT_LABELS[label]}: ${pct}%">${pct >= 8 ? pct + '%' : ''}</div>`;
}).join('');
return `<div class="sentiment-bar">${segs}</div>`;
}
function sentimentTileHtml(label, d) {
return `
<div class="stile" data-label="${label}">
<div class="stile-label"><span class="stile-dot dot-${label}"></span>${SENT_LABELS[label]}</div>
<div class="stile-value">${d.sentiment_counts[label] || 0}</div>
<div class="stile-pct">${d.sentiment_pct[label] || 0}% of scored items</div>
</div>`;
}
function topUsersHtml(users) {
if (!users || !users.length) return '<div class="empty-state" style="padding:24px">No identifiable authors in this archive.</div>';
return '<div class="user-list">' + users.map((u, i) => {
const avatarUrl = isSafeImageUrl(u.avatar) ? u.avatar : '';
const avatar = avatarUrl
? `<img class="user-avatar" src="${esc(avatarUrl)}" alt="" loading="lazy" referrerpolicy="no-referrer">`
: `<div class="user-avatar user-avatar-fallback">${esc(((u.name || u.screen_name || '?').charAt(0)).toUpperCase())}</div>`;
return `
<div class="user-row">
<span class="user-rank">${i + 1}</span>
${avatar}
<div class="user-identity">
<div class="user-name">${esc(u.name || u.screen_name)}</div>
<div class="user-handle">@${esc(u.screen_name)}</div>
</div>
<span class="user-count">${u.count}×</span>
</div>`;
}).join('') + '</div>';
}
function topEngagementHtml(items) {
if (!items || !items.length) return '<div class="empty-state" style="padding:24px">No engagement data (reply/retweet/favorite counts) in this archive.</div>';
return '<div class="engagement-list">' + items.map(e => {
const it = e.item;
const handle = it.screen_name || it.user || it.name || 'unknown';
const text = (it.text || it.full_text || it.post_title || it.post_text || it.description || '').trim();
return `
<div class="eng-row">
<div class="eng-top">
<span class="eng-handle">@${esc(handle)}</span>
<span class="eng-score">${e.engagement.toLocaleString()} interactions</span>
</div>
<div class="eng-text">${esc(text)}</div>
</div>`;
}).join('') + '</div>';
}
function wordCloudHtml(words) {
if (!words || !words.length) return '<div class="empty-state" style="padding:24px">Not enough text to build a word list.</div>';
const max = Math.max(...words.map(w => w.count));
const min = Math.min(...words.map(w => w.count));
const span = Math.max(1, max - min);
return '<div class="wordcloud">' + words.map(w => {
// Font size scaled by frequency (13px..38px) — size is the encoding here,
// not color, so this stays legible without inventing a new color ramp.
const t = (w.count - min) / span;
const size = Math.round(13 + t * 25);
const opacity = (0.55 + t * 0.45).toFixed(2);
return `<span class="wc-word" style="font-size:${size}px;opacity:${opacity}" title="${w.count}×">${esc(w.word)}</span>`;
}).join('') + '</div>';
}
// ── Item browser ─────────────────────────────────────────────────────────────
function renderItemList() {
const d = activeData;
const q = (document.getElementById('itemSearch').value || '').toLowerCase().trim();
let items = d.scored_items;
if (activeFilter) items = items.filter(it => it.label === activeFilter);
if (q) items = items.filter(it => (it.text || '').toLowerCase().includes(q) || (it.author || '').toLowerCase().includes(q));
document.getElementById('itemCount').textContent = `${items.length} / ${d.scored_items.length}`;
const list = document.getElementById('itemList');
if (!items.length) { list.innerHTML = '<div class="empty-state" style="padding:24px">No items match.</div>'; return; }
list.innerHTML = items.map(it => {
const matches = (it.matches || []).map(m =>
`<span class="match-chip mc-${m.polarity}">${esc(m.word)}</span>`
).join('');
const scoreLabel = typeof it.confidence === 'number'
? `${Math.round(it.confidence * 100)}% confidence`
: `score ${it.score > 0 ? '+' : ''}${it.score}`;
return `
<div class="item-card">
<div class="item-card-top">
<span class="item-author">${it.author ? '@' + esc(it.author) : 'Unknown'}</span>
<span class="sent-badge sent-${it.label}">${SENT_LABELS[it.label]}</span>
<span class="item-score">${scoreLabel}</span>
</div>
<div class="item-text">${esc(it.text)}</div>
${matches ? `<div class="item-matches">${matches}</div>` : ''}
</div>`;
}).join('');
}
loadSidebar();
})();
</script>
</body>
</html>