mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-08-17 18:35:41 +02:00
1653 lines
74 KiB
HTML
1653 lines
74 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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)} ×</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>
|