Files

1653 lines
74 KiB
HTML
Raw Permalink 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; }
.verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }
/* Same classes/colors as index.html/archive.html/graph.html's own
account_age_flag badge — new/recent accounts are a classic bot/
sockpuppet signal, worth being just as visible here as it already is
everywhere else this data shows up. */
.age-badge {
display: inline-block; font-size: 10px; font-weight: 500; padding: 1px 7px;
border-radius: 20px; border: 1px solid var(--border); margin-left: 6px; vertical-align: middle;
}
.age-badge.age-new { color: var(--danger); border-color: #7f1d1d; background: var(--danger-bg); }
.age-badge.age-recent { color: #f59e0b; border-color: #78350f; background: #1c0e02; }
.age-badge.age-established { color: var(--success); border-color: #14532d; background: var(--success-bg); }
/* No colored bucket, since there genuinely isn't one here (id_forensics.py
couldn't place this id in either range) — muted like sent-neutral,
not styled to look like a real new/recent/established verdict. */
.age-badge.age-unknown { color: var(--muted); border-color: var(--border); background: var(--bg); }
.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); }
/* ── Volume calendar — GitHub-contributions-style heatmap of how many
scored items landed on each day, so a topic's activity spikes/clusters
over time are visible at a glance. Click a day to filter Items below. ── */
.cal-outer { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.cal-scroll { overflow-x: auto; padding-bottom: 2px; }
.cal-months { display: flex; gap: 3px; height: 14px; margin-bottom: 4px; width: max-content; }
.cal-month-cell { width: 11px; flex: 0 0 auto; font-size: 10px; color: var(--muted); white-space: nowrap; overflow: visible; }
.cal-grid { display: flex; gap: 3px; width: max-content; }
.cal-week { display: flex; flex-direction: column; gap: 3px; }
.cal-cell { width: 11px; height: 11px; border-radius: 2px; background: var(--border); opacity: 0.4; }
.cal-cell[data-date] { cursor: pointer; }
.cal-cell.cal-has-data { background: var(--accent); }
.cal-cell.cal-level-1 { opacity: 0.28; }
.cal-cell.cal-level-2 { opacity: 0.5; }
.cal-cell.cal-level-3 { opacity: 0.75; }
.cal-cell.cal-level-4 { opacity: 1; }
.cal-cell.cal-active { outline: 2px solid var(--text); outline-offset: 1px; }
.cal-legend { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); margin-top: 10px; flex-wrap: wrap; }
.cal-legend .cal-cell { cursor: default; width: 10px; height: 10px; }
.cal-filter-chip {
display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text);
background: var(--accent-bg); border: 1px solid var(--accent); border-radius: 20px;
padding: 3px 10px; cursor: pointer; margin-left: 8px;
}
.cal-date-stat {
margin-top: 8px;
padding: 7px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
color: var(--text);
}
/* ── 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); }
.sent-badge.sent-mixed { color: var(--accent); border-color: #34348a; background: var(--accent-bg); }
/* ── Account sentiment breakdown — who's pro/con/neutral, as accounts, not
just individual posts ── */
.account-sent-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.asg-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.asg-count { font-size: 11px; color: var(--muted); }
.user-row.clickable { cursor: pointer; }
.user-row.clickable:hover { background: var(--surface2); }
.user-breakdown { font-size: 10px; color: var(--muted); flex-shrink: 0; white-space: nowrap; }
/* Auto-scroll — same idea as index.html's scroll-triggered load-more, just
scoped to each small box instead of the whole page: a fixed-height
panel that scrolls internally, revealing more rows as you scroll near
its bottom instead of dumping everything (or a hard cutoff) at once. */
.user-list.scrollable, .engagement-list.scrollable {
max-height: 320px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.load-more-note {
font-size: 11px;
color: var(--muted);
text-align: center;
padding: 10px 0 2px;
}
/* ── 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-date { font-size: 11px; color: var(--muted); }
.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); } }
/* Sentiment-analysis progress bar — same visual language as the archive
media-download bar on index.html/graph.html/archive.html, so a
long-running background job looks the same wherever one shows up. */
.archive-bar-fill-wrap { height: 3px; background: var(--border); border-radius: 2px; margin-top: 8px; overflow: hidden; }
.archive-bar-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }
.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>
<a href="/cases">Cases</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 blue-checkmark badge index.html/archive.html (card_constants.js) and
// graph.html each render next to a name — this page doesn't load either of
// those, so it gets its own copy rather than a shared file just for one
// function. `u`/`item` can be either an accountSentiment() entry or a raw
// scored-item's own `.item`, both carry the same two field names.
function verifiedBadgeHtml(u) {
if (!u || (!u.verified && !u.is_blue_verified)) return '';
const title = u.is_blue_verified ? 'Blue verified (X Premium)' : 'Verified (legacy)';
return `<span class="verified-badge" title="${esc(title)}">✓</span>`;
}
// Same account_age_flag badge index.html/archive.html/graph.html already
// render as a plain field row — a new/recent account is a classic bot/
// sockpuppet signal, worth surfacing here at a glance too, not just buried
// in a raw field somewhere this dashboard doesn't even show. `u`/`item`
// works the same as verifiedBadgeHtml() above: an accountSentiment() entry,
// sentiment.py's top_users() entry, or a raw scored-item's `.item` all
// carry the same field name.
const AGE_LABELS = { new: 'New account', recent: 'Recent account', established: 'Established account' };
function ageBadgeHtml(u) {
if (!u) return '';
if (u.account_age_flag) {
const label = AGE_LABELS[u.account_age_flag] || u.account_age_flag;
return `<span class="age-badge age-${esc(u.account_age_flag)}" title="${esc(u.account_age || '')}">${esc(label)}</span>`;
}
// account_age_flag is null in two different situations that look
// identical unless account_age_precision is checked too: no account id
// was ever available to enrich (nothing to show — most items, no badge
// at all is correct), vs. id_forensics.py's enrich_account_age() DID
// have an id but it fell into that module's own documented "unknown"
// gap (an id just past the calibrated range — see id_forensics.py's
// module docstring). The second case is still worth a visible marker:
// index.html/archive.html/graph.html all still show this account's own
// account_age text as a plain field row even here, and this dashboard
// has no equivalent raw-field fallback view, so silently showing
// nothing would make an intentionally-labeled "we don't know" look
// exactly like "there was never anything to know" instead.
if (u.account_age_precision === 'unknown') {
return `<span class="age-badge age-unknown" title="${esc(u.account_age || 'Account age could not be determined')}">Age unknown</span>`;
}
return '';
}
// 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' });
});
})();
// Click an account row (top-actors list or the pro/con/neutral/mixed
// breakdown) to filter the Items browser down to just that account — bound
// once here on the persistent #viewer container (delegated), rather than
// re-bound inside renderDashboard() every time an archive is opened, which
// would stack a duplicate listener on every switch.
document.getElementById('viewer').addEventListener('click', (e) => {
const row = e.target.closest('.user-row.clickable');
if (!row || !activeData) return;
const handle = row.dataset.handle;
const search = document.getElementById('itemSearch');
if (!search) return;
search.value = handle;
activeFilter = null;
document.querySelectorAll('.stile').forEach(t => t.classList.remove('active'));
renderItemList();
document.getElementById('itemList').scrollIntoView({ behavior: 'smooth', block: 'start' });
});
// Calendar day click — filters Items to that day; clicking the same day (or
// the "Filtered: …" chip) clears it. Delegated on #viewer like the account
// click handler above, since #calendarSection is rebuilt on every archive
// switch and every toggle.
document.getElementById('viewer').addEventListener('click', (e) => {
if (!activeData) return;
const clear = e.target.closest('#calClearFilter');
const cell = e.target.closest('.cal-cell[data-date]');
if (!clear && !cell) return;
if (clear || activeDateFilter === cell.dataset.date) {
activeDateFilter = null;
} else if (cell.classList.contains('cal-has-data')) {
activeDateFilter = cell.dataset.date;
} else {
return; // clicked an empty (no-data) day — nothing to filter to
}
const calSection = document.getElementById('calendarSection');
if (calSection) calSection.innerHTML = calendarHtml(dailyVolume(activeData));
renderItemList();
});
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)
let activeDateFilter = null; // 'YYYY-MM-DD' | null (calendar-driven item browser filter)
// ── Resilient fetch — auto-retry transient failures ─────────────────────────
// res.json() throws a raw SyntaxError ("unexpected character at line 1
// column 1...") whenever a response body isn't valid JSON — a dropped/
// truncated connection can do this even when the server itself is fine.
// Retries a few times with a short backoff (self-heals a one-off network
// blip automatically) before surfacing a real, readable error. A
// well-formed {ok:false,...} response is NOT retried — that's the server
// correctly answering "not found" / "corrupted," retrying wouldn't change it.
async function fetchJsonRetry(url, opts, attempts = 3) {
let lastErr;
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url, opts);
return await res.json();
} catch (e) {
lastErr = e;
if (i < attempts - 1) await new Promise(r => setTimeout(r, 500 * (i + 1)));
}
}
throw lastErr;
}
// ── Sidebar ──────────────────────────────────────────────────────────────────
async function loadSidebar() {
let json;
try {
json = await fetchJsonRetry('/api/archive/list');
} catch (e) {
archiveList.innerHTML = `<div class="no-archives" style="color:var(--danger)">Couldn't load archives — ${esc(e.message || String(e))}<br><button type="button" class="btn-toolbar" id="retrySidebarBtn" style="margin-top:8px">Retry</button></div>`;
document.getElementById('retrySidebarBtn').addEventListener('click', loadSidebar);
return;
}
if (!json.ok || !json.archives.length) {
allArchives = [];
archiveList.innerHTML = json.ok
? '<div class="no-archives">No archives yet.<br>Save one from the search page, graph, or geo search first.</div>'
: `<div class="no-archives" style="color:var(--danger)">${esc(json.error)}</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 ──────────────────────────────────────────────────────────────────
// ML sentiment scoring runs locally on CPU at ~11-12ms/item (measured) — a
// large archive (thousands of items) can genuinely take a minute-plus, so
// this starts the job on the server (background thread, see app.py's
// analytics_start/_run_analytics) and polls for progress instead of
// blocking on one long request with nothing to show but a static spinner.
async function loadAnalytics(id) {
activeId = id;
activeFilter = null;
activeDateFilter = null;
archiveList.querySelectorAll('.archive-entry').forEach(el => el.classList.toggle('active', el.dataset.id === id));
viewer.innerHTML = `
<div class="empty-state">
<div style="max-width:340px;margin:0 auto;text-align:left">
<div><span class="spinner"></span> <span id="analyzeStatusText">Starting analysis…</span></div>
<div class="archive-bar-fill-wrap"><div class="archive-bar-fill" id="analyzeBarFill" style="width:0%"></div></div>
</div>
</div>`;
try {
const startJson = await fetchJsonRetry('/api/analytics/' + encodeURIComponent(id) + '/start', { method: 'POST' });
if (activeId !== id) return; // user already clicked a different archive
if (!startJson.ok) { showAnalyticsError(id, startJson.error || 'Analysis failed to start'); return; }
pollAnalytics(id);
} catch (e) {
showAnalyticsError(id, e.message || String(e));
}
}
function showAnalyticsError(id, message) {
if (activeId !== id) return; // a later archive click already replaced this view
viewer.innerHTML = `<div class="empty-state">Couldn't analyze this archive — ${esc(message)}<br><button type="button" class="btn-toolbar" id="retryAnalyticsBtn" style="margin-top:8px">Retry</button></div>`;
document.getElementById('retryAnalyticsBtn').addEventListener('click', () => loadAnalytics(id));
}
function pollAnalytics(id) {
const interval = setInterval(async () => {
// Stop polling for an archive the user has since clicked away from —
// otherwise a slow poll response landing late could clobber whatever
// (possibly already-finished) view is on screen now.
if (activeId !== id) { clearInterval(interval); return; }
let json;
try {
json = await fetchJsonRetry('/api/analytics/' + encodeURIComponent(id) + '/status');
} catch (e) {
clearInterval(interval);
showAnalyticsError(id, e.message || String(e));
return;
}
if (activeId !== id) { clearInterval(interval); return; }
if (!json.ok) {
clearInterval(interval);
showAnalyticsError(id, json.error || 'Analysis failed');
return;
}
if (json.status === 'done') {
clearInterval(interval);
activeData = json;
renderDashboard();
return;
}
// status === 'scoring' — update the bar and keep polling
const total = json.total || 0;
const pct = total > 0 ? Math.round((json.progress / total) * 100) : 0;
const statusText = document.getElementById('analyzeStatusText');
const fill = document.getElementById('analyzeBarFill');
if (statusText) statusText.textContent = total > 0 ? `Scoring ${json.progress} / ${total} items…` : 'Preparing…';
if (fill) fill.style.width = pct + '%';
}, 700);
}
const SENT_LABELS = { pro: 'Pro', neutral: 'Neutral', con: 'Con', mixed: 'Mixed' };
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 con 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="section">
<div class="section-title">Volume over time <span class="section-note">items per day — click a day to filter Items below, click again to clear</span></div>
<div id="calendarSection">${calendarHtml(dailyVolume(d))}</div>
</div>
<div class="section">
<div class="section-title">Account sentiment breakdown <span class="section-note">accounts grouped by their own pro/con/neutral lean, not just individual posts — click an account to filter Items below</span></div>
${accountSentimentHtml(d)}
</div>
<div class="grid-2 section">
<div>
<div class="item-toolbar" style="margin-bottom:8px">
<div class="section-title" style="margin-bottom:0">Most active accounts <span class="item-count" id="topUsersCount">${(d.top_users || []).length}</span></div>
<input type="text" class="item-search" id="topUsersSearch" placeholder="Search accounts...">
</div>
${topUsersHtml(d.top_users, accountSentiment(d))}
</div>
<div>
<div class="item-toolbar" style="margin-bottom:8px">
<div class="section-title" style="margin-bottom:0">Most engagement <span class="item-count" id="topEngagementCount">${(d.top_engagement || []).length}</span></div>
<input type="text" class="item-search" id="engSearch" placeholder="Search text or author...">
</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/quote 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 id="itemListSentinel" style="height:1px"></div>
<div class="load-more-note" id="itemListStatus" style="display:none"></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();
});
});
// Debounced — on a large archive (thousands of scored items) re-filtering
// and re-rendering the whole list on every single keystroke makes typing
// itself feel laggy; 180ms is short enough to still feel instant once
// typing pauses, long enough to collapse a fast typist's keystrokes into
// one render instead of one per character.
let itemSearchDebounce = null;
document.getElementById('itemSearch').addEventListener('input', () => {
clearTimeout(itemSearchDebounce);
itemSearchDebounce = setTimeout(renderItemList, 180);
});
renderItemList();
// Same debounced-search pattern as itemSearch above, scoped to the Most
// active accounts / Most engagement boxes — their search inputs live in
// the section header now (see renderDashboard()'s own template above),
// so they're always present regardless of whether either box has data.
let topUsersSearchDebounce = null;
document.getElementById('topUsersSearch').addEventListener('input', (e) => {
const val = e.target.value;
clearTimeout(topUsersSearchDebounce);
topUsersSearchDebounce = setTimeout(() => {
topUsersQuery = val.toLowerCase().trim();
renderTopUsersBox();
}, 180);
});
let engSearchDebounce = null;
document.getElementById('engSearch').addEventListener('input', (e) => {
const val = e.target.value;
clearTimeout(engSearchDebounce);
engSearchDebounce = setTimeout(() => {
topEngagementQuery = val.toLowerCase().trim();
renderTopEngagementBox();
}, 180);
});
// Re-bind both scroll-observers to this render's freshly built sentinel
// nodes (see the comments above itemListObserver / rebindAccountScrollObservers
// for why re-binding is needed on every archive switch).
itemListObserver.disconnect();
const itemSentinel = document.getElementById('itemListSentinel');
if (itemSentinel) itemListObserver.observe(itemSentinel);
rebindAccountScrollObservers();
rebindTopListObservers();
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 = accountSentiment(d); // 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 && 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' } });
});
// Quote-tweet edges — quoted_user is already denormalized straight onto
// the record (cookie_client.py's _tweet_to_dict), so unlike the reply
// edge above this needs no idToAuthor lookup — just confirm the quoted
// account is itself a node here (i.e. it authored something else in this
// same archive; if it never did, there's no node for the edge to point
// to and it's silently skipped, same rule idToAuthor enforces for replies).
d.scored_items.forEach(it => {
const raw = it.item || {};
const quotedUser = raw.quoted_user;
const fromHandle = it.author || raw.screen_name || raw.user;
if (!quotedUser || !fromHandle || quotedUser === fromHandle || !accounts[quotedUser]) return;
const key = fromHandle + '→' + quotedUser;
if (seenEdge.has(key)) return;
seenEdge.add(key);
edges.push({ data: { id: 'e_' + edges.length, source: fromHandle, target: quotedUser, label: 'quoted' } });
});
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,
verified: a.verified, is_blue_verified: a.is_blue_verified,
account_age_flag: a.account_age_flag, account_age: a.account_age,
account_age_precision: a.account_age_precision,
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)}${verifiedBadgeHtml(nd)}</div>
<div class="sna-node-handle">@${esc(nd.id)}${ageBadgeHtml(nd)}</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>`;
}
// ── Item dates ────────────────────────────────────────────────────────────
// created_at is set by cookie_client/xquik_client/wayback_client/google_cse_
// client on the raw item (Twitter's classic date string, e.g. "Wed Oct 10
// 20:19:24 +0000 2018" — a format the Date constructor parses natively).
// retweeted_at/published_at/date cover the odd record shape that doesn't use
// created_at. Returns null (not a bare user record, no text, or an
// unparseable date) rather than throwing — dates are best-effort metadata,
// not something scoring depends on.
function itemDate(it) {
const raw = (it && it.item) || {};
const s = raw.created_at || raw.retweeted_at || raw.published_at || raw.date;
if (!s) return null;
const dt = new Date(s);
return isNaN(dt.getTime()) ? null : dt;
}
function dateKey(dt) {
// UTC calendar day, not local — keeps the calendar's bucketing stable
// regardless of which timezone the browser viewing it happens to be in.
return dt.toISOString().slice(0, 10);
}
function formatItemDate(dt) {
return dt.toLocaleString('en-US', {
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC',
});
}
// Memoized the same way accountSentiment() is — buildDashboard only needs
// this computed once per archive, not once per re-render.
let _dailyCache = null;
let _dailyCacheFor = null;
function dailyVolume(d) {
if (_dailyCacheFor === d) return _dailyCache;
const days = new Map(); // 'YYYY-MM-DD' -> { total, pro, neutral, con }
let undated = 0;
d.scored_items.forEach(it => {
const dt = itemDate(it);
if (!dt) { undated++; return; }
const key = dateKey(dt);
if (!days.has(key)) days.set(key, { total: 0, pro: 0, neutral: 0, con: 0 });
const bucket = days.get(key);
bucket.total++;
bucket[it.label]++;
});
const result = { days, undated };
_dailyCache = result;
_dailyCacheFor = d;
return result;
}
function monthLabelsHtml(weeks) {
let lastMonth = null;
return weeks.map(week => {
const month = week[0].key.slice(0, 7);
if (month === lastMonth) return '<div class="cal-month-cell"></div>';
lastMonth = month;
const label = new Date(week[0].key + 'T00:00:00Z').toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
return `<div class="cal-month-cell">${label}</div>`;
}).join('');
}
function calendarHtml(daily) {
const { days, undated } = daily;
if (!days.size) return '<div class="empty-state" style="padding:24px">No dated items to plot (raw records had no usable timestamp).</div>';
const keys = Array.from(days.keys()).sort();
const start = new Date(keys[0] + 'T00:00:00Z');
start.setUTCDate(start.getUTCDate() - start.getUTCDay()); // back up to Sunday
const end = new Date(keys[keys.length - 1] + 'T00:00:00Z');
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay())); // forward to Saturday
const maxCount = Math.max(...Array.from(days.values()).map(v => v.total));
const weeks = [];
for (let cur = new Date(start); cur <= end; ) {
const week = [];
for (let i = 0; i < 7; i++) {
const key = dateKey(cur);
week.push({ key, bucket: days.get(key) || null });
cur.setUTCDate(cur.getUTCDate() + 1);
}
weeks.push(week);
}
const weeksHtml = weeks.map(week => {
const cells = week.map(day => {
if (!day.bucket) {
return `<div class="cal-cell" data-date="${day.key}" title="${day.key}: 0 items"></div>`;
}
const level = Math.max(1, Math.ceil((day.bucket.total / maxCount) * 4));
const title = `${day.key}: ${day.bucket.total} item(s) · ${day.bucket.pro}p/${day.bucket.neutral}n/${day.bucket.con}c`;
return `<div class="cal-cell cal-has-data cal-level-${level}${day.key === activeDateFilter ? ' cal-active' : ''}" data-date="${day.key}" title="${esc(title)}"></div>`;
}).join('');
return `<div class="cal-week">${cells}</div>`;
}).join('');
const selDay = activeDateFilter ? days.get(activeDateFilter) : null;
const selStat = selDay
? (() => {
const d = selDay;
const parts = [];
if (d.pro > 0) parts.push(`${d.pro} positive`);
if (d.neutral > 0) parts.push(`${d.neutral} neutral`);
if (d.con > 0) parts.push(`${d.con} con`);
return `${d.total} item${d.total !== 1 ? 's' : ''} collected${parts.length ? ' — ' + parts.join(' / ') : ''}`;
})()
: null;
return `
<div class="cal-outer">
<div class="cal-scroll">
<div class="cal-months">${monthLabelsHtml(weeks)}</div>
<div class="cal-grid">${weeksHtml}</div>
</div>
<div class="cal-legend">
Less
<span class="cal-cell"></span>
<span class="cal-cell cal-has-data cal-level-1"></span>
<span class="cal-cell cal-has-data cal-level-2"></span>
<span class="cal-cell cal-has-data cal-level-3"></span>
<span class="cal-cell cal-has-data cal-level-4"></span>
More
${undated ? ${undated} item(s) with no usable date not shown` : ''}
${activeDateFilter ? `<span class="cal-filter-chip" id="calClearFilter">${esc(activeDateFilter)} &times;</span>` : ''}
</div>
${selStat ? `<div class="cal-date-stat">${esc(activeDateFilter)}: ${esc(selStat)}</div>` : ''}
</div>`;
}
// ── Account-level sentiment (not just per-post) ─────────────────────────────
// Groups scored_items by author so "who's pro/con/neutral" can be answered
// about ACCOUNTS, the same way top_users answers "who's most active" —
// sentiment.py's own scoring stays per-item (a person can write both a pro
// and a con post), this just aggregates what's already in the payload.
// Memoized per activeData object since buildSnaGraph(), topUsersHtml() and
// accountSentimentHtml() all need the identical aggregation for the same
// archive and it's wasteful (and a drift risk) to recompute it three ways.
let _acctSentCache = null;
let _acctSentCacheFor = null;
function accountSentiment(d) {
if (_acctSentCacheFor === d) return _acctSentCache;
const accounts = {}; // handle -> { pro, neutral, con, total, name, avatar, verified, is_blue_verified, account_age_flag, account_age, account_created }
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 || '',
verified: false, is_blue_verified: false,
// Set once at first sight, same as name/avatar above — an
// account's creation date doesn't change between records, so
// there's no "accumulate" logic needed the way verified has.
account_age_flag: raw.account_age_flag || null,
account_age: raw.account_age || null,
account_created: raw.account_created || null,
account_age_precision: raw.account_age_precision || null,
};
}
// An account can show up on both a verified and (in principle, if the
// raw data ever disagreed across records) a non-verified item — once
// true, stays true, same as any other identity fact aggregated here.
if (raw.verified) accounts[handle].verified = true;
if (raw.is_blue_verified) accounts[handle].is_blue_verified = true;
accounts[handle][it.label]++;
accounts[handle].total++;
});
_acctSentCache = accounts;
_acctSentCacheFor = d;
return accounts;
}
// An account's overall lean — majority label among ITS OWN scored items. A
// tie between pro and con (the case that matters most: an account posting
// equally strongly on both sides of a hot-button topic) is deliberately NOT
// broken toward either side — arbitrarily crowning one would bake a
// directional bias into exactly the accounts where the evidence is split
// down the middle. Those land in "mixed", kept distinct from "neutral"
// (which means mostly neutral-toned posts, i.e. never really took a side —
// a different thing than "took both sides equally").
function dominantLabel(a) {
const max = Math.max(a.pro, a.neutral, a.con);
if (max === 0) return null;
const winners = ['pro', 'neutral', 'con'].filter(k => a[k] === max);
return winners.length === 1 ? winners[0] : 'mixed';
}
function userRowHtml(handle, u, opts = {}) {
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 || handle || '?').charAt(0)).toUpperCase())}</div>`;
const rank = opts.rank != null ? `<span class="user-rank">${opts.rank}</span>` : '';
const right = opts.right || '';
return `
<div class="user-row clickable" data-handle="${esc(handle)}">
${rank}
${avatar}
<div class="user-identity">
<div class="user-name">${esc(u.name || handle)}${verifiedBadgeHtml(u)}</div>
<div class="user-handle">@${esc(handle)}${ageBadgeHtml(u)}</div>
</div>
${right}
</div>`;
}
// Auto-scroll, same reasoning as the four pro/con/neutral/mixed buckets
// below and the Items list further down — sentiment.py's top_users()/
// top_engagement() now return EVERY account/post (no backend top-N cutoff
// anymore, see sentiment.py), since this is an OSINT digital-evidence
// archive and silently dropping who/what shows up here isn't acceptable.
// Pacing how much gets built into HTML as you scroll is purely a rendering
// concern — the full arrays (topUsersList/topEngagementList) stay in
// memory the whole time, nothing is ever thrown away.
const TOP_LIST_BATCH = 20;
let topUsersFull = []; // every account sentiment.py returned, unfiltered
let topUsersQuery = '';
let topUsersList = []; // topUsersFull, narrowed by topUsersQuery — what actually gets paginated
let topUsersShown = 0;
let topUsersSentByHandle = null;
let topEngagementList = [];
let topEngagementShown = 0;
function topUserRowHtml(u, i) {
const s = topUsersSentByHandle && topUsersSentByHandle[u.screen_name];
const label = s ? dominantLabel(s) : null;
const badge = label ? `<span class="sent-badge sent-${label}" style="margin-right:6px">${SENT_LABELS[label]}</span>` : '';
return userRowHtml(u.screen_name, u, { rank: i + 1, right: `${badge}<span class="user-count">${u.count}×</span>` });
}
function userMatchText(u) {
return [u.name || '', u.screen_name || ''].join(' ').toLowerCase();
}
function updateTopUsersStatus() {
const status = document.getElementById('topUsersStatus');
if (!status) return;
const remaining = topUsersList.length - topUsersShown;
if (remaining > 0) {
status.style.display = '';
} else {
status.style.display = 'none';
}
}
function loadMoreTopUsers() {
if (topUsersShown >= topUsersList.length) return;
const prev = topUsersShown;
topUsersShown = Math.min(topUsersShown + TOP_LIST_BATCH, topUsersList.length);
const sentinel = document.getElementById('topUsersSentinel');
if (sentinel) sentinel.insertAdjacentHTML('beforebegin', topUsersList.slice(prev, topUsersShown).map((u, idx) => topUserRowHtml(u, prev + idx)).join(''));
updateTopUsersStatus();
}
// Re-renders just #topUsersBox's own contents — called both on first
// render (topUsersHtml, below) and again whenever the search box's query
// changes (renderDashboard() wires that debounced listener), same split
// renderTopEngagementBox() below uses for its own search box.
function renderTopUsersBox() {
const box = document.getElementById('topUsersBox');
if (!box) return;
topUsersList = topUsersQuery ? topUsersFull.filter(u => userMatchText(u).includes(topUsersQuery)) : topUsersFull;
topUsersShown = Math.min(TOP_LIST_BATCH, topUsersList.length);
box.innerHTML = topUsersList.length
? topUsersList.slice(0, topUsersShown).map((u, i) => topUserRowHtml(u, i)).join('') + '<div id="topUsersSentinel" style="height:1px"></div>'
: '<div class="empty-state" style="padding:16px;font-size:12px">No matches.</div>';
const countEl = document.getElementById('topUsersCount');
if (countEl) countEl.textContent = topUsersQuery ? `${topUsersList.length} / ${topUsersFull.length}` : `${topUsersFull.length}`;
updateTopUsersStatus();
rebindTopUsersObserver();
}
function topUsersHtml(users, sentByHandle) {
topUsersFull = users || [];
topUsersQuery = '';
topUsersList = topUsersFull;
topUsersSentByHandle = sentByHandle;
topUsersShown = Math.min(TOP_LIST_BATCH, topUsersList.length);
if (!topUsersFull.length) return '<div class="empty-state" style="padding:24px">No identifiable authors in this archive.</div>';
return `
<div class="user-list scrollable" id="topUsersBox">
${topUsersList.slice(0, topUsersShown).map((u, i) => topUserRowHtml(u, i)).join('')}
<div id="topUsersSentinel" style="height:1px"></div>
</div>
<div class="load-more-note" id="topUsersStatus" style="display:none"></div>`;
}
// Explicit "which accounts are pro / con / neutral / mixed" listing — the
// SNA graph already encodes this visually (pie-per-node), but that's a
// click-to-open, hover-to-read view; this is the plain-text equivalent
// requested directly: a scannable list per bucket, sorted by how many
// scored items back that lean up (not just alphabetical).
//
// Auto-scroll rather than a hard cutoff: an archive with thousands of items
// can have thousands of unique accounts per bucket, so all four lists start
// at ACCOUNT_BATCH rows and grow by ACCOUNT_BATCH more each time you scroll
// near the bottom of that bucket's own little scroll box (same idea as
// index.html's page-level scroll-to-load-more, just scoped per box since
// four buckets share one screen). The full sorted arrays stay in
// `acctBuckets` in memory — nothing here ever throws data away, it just
// paces how much gets built into HTML at once.
const ACCOUNT_BATCH = 20;
let acctBuckets = { pro: [], con: [], neutral: [], mixed: [] }; // full sorted [handle, data] arrays
let acctShown = { pro: 0, con: 0, neutral: 0, mixed: 0 };
const acctObservers = {};
function acctRowsHtml(key, from, to) {
const slice = acctBuckets[key].slice(from, to);
if (!slice.length) return from === 0 ? '<div class="empty-state" style="padding:16px;font-size:12px">None</div>' : '';
return slice.map(([handle, a]) => userRowHtml(handle, a, {
right: `<span class="user-breakdown">${a.pro}p · ${a.neutral}n · ${a.con}c</span>`,
})).join('');
}
function loadMoreAccounts(key) {
const list = acctBuckets[key];
if (acctShown[key] >= list.length) return;
const prev = acctShown[key];
acctShown[key] = Math.min(acctShown[key] + ACCOUNT_BATCH, list.length);
const sentinel = document.getElementById('asgSentinel-' + key);
if (sentinel) sentinel.insertAdjacentHTML('beforebegin', acctRowsHtml(key, prev, acctShown[key]));
}
function accountSentimentHtml(d) {
const accounts = accountSentiment(d);
const buckets = { pro: [], neutral: [], con: [], mixed: [] };
Object.keys(accounts).forEach(handle => {
const a = accounts[handle];
const label = dominantLabel(a);
if (label) buckets[label].push([handle, a]);
});
Object.values(buckets).forEach(list => list.sort((x, y) => y[1].total - x[1].total));
acctBuckets = buckets;
acctShown = { pro: 0, con: 0, neutral: 0, mixed: 0 };
const sections = [
['pro', 'Pro'], ['con', 'Con'], ['neutral', 'Neutral'], ['mixed', 'Mixed'],
].map(([key, title]) => {
const list = buckets[key];
acctShown[key] = Math.min(ACCOUNT_BATCH, list.length);
return `
<div>
<div class="asg-head"><span class="sent-badge sent-${key}">${title}</span><span class="asg-count">${list.length} account${list.length === 1 ? '' : 's'}</span></div>
<div class="user-list scrollable" id="asgList-${key}">
${acctRowsHtml(key, 0, acctShown[key])}
<div class="asg-sentinel" id="asgSentinel-${key}" style="height:1px"></div>
</div>
</div>`;
}).join('');
return `<div class="account-sent-grid">${sections}</div>`;
}
// Re-bound after every render — the scroll box + sentinel for each bucket
// are rebuilt fresh whenever an archive is opened (they're part of the
// dashboard's innerHTML), so the previous archive's observer would be
// watching a detached node. `root` is the scroll box itself (not the page),
// since these are small internally-scrolling panels, not page-level scroll.
function rebindAccountScrollObservers() {
['pro', 'con', 'neutral', 'mixed'].forEach(key => {
if (acctObservers[key]) acctObservers[key].disconnect();
const root = document.getElementById('asgList-' + key);
const sentinel = document.getElementById('asgSentinel-' + key);
if (!root || !sentinel) return;
acctObservers[key] = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMoreAccounts(key);
}, { root, rootMargin: '80px' });
acctObservers[key].observe(sentinel);
});
}
function topEngagementRowHtml(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();
// Same itemDate()/formatItemDate() the Items list below uses — e's own
// shape ({engagement, item}) already matches what itemDate() expects
// (it reads `.item`), so this is the identical date, not a re-derived one.
const dt = itemDate(e);
const dateLabel = dt ? `<span class="item-date">${esc(formatItemDate(dt))}</span>` : '';
return `
<div class="eng-row">
<div class="eng-top">
<span class="eng-handle">@${esc(handle)}${verifiedBadgeHtml(it)}${ageBadgeHtml(it)}</span>
${dateLabel}
<span class="eng-score">${e.engagement.toLocaleString()} interactions</span>
</div>
<div class="eng-text">${esc(text)}</div>
</div>`;
}
function updateTopEngagementStatus() {
const status = document.getElementById('topEngagementStatus');
if (!status) return;
const remaining = topEngagementList.length - topEngagementShown;
if (remaining > 0) {
status.style.display = '';
} else {
status.style.display = 'none';
}
}
function loadMoreTopEngagement() {
if (topEngagementShown >= topEngagementList.length) return;
const prev = topEngagementShown;
topEngagementShown = Math.min(topEngagementShown + TOP_LIST_BATCH, topEngagementList.length);
const sentinel = document.getElementById('topEngagementSentinel');
if (sentinel) sentinel.insertAdjacentHTML('beforebegin', topEngagementList.slice(prev, topEngagementShown).map(topEngagementRowHtml).join(''));
updateTopEngagementStatus();
}
// Re-bound after every render — same reasoning as rebindAccountScrollObservers()
// just above: #topUsersBox/#topEngagementBox are rebuilt fresh whenever an
// archive is opened, so a previous archive's observer would be watching a
// detached node. `root` is each box itself (they scroll internally via
// .scrollable), not the page.
let topUsersObserver = null, topEngagementObserver = null;
// Split out from rebindTopListObservers() below — renderTopUsersBox()/
// renderTopEngagementBox() (each search box's own re-render, whenever its
// query changes) need to re-bind just their own observer to the fresh
// box/sentinel they just wrote, without touching the other one.
function rebindTopUsersObserver() {
if (topUsersObserver) topUsersObserver.disconnect();
const uRoot = document.getElementById('topUsersBox');
const uSentinel = document.getElementById('topUsersSentinel');
if (uRoot && uSentinel) {
topUsersObserver = new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) loadMoreTopUsers();
}, { root: uRoot, rootMargin: '80px' });
topUsersObserver.observe(uSentinel);
}
}
function rebindTopEngagementObserver() {
if (topEngagementObserver) topEngagementObserver.disconnect();
const eRoot = document.getElementById('topEngagementBox');
const eSentinel = document.getElementById('topEngagementSentinel');
if (eRoot && eSentinel) {
topEngagementObserver = new IntersectionObserver(entries => {
if (entries.some(e => e.isIntersecting)) loadMoreTopEngagement();
}, { root: eRoot, rootMargin: '80px' });
topEngagementObserver.observe(eSentinel);
}
}
function rebindTopListObservers() {
rebindTopUsersObserver();
updateTopUsersStatus();
rebindTopEngagementObserver();
updateTopEngagementStatus();
}
// topEngagementFull is every scored item sentiment.py returned (no backend
// cap, see top_engagement()'s own docstring); topEngagementList is whatever
// subset the search box below has currently narrowed that down to — the
// same "full data stays in memory, only rendering is paced/filtered" split
// the Items browser further down uses.
let topEngagementFull = [];
let topEngagementQuery = '';
function engagementMatchText(e) {
const it = e.item || {};
return [it.screen_name || it.user || it.name || '', it.text || it.full_text || it.post_title || it.post_text || it.description || '']
.join(' ').toLowerCase();
}
function renderTopEngagementBox() {
const box = document.getElementById('topEngagementBox');
if (!box) return;
topEngagementList = topEngagementQuery
? topEngagementFull.filter(e => engagementMatchText(e).includes(topEngagementQuery))
: topEngagementFull;
topEngagementShown = Math.min(TOP_LIST_BATCH, topEngagementList.length);
box.innerHTML = topEngagementList.length
? topEngagementList.slice(0, topEngagementShown).map(topEngagementRowHtml).join('') + '<div id="topEngagementSentinel" style="height:1px"></div>'
: '<div class="empty-state" style="padding:16px;font-size:12px">No matches.</div>';
const countEl = document.getElementById('topEngagementCount');
if (countEl) countEl.textContent = topEngagementQuery ? `${topEngagementList.length} / ${topEngagementFull.length}` : `${topEngagementFull.length}`;
updateTopEngagementStatus();
rebindTopEngagementObserver();
}
function topEngagementHtml(items) {
topEngagementFull = items || [];
topEngagementQuery = '';
topEngagementList = topEngagementFull;
topEngagementShown = Math.min(TOP_LIST_BATCH, topEngagementList.length);
if (!topEngagementFull.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 scrollable" id="topEngagementBox">
${topEngagementList.slice(0, topEngagementShown).map(topEngagementRowHtml).join('')}
<div id="topEngagementSentinel" style="height:1px"></div>
</div>
<div class="load-more-note" id="topEngagementStatus" style="display:none"></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>';
}
const ITEM_BATCH = 30;
let filteredItems = [];
let itemRenderCount = 0;
function itemCardHtml(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}`;
const dt = itemDate(it);
const dateLabel = dt ? `<span class="item-date">${esc(formatItemDate(dt))}</span>` : '';
return `
<div class="item-card">
<div class="item-card-top">
<span class="item-author">${it.author ? '@' + esc(it.author) : 'Unknown'}${verifiedBadgeHtml(it.item)}${ageBadgeHtml(it.item)}</span>
<span class="sent-badge sent-${it.label}">${SENT_LABELS[it.label]}</span>
${dateLabel}
<span class="item-score">${scoreLabel}</span>
</div>
<div class="item-text">${esc(it.text)}</div>
${matches ? `<div class="item-matches">${matches}</div>` : ''}
</div>`;
}
function updateItemListStatus() {
const status = document.getElementById('itemListStatus');
if (!status) return;
const remaining = filteredItems.length - itemRenderCount;
if (remaining > 0) {
status.style.display = '';
} else {
status.style.display = 'none';
}
}
function loadMoreItems() {
if (itemRenderCount >= filteredItems.length) return;
const prev = itemRenderCount;
itemRenderCount = Math.min(itemRenderCount + ITEM_BATCH, filteredItems.length);
document.getElementById('itemList').insertAdjacentHTML(
'beforeend',
filteredItems.slice(prev, itemRenderCount).map(itemCardHtml).join(''),
);
updateItemListStatus();
}
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 (activeDateFilter) items = items.filter(it => { const dt = itemDate(it); return dt && dateKey(dt) === activeDateFilter; });
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}`;
filteredItems = items;
itemRenderCount = Math.min(ITEM_BATCH, items.length);
const list = document.getElementById('itemList');
if (!items.length) {
list.innerHTML = '<div class="empty-state" style="padding:24px">No items match.</div>';
updateItemListStatus();
return;
}
list.innerHTML = items.slice(0, itemRenderCount).map(itemCardHtml).join('');
updateItemListStatus();
}
// root: null (page viewport) — same reasoning as index.html's own load-more
// observer: the item list is never height-constrained itself, the page is
// what actually scrolls. Re-observed (not just created once) after every
// render since #itemListSentinel is rebuilt along with the rest of the
// dashboard each time a different archive is opened.
const itemListObserver = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMoreItems();
}, { root: null, rootMargin: '300px' });
loadSidebar();
})();
</script>
</body>
</html>