| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>PulseAI β Social Intelligence Platform</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Mono:ital,wght@0,300;0,400;0,500;1,400&family=Instrument+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet"> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script> |
| <style> |
| |
| |
| |
| :root { |
| --bg-void: #080b12; |
| --bg-base: #0d1117; |
| --bg-surface: #111827; |
| --bg-elevated: #161f2e; |
| --bg-overlay: #1a2535; |
| |
| --border-subtle: rgba(255,255,255,0.05); |
| --border-default: rgba(255,255,255,0.09); |
| --border-strong: rgba(255,255,255,0.15); |
| |
| --text-primary: #f0f4ff; |
| --text-secondary: #8b9ab4; |
| --text-tertiary: #4a5568; |
| --text-accent: #5b9cf6; |
| |
| --blue-500: #5b9cf6; |
| --blue-400: #7db3f8; |
| --blue-300: #a5c8fb; |
| --blue-glow: rgba(91,156,246,0.15); |
| |
| --amber-500: #f59e0b; |
| --amber-400: #fbbf24; |
| --amber-glow: rgba(245,158,11,0.15); |
| |
| --green-500: #10b981; |
| --green-400: #34d399; |
| --green-glow: rgba(16,185,129,0.12); |
| |
| --red-500: #ef4444; |
| --red-400: #f87171; |
| --red-glow: rgba(239,68,68,0.12); |
| |
| --purple-500: #8b5cf6; |
| --purple-400: #a78bfa; |
| |
| --cyan-500: #06b6d4; |
| --cyan-400: #22d3ee; |
| |
| --font-display: 'Syne', sans-serif; |
| --font-body: 'Instrument Sans', sans-serif; |
| --font-mono: 'DM Mono', monospace; |
| |
| --radius-sm: 6px; |
| --radius-md: 10px; |
| --radius-lg: 14px; |
| --radius-xl: 20px; |
| |
| --sidebar-w: 240px; |
| --header-h: 60px; |
| } |
| |
| |
| |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| html { height: 100%; } |
| body { |
| font-family: var(--font-body); |
| background: var(--bg-void); |
| color: var(--text-primary); |
| height: 100%; |
| overflow: hidden; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| a { color: inherit; text-decoration: none; } |
| button { cursor: pointer; border: none; background: none; font-family: inherit; } |
| input, textarea { font-family: inherit; } |
| |
| |
| body::before { |
| content: ''; |
| position: fixed; |
| inset: 0; |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.035'/%3E%3C/svg%3E"); |
| pointer-events: none; |
| z-index: 9999; |
| opacity: 0.5; |
| } |
| |
| |
| |
| |
| .app-shell { |
| display: grid; |
| grid-template-columns: var(--sidebar-w) 1fr; |
| grid-template-rows: var(--header-h) 1fr; |
| height: 100vh; |
| overflow: hidden; |
| } |
| |
| |
| .header { |
| grid-column: 1 / -1; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 0 24px; |
| border-bottom: 1px solid var(--border-subtle); |
| background: var(--bg-base); |
| backdrop-filter: blur(20px); |
| position: relative; |
| z-index: 100; |
| } |
| |
| .logo { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| .logo-mark { |
| width: 32px; height: 32px; |
| background: linear-gradient(135deg, var(--blue-500) 0%, var(--purple-500) 100%); |
| border-radius: 8px; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 16px; |
| box-shadow: 0 0 20px var(--blue-glow); |
| } |
| .logo-text { |
| font-family: var(--font-display); |
| font-size: 18px; |
| font-weight: 800; |
| letter-spacing: -0.02em; |
| background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| } |
| .logo-tag { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--blue-500); |
| background: var(--blue-glow); |
| border: 1px solid rgba(91,156,246,0.2); |
| padding: 2px 7px; |
| border-radius: 4px; |
| letter-spacing: 0.05em; |
| text-transform: uppercase; |
| } |
| |
| .header-center { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| background: var(--bg-surface); |
| border: 1px solid var(--border-default); |
| border-radius: var(--radius-md); |
| padding: 6px 12px; |
| width: 320px; |
| } |
| .search-icon { color: var(--text-tertiary); flex-shrink: 0; } |
| .header-search { |
| background: none; |
| border: none; |
| color: var(--text-primary); |
| width: 100%; |
| font-size: 13px; |
| outline: none; |
| } |
| .header-search::placeholder { color: var(--text-tertiary); } |
| |
| .header-actions { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| |
| .status-pill { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| font-family: var(--font-mono); |
| font-size: 11px; |
| color: var(--green-400); |
| background: var(--green-glow); |
| border: 1px solid rgba(16,185,129,0.2); |
| padding: 4px 10px; |
| border-radius: 20px; |
| } |
| .status-dot { |
| width: 6px; height: 6px; |
| border-radius: 50%; |
| background: var(--green-400); |
| animation: pulse-dot 2s infinite; |
| } |
| @keyframes pulse-dot { |
| 0%, 100% { opacity: 1; transform: scale(1); } |
| 50% { opacity: 0.5; transform: scale(0.8); } |
| } |
| |
| .avatar { |
| width: 32px; height: 32px; |
| border-radius: 50%; |
| background: linear-gradient(135deg, var(--blue-500), var(--purple-500)); |
| display: flex; align-items: center; justify-content: center; |
| font-family: var(--font-display); |
| font-size: 12px; |
| font-weight: 700; |
| } |
| |
| |
| .sidebar { |
| background: var(--bg-base); |
| border-right: 1px solid var(--border-subtle); |
| display: flex; |
| flex-direction: column; |
| padding: 16px 0; |
| overflow: hidden; |
| } |
| |
| .nav-section-label { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| text-transform: uppercase; |
| letter-spacing: 0.1em; |
| color: var(--text-tertiary); |
| padding: 8px 20px 4px; |
| margin-top: 8px; |
| } |
| |
| .nav-item { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 9px 20px; |
| margin: 1px 8px; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| transition: all 0.15s ease; |
| font-size: 13px; |
| font-weight: 500; |
| color: var(--text-secondary); |
| position: relative; |
| } |
| .nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); } |
| .nav-item.active { |
| background: var(--blue-glow); |
| color: var(--blue-400); |
| border: 1px solid rgba(91,156,246,0.15); |
| } |
| .nav-item.active::before { |
| content: ''; |
| position: absolute; |
| left: 0; top: 50%; |
| transform: translateY(-50%); |
| width: 2px; height: 60%; |
| background: var(--blue-500); |
| border-radius: 0 2px 2px 0; |
| } |
| .nav-icon { font-size: 15px; width: 18px; text-align: center; flex-shrink: 0; } |
| |
| .nav-badge { |
| margin-left: auto; |
| font-family: var(--font-mono); |
| font-size: 10px; |
| padding: 2px 6px; |
| border-radius: 10px; |
| background: var(--red-glow); |
| color: var(--red-400); |
| border: 1px solid rgba(239,68,68,0.2); |
| } |
| .nav-badge.blue { |
| background: var(--blue-glow); |
| color: var(--blue-400); |
| border-color: rgba(91,156,246,0.2); |
| } |
| |
| .sidebar-bottom { |
| margin-top: auto; |
| padding: 16px; |
| border-top: 1px solid var(--border-subtle); |
| } |
| .sidebar-brand-select { |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-default); |
| border-radius: var(--radius-md); |
| padding: 10px 12px; |
| } |
| .brand-label { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--text-tertiary); |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| margin-bottom: 4px; |
| } |
| .brand-name { |
| font-family: var(--font-display); |
| font-size: 13px; |
| font-weight: 600; |
| color: var(--text-primary); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| |
| .main { |
| overflow-y: auto; |
| background: var(--bg-void); |
| scrollbar-width: thin; |
| scrollbar-color: var(--border-default) transparent; |
| } |
| .main::-webkit-scrollbar { width: 4px; } |
| .main::-webkit-scrollbar-track { background: transparent; } |
| .main::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 2px; } |
| |
| .view { display: none; padding: 24px; } |
| .view.active { display: block; } |
| |
| |
| .page-header { |
| display: flex; |
| align-items: flex-start; |
| justify-content: space-between; |
| margin-bottom: 24px; |
| } |
| .page-title { |
| font-family: var(--font-display); |
| font-size: 22px; |
| font-weight: 700; |
| letter-spacing: -0.02em; |
| } |
| .page-subtitle { |
| font-size: 13px; |
| color: var(--text-secondary); |
| margin-top: 2px; |
| } |
| |
| .header-actions-row { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .btn { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| padding: 8px 14px; |
| border-radius: var(--radius-sm); |
| font-size: 12px; |
| font-weight: 600; |
| letter-spacing: 0.01em; |
| transition: all 0.15s ease; |
| border: 1px solid transparent; |
| } |
| .btn-ghost { |
| color: var(--text-secondary); |
| border-color: var(--border-default); |
| background: transparent; |
| } |
| .btn-ghost:hover { background: var(--bg-surface); color: var(--text-primary); } |
| .btn-primary { |
| background: var(--blue-500); |
| color: white; |
| border-color: var(--blue-500); |
| box-shadow: 0 0 20px var(--blue-glow); |
| } |
| .btn-primary:hover { background: var(--blue-400); box-shadow: 0 0 30px var(--blue-glow); } |
| |
| |
| |
| |
| .grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } |
| .grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px; } |
| .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; } |
| .grid-3-1 { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; margin-bottom: 20px; } |
| .grid-1-2 { display: grid; grid-template-columns: 1fr 2fr; gap: 16px; margin-bottom: 20px; } |
| |
| .card { |
| background: var(--bg-surface); |
| border: 1px solid var(--border-subtle); |
| border-radius: var(--radius-lg); |
| padding: 20px; |
| position: relative; |
| overflow: hidden; |
| transition: border-color 0.2s ease; |
| } |
| .card:hover { border-color: var(--border-default); } |
| .card::after { |
| content: ''; |
| position: absolute; |
| inset: 0; |
| background: linear-gradient(135deg, rgba(255,255,255,0.015) 0%, transparent 60%); |
| pointer-events: none; |
| border-radius: inherit; |
| } |
| |
| |
| .kpi-card { |
| padding: 18px 20px; |
| } |
| .kpi-label { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| text-transform: uppercase; |
| letter-spacing: 0.1em; |
| color: var(--text-tertiary); |
| margin-bottom: 8px; |
| } |
| .kpi-value { |
| font-family: var(--font-display); |
| font-size: 28px; |
| font-weight: 800; |
| letter-spacing: -0.03em; |
| line-height: 1; |
| } |
| .kpi-sub { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| margin-top: 8px; |
| font-size: 12px; |
| color: var(--text-secondary); |
| } |
| .delta { |
| display: flex; |
| align-items: center; |
| gap: 3px; |
| font-family: var(--font-mono); |
| font-size: 11px; |
| font-weight: 500; |
| padding: 2px 6px; |
| border-radius: 4px; |
| } |
| .delta.pos { color: var(--green-400); background: var(--green-glow); } |
| .delta.neg { color: var(--red-400); background: var(--red-glow); } |
| .delta.neu { color: var(--amber-400); background: var(--amber-glow); } |
| |
| .kpi-accent { |
| position: absolute; |
| top: 0; right: 0; |
| width: 60px; height: 60px; |
| border-radius: 0 14px 0 60px; |
| opacity: 0.08; |
| } |
| |
| |
| .card-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 16px; |
| } |
| .card-title { |
| font-family: var(--font-display); |
| font-size: 13px; |
| font-weight: 700; |
| letter-spacing: -0.01em; |
| } |
| .card-tag { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| padding: 2px 8px; |
| border-radius: 4px; |
| background: var(--bg-overlay); |
| color: var(--text-tertiary); |
| border: 1px solid var(--border-default); |
| } |
| |
| |
| |
| |
| .sentiment-gauge-wrap { |
| display: flex; |
| align-items: center; |
| gap: 20px; |
| } |
| .gauge-svg { flex-shrink: 0; } |
| .gauge-legend { flex: 1; } |
| .gauge-item { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| margin-bottom: 10px; |
| } |
| .gauge-dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| flex-shrink: 0; |
| } |
| .gauge-item-label { |
| font-size: 12px; |
| color: var(--text-secondary); |
| flex: 1; |
| } |
| .gauge-item-val { |
| font-family: var(--font-mono); |
| font-size: 12px; |
| font-weight: 500; |
| color: var(--text-primary); |
| } |
| |
| |
| |
| |
| .chart-wrap { |
| position: relative; |
| height: 220px; |
| } |
| .chart-wrap-sm { height: 160px; } |
| .chart-wrap-lg { height: 280px; } |
| |
| |
| |
| |
| #topic-bubble-svg { |
| width: 100%; |
| overflow: visible; |
| } |
| .topic-bubble { cursor: pointer; transition: opacity 0.2s; } |
| .topic-bubble:hover { opacity: 0.85; } |
| |
| .topic-card-grid { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 10px; |
| } |
| .topic-chip { |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-subtle); |
| border-radius: var(--radius-md); |
| padding: 12px 14px; |
| cursor: pointer; |
| transition: all 0.15s ease; |
| } |
| .topic-chip:hover { border-color: var(--border-strong); background: var(--bg-overlay); } |
| .topic-chip.selected { border-color: var(--blue-500); background: var(--blue-glow); } |
| .topic-chip-name { |
| font-size: 12px; |
| font-weight: 600; |
| margin-bottom: 4px; |
| color: var(--text-primary); |
| } |
| .topic-chip-meta { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| .topic-count { |
| font-family: var(--font-mono); |
| font-size: 11px; |
| color: var(--text-tertiary); |
| } |
| .topic-sentiment-bar { |
| height: 4px; |
| border-radius: 2px; |
| background: var(--bg-void); |
| flex: 1; |
| overflow: hidden; |
| } |
| .topic-sentiment-fill { |
| height: 100%; |
| border-radius: 2px; |
| transition: width 0.8s ease; |
| } |
| |
| |
| |
| |
| .crisis-alert { |
| border-radius: var(--radius-md); |
| padding: 14px 16px; |
| margin-bottom: 12px; |
| display: flex; |
| align-items: flex-start; |
| gap: 12px; |
| border: 1px solid; |
| transition: all 0.2s ease; |
| } |
| .crisis-alert.critical { background: rgba(239,68,68,0.06); border-color: rgba(239,68,68,0.2); } |
| .crisis-alert.high { background: rgba(245,158,11,0.06); border-color: rgba(245,158,11,0.2); } |
| .crisis-alert.medium { background: rgba(16,185,129,0.04); border-color: rgba(16,185,129,0.15); } |
| .crisis-alert.low { background: var(--bg-elevated); border-color: var(--border-subtle); } |
| |
| .crisis-icon { font-size: 18px; flex-shrink: 0; margin-top: 1px; } |
| .crisis-content { flex: 1; min-width: 0; } |
| .crisis-title { |
| font-weight: 600; |
| font-size: 13px; |
| margin-bottom: 3px; |
| } |
| .crisis-desc { |
| font-size: 12px; |
| color: var(--text-secondary); |
| line-height: 1.5; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| .crisis-time { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--text-tertiary); |
| margin-top: 4px; |
| } |
| .crisis-score { |
| font-family: var(--font-mono); |
| font-size: 16px; |
| font-weight: 700; |
| flex-shrink: 0; |
| } |
| .crisis-score.critical { color: var(--red-400); } |
| .crisis-score.high { color: var(--amber-400); } |
| .crisis-score.medium { color: var(--green-400); } |
| |
| |
| |
| |
| .competitor-row { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| padding: 12px 0; |
| border-bottom: 1px solid var(--border-subtle); |
| } |
| .competitor-row:last-child { border-bottom: none; } |
| .competitor-name { |
| font-weight: 600; |
| font-size: 13px; |
| width: 90px; |
| flex-shrink: 0; |
| } |
| .competitor-name.own { color: var(--blue-400); } |
| .comp-bar-wrap { |
| flex: 1; |
| height: 8px; |
| background: var(--bg-elevated); |
| border-radius: 4px; |
| overflow: hidden; |
| } |
| .comp-bar { |
| height: 100%; |
| border-radius: 4px; |
| transition: width 1s ease; |
| } |
| .comp-score { |
| font-family: var(--font-mono); |
| font-size: 12px; |
| font-weight: 500; |
| width: 36px; |
| text-align: right; |
| flex-shrink: 0; |
| } |
| .comp-trend { |
| font-size: 11px; |
| width: 20px; |
| text-align: center; |
| flex-shrink: 0; |
| } |
| |
| |
| |
| |
| .post-feed { max-height: 420px; overflow-y: auto; } |
| .post-feed::-webkit-scrollbar { width: 3px; } |
| .post-feed::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 2px; } |
| |
| .post-item { |
| padding: 12px 0; |
| border-bottom: 1px solid var(--border-subtle); |
| cursor: default; |
| transition: all 0.15s ease; |
| } |
| .post-item:hover { padding-left: 6px; } |
| .post-item:last-child { border-bottom: none; } |
| .post-meta { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| margin-bottom: 5px; |
| } |
| .post-source { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| padding: 2px 7px; |
| border-radius: 4px; |
| background: var(--bg-overlay); |
| color: var(--text-tertiary); |
| } |
| .sentiment-pill { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| padding: 2px 7px; |
| border-radius: 4px; |
| font-weight: 500; |
| } |
| .sentiment-pill.positive { background: var(--green-glow); color: var(--green-400); } |
| .sentiment-pill.negative { background: var(--red-glow); color: var(--red-400); } |
| .sentiment-pill.neutral { background: var(--bg-overlay); color: var(--text-tertiary); } |
| .sentiment-pill.crisis { background: rgba(239,68,68,0.15); color: var(--red-400); } |
| |
| .post-text { |
| font-size: 13px; |
| color: var(--text-secondary); |
| line-height: 1.5; |
| display: -webkit-box; |
| -webkit-line-clamp: 2; |
| -webkit-box-orient: vertical; |
| overflow: hidden; |
| } |
| .post-time { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--text-tertiary); |
| margin-top: 4px; |
| } |
| |
| |
| |
| |
| .analyzer-textarea { |
| width: 100%; |
| min-height: 100px; |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-default); |
| border-radius: var(--radius-md); |
| padding: 14px; |
| color: var(--text-primary); |
| font-size: 14px; |
| resize: vertical; |
| outline: none; |
| transition: border-color 0.2s; |
| margin-bottom: 12px; |
| } |
| .analyzer-textarea:focus { border-color: var(--blue-500); } |
| .analyzer-textarea::placeholder { color: var(--text-tertiary); } |
| |
| .analyzer-result { |
| display: none; |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-default); |
| border-radius: var(--radius-md); |
| padding: 16px; |
| margin-top: 12px; |
| animation: slideUp 0.3s ease; |
| } |
| .analyzer-result.visible { display: block; } |
| @keyframes slideUp { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .result-label { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| margin-bottom: 12px; |
| } |
| .result-sentiment-badge { |
| font-family: var(--font-display); |
| font-size: 16px; |
| font-weight: 700; |
| padding: 6px 14px; |
| border-radius: var(--radius-sm); |
| } |
| .result-confidence { |
| font-family: var(--font-mono); |
| font-size: 12px; |
| color: var(--text-secondary); |
| } |
| |
| .aspect-grid { |
| display: grid; |
| grid-template-columns: repeat(2, 1fr); |
| gap: 8px; |
| margin-top: 12px; |
| } |
| .aspect-item { |
| background: var(--bg-surface); |
| border: 1px solid var(--border-subtle); |
| border-radius: var(--radius-sm); |
| padding: 8px 10px; |
| } |
| .aspect-name { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--text-tertiary); |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| margin-bottom: 3px; |
| } |
| .aspect-sentiment { |
| font-size: 12px; |
| font-weight: 600; |
| } |
| |
| |
| |
| |
| .loading-overlay { |
| position: fixed; |
| inset: 0; |
| background: var(--bg-void); |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| z-index: 1000; |
| gap: 20px; |
| transition: opacity 0.5s ease; |
| } |
| .loading-overlay.hidden { opacity: 0; pointer-events: none; } |
| .loading-logo { |
| font-family: var(--font-display); |
| font-size: 32px; |
| font-weight: 800; |
| letter-spacing: -0.03em; |
| background: linear-gradient(135deg, var(--blue-400) 0%, var(--purple-400) 100%); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| background-clip: text; |
| } |
| .loading-bar-wrap { |
| width: 200px; |
| height: 2px; |
| background: var(--bg-surface); |
| border-radius: 1px; |
| overflow: hidden; |
| } |
| .loading-bar { |
| height: 100%; |
| background: linear-gradient(90deg, var(--blue-500), var(--purple-500)); |
| border-radius: 1px; |
| animation: loadBar 2s ease infinite; |
| } |
| @keyframes loadBar { |
| 0% { width: 0%; margin-left: 0; } |
| 50% { width: 60%; } |
| 100% { width: 0%; margin-left: 100%; } |
| } |
| .loading-text { |
| font-family: var(--font-mono); |
| font-size: 12px; |
| color: var(--text-tertiary); |
| } |
| |
| |
| |
| |
| .fade-in { |
| animation: fadeIn 0.4s ease forwards; |
| } |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .stagger > * { opacity: 0; animation: fadeIn 0.4s ease forwards; } |
| .stagger > *:nth-child(1) { animation-delay: 0.05s; } |
| .stagger > *:nth-child(2) { animation-delay: 0.10s; } |
| .stagger > *:nth-child(3) { animation-delay: 0.15s; } |
| .stagger > *:nth-child(4) { animation-delay: 0.20s; } |
| .stagger > *:nth-child(5) { animation-delay: 0.25s; } |
| .stagger > *:nth-child(6) { animation-delay: 0.30s; } |
| .stagger > *:nth-child(7) { animation-delay: 0.35s; } |
| .stagger > *:nth-child(8) { animation-delay: 0.40s; } |
| |
| |
| |
| |
| * { scrollbar-width: thin; scrollbar-color: var(--border-default) transparent; } |
| |
| |
| |
| |
| .opportunity-card { |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-subtle); |
| border-radius: var(--radius-md); |
| padding: 14px; |
| margin-bottom: 10px; |
| position: relative; |
| overflow: hidden; |
| } |
| .opportunity-card::before { |
| content: ''; |
| position: absolute; |
| left: 0; top: 0; bottom: 0; |
| width: 3px; |
| background: var(--amber-500); |
| border-radius: 3px 0 0 3px; |
| } |
| .opportunity-card.high::before { background: var(--blue-500); } |
| .opp-tag { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| color: var(--amber-400); |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| margin-bottom: 4px; |
| } |
| .opp-tag.high { color: var(--blue-400); } |
| .opp-text { font-size: 12px; color: var(--text-secondary); line-height: 1.5; } |
| .opp-action { font-size: 11px; color: var(--text-primary); margin-top: 6px; font-weight: 500; } |
| |
| |
| .source-badge-row { display: flex; gap: 6px; flex-wrap: wrap; } |
| .source-badge { |
| font-family: var(--font-mono); |
| font-size: 10px; |
| padding: 3px 8px; |
| border-radius: 4px; |
| background: var(--bg-elevated); |
| border: 1px solid var(--border-default); |
| color: var(--text-secondary); |
| cursor: pointer; |
| transition: all 0.15s ease; |
| } |
| .source-badge:hover, .source-badge.active { background: var(--blue-glow); border-color: var(--blue-500); color: var(--blue-400); } |
| |
| |
| |
| |
| .mini-spark { display: block; overflow: visible; } |
| </style> |
| </head> |
|
|
| <body> |
|
|
| |
| <div class="loading-overlay" id="loadingOverlay"> |
| <div class="loading-logo">PulseAI</div> |
| <div class="loading-bar-wrap"> |
| <div class="loading-bar"></div> |
| </div> |
| <div class="loading-text" id="loadingText">Connecting to intelligence engine...</div> |
| </div> |
|
|
| <div class="app-shell"> |
|
|
| |
| <header class="header"> |
| <div class="logo"> |
| <div class="logo-mark">β‘</div> |
| <span class="logo-text">PulseAI</span> |
| <span class="logo-tag">BETA</span> |
| </div> |
| <div class="header-center"> |
| <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> |
| <input class="header-search" type="text" placeholder="Search posts, topics, keywordsβ¦" /> |
| </div> |
| <div class="header-actions"> |
| <div class="status-pill"><div class="status-dot"></div><span id="modelMode">Loadingβ¦</span></div> |
| <div class="avatar">AK</div> |
| </div> |
| </header> |
|
|
| |
| <nav class="sidebar"> |
| <div class="nav-section-label">Overview</div> |
| <div class="nav-item active" onclick="showView('dashboard', this)"> |
| <span class="nav-icon">β</span> Dashboard |
| </div> |
| <div class="nav-item" onclick="showView('trends', this)"> |
| <span class="nav-icon">β·</span> Trends |
| </div> |
|
|
| <div class="nav-section-label">Intelligence</div> |
| <div class="nav-item" onclick="showView('topics', this)"> |
| <span class="nav-icon">⬑</span> Topic Clusters |
| <span class="nav-badge blue" id="topicCount">β</span> |
| </div> |
| <div class="nav-item" onclick="showView('crisis', this)"> |
| <span class="nav-icon">β</span> Crisis Radar |
| <span class="nav-badge" id="crisisBadge">β</span> |
| </div> |
| <div class="nav-item" onclick="showView('competitors', this)"> |
| <span class="nav-icon">β</span> Competitors |
| </div> |
|
|
| <div class="nav-section-label">Tools</div> |
| <div class="nav-item" onclick="showView('analyzer', this)"> |
| <span class="nav-icon">β¬</span> Live Analyzer |
| </div> |
| <div class="nav-item" onclick="showView('feed', this)"> |
| <span class="nav-icon">β‘</span> Post Feed |
| </div> |
|
|
| <div class="sidebar-bottom"> |
| <div class="sidebar-brand-select"> |
| <div class="brand-label">Monitoring</div> |
| <div class="brand-name"> |
| TechFlow |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" opacity="0.4"><polyline points="6 9 12 15 18 9"/></svg> |
| </div> |
| </div> |
| </div> |
| </nav> |
|
|
| |
| |
| |
| <main class="main"> |
|
|
| |
| |
| |
| <div id="view-dashboard" class="view active"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Brand Intelligence Dashboard</div> |
| <div class="page-subtitle">Real-time sentiment, trends, and competitive signals for TechFlow</div> |
| </div> |
| <div class="header-actions-row"> |
| <button class="btn btn-ghost">β Export</button> |
| <button class="btn btn-primary">+ Add Source</button> |
| </div> |
| </div> |
|
|
| |
| <div class="grid-4 stagger" id="kpiRow"> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Brand Sentiment</div> |
| <div class="kpi-value" id="kpi-sentiment" style="color:var(--green-400)">β</div> |
| <div class="kpi-sub"> |
| <span class="delta pos" id="kpi-sentiment-delta">β</span> |
| <span>vs 30-day avg</span> |
| </div> |
| <div class="kpi-accent" style="background:var(--green-500)"></div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Post Volume</div> |
| <div class="kpi-value" id="kpi-volume" style="color:var(--blue-400)">β</div> |
| <div class="kpi-sub"> |
| <span class="delta neu" id="kpi-vol-trend">β</span> |
| <span>volume trend</span> |
| </div> |
| <div class="kpi-accent" style="background:var(--blue-500)"></div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Net Promoter (est.)</div> |
| <div class="kpi-value" id="kpi-nps" style="color:var(--amber-400)">β</div> |
| <div class="kpi-sub"> |
| <span class="delta pos" id="kpi-nps-sub">β</span> |
| <span>positive posts</span> |
| </div> |
| <div class="kpi-accent" style="background:var(--amber-500)"></div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Crisis Alert</div> |
| <div class="kpi-value" id="kpi-crisis" style="color:var(--red-400); font-size:20px;">β</div> |
| <div class="kpi-sub"> |
| <span class="delta neg" id="kpi-crisis-count">β</span> |
| <span>active signals</span> |
| </div> |
| <div class="kpi-accent" style="background:var(--red-500)"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="grid-3-1"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Sentiment Trend β 90 Days</span> |
| <span class="card-tag" id="trendTag">β</span> |
| </div> |
| <div class="chart-wrap-lg"> |
| <canvas id="trendChart"></canvas> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Sentiment Mix</span> |
| <span class="card-tag">Last 30d</span> |
| </div> |
| <div class="sentiment-gauge-wrap" style="margin-top: 10px;"> |
| <canvas id="sentimentDonut" width="130" height="130" class="gauge-svg"></canvas> |
| <div class="gauge-legend" id="sentimentLegend"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="grid-2"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Volume by Source</span> |
| <span class="card-tag">All time</span> |
| </div> |
| <div class="chart-wrap"> |
| <canvas id="sourceChart"></canvas> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">π΄ Top Crisis Signals</span> |
| <span class="card-tag" id="crisisAlertLevel">β</span> |
| </div> |
| <div id="topCrisisList"></div> |
| </div> |
| </div> |
|
|
| |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Recent Posts</span> |
| <div class="source-badge-row" id="filterBadges"> |
| <span class="source-badge active" onclick="filterPosts(null, this)">All</span> |
| <span class="source-badge" onclick="filterPosts('positive', this)">Positive</span> |
| <span class="source-badge" onclick="filterPosts('negative', this)">Negative</span> |
| <span class="source-badge" onclick="filterPosts('crisis', this)">Crisis</span> |
| </div> |
| </div> |
| <div class="post-feed" id="postFeed"></div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-trends" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Trend Analysis & Forecasting</div> |
| <div class="page-subtitle">Sentiment momentum, anomaly detection, and 14-day forecast</div> |
| </div> |
| </div> |
|
|
| <div class="grid-4 stagger" id="trendKpis"> |
| <div class="card kpi-card"> |
| <div class="kpi-label">7-Day Avg Sentiment</div> |
| <div class="kpi-value" id="t-kpi-7d" style="color:var(--blue-400)">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">30-Day Avg Sentiment</div> |
| <div class="kpi-value" id="t-kpi-30d" style="color:var(--text-secondary)">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Trend Direction</div> |
| <div class="kpi-value" id="t-kpi-dir" style="font-size:14px; padding-top:8px;">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Anomalies Detected</div> |
| <div class="kpi-value" id="t-kpi-anomalies" style="color:var(--amber-400)">β</div> |
| </div> |
| </div> |
|
|
| <div class="card" style="margin-bottom:20px;"> |
| <div class="card-header"> |
| <span class="card-title">Sentiment Timeline + Forecast (14-Day)</span> |
| <div style="display:flex;gap:12px;align-items:center;"> |
| <span style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-secondary)"><span style="width:16px;height:2px;background:var(--blue-500);display:inline-block;border-radius:2px;"></span>Actual</span> |
| <span style="display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-secondary)"><span style="width:16px;height:2px;background:var(--purple-500);display:inline-block;border-radius:2px;border-top:2px dashed var(--purple-500);"></span>Forecast</span> |
| </div> |
| </div> |
| <div class="chart-wrap-lg"> |
| <canvas id="forecastChart"></canvas> |
| </div> |
| </div> |
|
|
| <div class="grid-2"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Volume Trend</span> |
| <span class="card-tag">Daily posts</span> |
| </div> |
| <div class="chart-wrap"> |
| <canvas id="volumeChart"></canvas> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Anomalies Detected</span> |
| <span class="card-tag">Statistical outliers</span> |
| </div> |
| <div id="anomalyList" style="max-height:220px;overflow-y:auto;"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-topics" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Topic Intelligence</div> |
| <div class="page-subtitle">NMF-powered topic discovery across all customer conversations</div> |
| </div> |
| <div class="header-actions-row"> |
| <span style="font-family:var(--font-mono);font-size:11px;color:var(--text-tertiary)">Model: NMF + TF-IDF</span> |
| </div> |
| </div> |
|
|
| <div class="grid-1-2" style="align-items:start;"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Topic Clusters</span> |
| <span class="card-tag" id="topicTotalPosts">β</span> |
| </div> |
| <div class="topic-card-grid" id="topicChips"></div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Cluster Distribution</span> |
| <span class="card-tag">Bubble = post volume</span> |
| </div> |
| <svg id="topic-bubble-svg" height="340"></svg> |
| </div> |
| </div> |
|
|
| <div class="card" id="topicDetailCard" style="display:none;"> |
| <div class="card-header"> |
| <span class="card-title" id="topicDetailName">β</span> |
| <span class="card-tag" id="topicDetailCount">β</span> |
| </div> |
| <div class="grid-2" style="margin-bottom:0;"> |
| <div> |
| <div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--text-tertiary);margin-bottom:10px;">Top Keywords</div> |
| <div id="topicKeywords" style="display:flex;flex-wrap:wrap;gap:6px;"></div> |
| </div> |
| <div> |
| <div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--text-tertiary);margin-bottom:10px;">Sample Posts</div> |
| <div id="topicExamples"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-crisis" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Crisis Radar</div> |
| <div class="page-subtitle">Multi-signal brand crisis detection and escalation intelligence</div> |
| </div> |
| </div> |
|
|
| <div class="grid-4 stagger"> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Overall Alert Level</div> |
| <div class="kpi-value" id="c-overall" style="font-size:18px;padding-top:4px;">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Crisis Posts</div> |
| <div class="kpi-value" id="c-total" style="color:var(--red-400)">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Active High-Severity</div> |
| <div class="kpi-value" id="c-active" style="color:var(--amber-400)">β</div> |
| </div> |
| <div class="card kpi-card"> |
| <div class="kpi-label">Top Signal</div> |
| <div class="kpi-value" id="c-top-signal" style="font-size:14px;padding-top:8px;">β</div> |
| </div> |
| </div> |
|
|
| <div class="grid-2"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Active Crisis Posts</span> |
| <span class="card-tag">By severity</span> |
| </div> |
| <div id="crisisList" style="max-height:360px;overflow-y:auto;"></div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Signal Frequency</span> |
| <span class="card-tag">Crisis categories</span> |
| </div> |
| <div class="chart-wrap"> |
| <canvas id="signalChart"></canvas> |
| </div> |
| <div style="margin-top:16px;"> |
| <div class="card-header" style="margin-bottom:8px;"> |
| <span class="card-title">Recommended Actions</span> |
| </div> |
| <div id="crisisActions"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-competitors" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Competitive Intelligence</div> |
| <div class="page-subtitle">Competitor sentiment, share of voice, and opportunity signals</div> |
| </div> |
| </div> |
|
|
| <div class="grid-2" style="align-items:start;"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Sentiment Comparison</span> |
| <span class="card-tag">Score = % positive mentions</span> |
| </div> |
| <div id="competitorRows" style="margin-top:8px;"></div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Share of Voice</span> |
| <span class="card-tag">% of corpus mentions</span> |
| </div> |
| <div class="chart-wrap"> |
| <canvas id="sovChart"></canvas> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">π‘ Opportunity Intelligence</span> |
| <span class="card-tag">AI-identified gaps</span> |
| </div> |
| <div id="opportunityList" style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;"></div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-analyzer" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Live Text Analyzer</div> |
| <div class="page-subtitle">Real-time BERT-powered sentiment, aspect, and crisis scoring</div> |
| </div> |
| </div> |
|
|
| <div class="grid-2" style="align-items:start;"> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Analyze Text</span> |
| <span class="card-tag" id="analyzerModelBadge">β</span> |
| </div> |
| <textarea class="analyzer-textarea" id="analyzerInput" placeholder="Paste a customer review, tweet, or support ticket here⦠|
| |
| Example: The dashboard is beautiful but loading times are painfully slow. Considering switching to a competitor if this isn't fixed soon."></textarea> |
| <button class="btn btn-primary" onclick="runAnalysis()" style="width:100%;justify-content:center;" id="analyzeBtn"> |
| β‘ Analyze |
| </button> |
| <div class="analyzer-result" id="analyzerResult"> |
| <div class="result-label"> |
| <span class="result-sentiment-badge" id="resultBadge">β</span> |
| <span class="result-confidence" id="resultConf">β</span> |
| </div> |
| <div id="crisisResultBox"></div> |
| <div> |
| <div style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--text-tertiary);margin-bottom:8px;margin-top:12px;">Detected Aspects</div> |
| <div class="aspect-grid" id="aspectGrid"></div> |
| </div> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <span class="card-title">Quick Examples</span> |
| </div> |
| <div id="exampleList"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| |
| |
| <div id="view-feed" class="view"> |
| <div class="page-header"> |
| <div> |
| <div class="page-title">Post Feed</div> |
| <div class="page-subtitle">All monitored posts with sentiment and topic labels</div> |
| </div> |
| </div> |
| <div class="card"> |
| <div class="card-header"> |
| <div class="source-badge-row"> |
| <span class="source-badge active" onclick="filterFeedPosts(null, this)">All</span> |
| <span class="source-badge" onclick="filterFeedPosts('positive', this)">β Positive</span> |
| <span class="source-badge" onclick="filterFeedPosts('negative', this)">β Negative</span> |
| <span class="source-badge" onclick="filterFeedPosts('neutral', this)">β Neutral</span> |
| <span class="source-badge" onclick="filterFeedPosts('crisis', this)">β Crisis</span> |
| </div> |
| </div> |
| <div class="post-feed" id="fullPostFeed" style="max-height:600px;"></div> |
| </div> |
| </div> |
|
|
| </main> |
| </div> |
|
|
| <script> |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| GLOBAL STATE & CONFIG |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| const API = 'http://localhost:8000/api'; |
| let _data = null; |
| let _posts = []; |
| let _currentFilter = null; |
| let _charts = {}; |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| NAVIGATION |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function showView(viewId, navEl) { |
| document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); |
| document.getElementById(`view-${viewId}`).classList.add('active'); |
| if (navEl) navEl.classList.add('active'); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| DATA LOADING |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| async function loadDashboard() { |
| const loadingTexts = [ |
| 'Connecting to intelligence engine...', |
| 'Running BERT sentiment pipeline...', |
| 'Fitting topic model (NMF)...', |
| 'Analyzing competitor signals...', |
| 'Running crisis detection scan...', |
| 'Building trend forecasts...', |
| 'Assembling dashboard...' |
| ]; |
| |
| let ti = 0; |
| const loadEl = document.getElementById('loadingText'); |
| const loadInterval = setInterval(() => { |
| ti = (ti + 1) % loadingTexts.length; |
| loadEl.textContent = loadingTexts[ti]; |
| }, 1800); |
| |
| try { |
| // Poll until backend is ready |
| let ready = false; |
| for (let attempt = 0; attempt < 30; attempt++) { |
| try { |
| const health = await fetch(`${API}/health`); |
| const hd = await health.json(); |
| if (hd.initialized) { ready = true; break; } |
| } catch {} |
| await new Promise(r => setTimeout(r, 2000)); |
| } |
| |
| if (!ready) throw new Error('Backend timeout'); |
| |
| const [dashRes, postsRes] = await Promise.all([ |
| fetch(`${API}/dashboard`), |
| fetch(`${API}/posts?limit=200`), |
| ]); |
| |
| _data = await dashRes.json(); |
| const postsData = await postsRes.json(); |
| _posts = postsData.posts; |
| |
| clearInterval(loadInterval); |
| renderAll(); |
| |
| setTimeout(() => { |
| document.getElementById('loadingOverlay').classList.add('hidden'); |
| }, 500); |
| |
| } catch (err) { |
| clearInterval(loadInterval); |
| // Show demo data if backend unavailable |
| loadEl.textContent = 'Backend offline β showing demo data'; |
| _data = getDemoData(); |
| _posts = getDemoPosts(); |
| renderAll(); |
| setTimeout(() => document.getElementById('loadingOverlay').classList.add('hidden'), 1500); |
| } |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| RENDER ORCHESTRATOR |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderAll() { |
| const s = _data.summary; |
| const meta = _data.meta || {}; |
| |
| document.getElementById('modelMode').textContent = (meta.model_mode || 'demo') + ' mode'; |
| |
| renderKPIs(s); |
| renderTrendChart(); |
| renderSentimentDonut(s); |
| renderSourceChart(); |
| renderTopCrisis(); |
| renderPostFeed(); |
| renderTrendsView(); |
| renderTopicsView(); |
| renderCrisisView(); |
| renderCompetitorView(); |
| renderAnalyzerView(); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| KPIs |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderKPIs(s) { |
| document.getElementById('kpi-sentiment').textContent = pct(s.overall_sentiment); |
| const delta = s.delta || 0; |
| const dEl = document.getElementById('kpi-sentiment-delta'); |
| dEl.textContent = (delta >= 0 ? '+' : '') + pct(delta); |
| dEl.className = `delta ${delta >= 0 ? 'pos' : 'neg'}`; |
| |
| document.getElementById('kpi-volume').textContent = fmt(s.total_volume); |
| const volEl = document.getElementById('kpi-vol-trend'); |
| const vt = s.volume_trend || _data.trends?.trend?.volume_trend || 'stable'; |
| volEl.textContent = { growing: 'β Growing', shrinking: 'β Shrinking', stable: 'β Stable' }[vt] || vt; |
| volEl.className = `delta ${vt === 'growing' ? 'pos' : vt === 'shrinking' ? 'neg' : 'neu'}`; |
| |
| document.getElementById('kpi-nps').textContent = s.nps_estimate > 0 ? '+' + s.nps_estimate : s.nps_estimate; |
| document.getElementById('kpi-nps-sub').textContent = `${s.positive_pct}%`; |
| |
| const alertMap = { low:'π’ LOW', medium:'π‘ MEDIUM', high:'π HIGH', critical:'π΄ CRITICAL' }; |
| document.getElementById('kpi-crisis').textContent = alertMap[s.crisis_alert] || s.crisis_alert?.toUpperCase(); |
| document.getElementById('kpi-crisis-count').textContent = `${_data.crisis?.active_crises || 0} high+`; |
| |
| // Update badge |
| const cb = document.getElementById('crisisBadge'); |
| const ca = _data.crisis?.active_crises || 0; |
| cb.textContent = ca; |
| if (s.crisis_alert === 'high' || s.crisis_alert === 'critical') cb.style.background = 'rgba(239,68,68,0.15)'; |
| |
| document.getElementById('topicCount').textContent = _data.topics?.length || 'β'; |
| document.getElementById('crisisAlertLevel').textContent = (s.crisis_alert || 'low').toUpperCase(); |
| document.getElementById('trendTag').textContent = (_data.trends?.trend?.direction || 'β').toUpperCase(); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| CHARTS |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| const chartDefaults = { |
| plugins: { legend: { display: false }, tooltip: { callbacks: {} } }, |
| scales: {}, |
| animation: { duration: 800, easing: 'easeOutQuart' }, |
| }; |
| |
| function getCtx(id) { return document.getElementById(id)?.getContext('2d'); } |
| |
| function destroyChart(id) { |
| if (_charts[id]) { _charts[id].destroy(); delete _charts[id]; } |
| } |
| |
| function renderTrendChart() { |
| destroyChart('trendChart'); |
| const series = _data.trends?.time_series || []; |
| if (!series.length) return; |
| |
| const ctx = getCtx('trendChart'); |
| _charts.trendChart = new Chart(ctx, { |
| type: 'line', |
| data: { |
| labels: series.map(d => d.date), |
| datasets: [{ |
| label: 'Sentiment', |
| data: series.map(d => (d.sentiment * 100).toFixed(1)), |
| borderColor: '#5b9cf6', |
| backgroundColor: createGradient(ctx, '#5b9cf6', 0.15, 0.01), |
| borderWidth: 2, |
| fill: true, |
| tension: 0.4, |
| pointRadius: 0, |
| pointHoverRadius: 4, |
| }] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, |
| plugins: { legend: { display: false }, tooltip: { |
| mode: 'index', intersect: false, |
| callbacks: { label: c => ` Sentiment: ${c.raw}%` } |
| }}, |
| scales: { |
| x: { type: 'time', time: { unit: 'week' }, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } }, |
| y: { min: 0, max: 100, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 }, callback: v => v + '%' } } |
| } |
| } |
| }); |
| } |
| |
| function createGradient(ctx, color, alphaTop, alphaBottom) { |
| const gradient = ctx.createLinearGradient(0, 0, 0, 300); |
| gradient.addColorStop(0, color + Math.round(alphaTop * 255).toString(16).padStart(2,'0')); |
| gradient.addColorStop(1, color + Math.round(alphaBottom * 255).toString(16).padStart(2,'0')); |
| return gradient; |
| } |
| |
| function renderSentimentDonut(s) { |
| destroyChart('sentimentDonut'); |
| const ctx = getCtx('sentimentDonut'); |
| const pos = s.positive_count || 0, neg = s.negative_count || 0, neu = s.neutral_count || 0; |
| _charts.sentimentDonut = new Chart(ctx, { |
| type: 'doughnut', |
| data: { |
| labels: ['Positive', 'Negative', 'Neutral'], |
| datasets: [{ data: [pos, neg, neu], backgroundColor: ['#10b981', '#ef4444', '#4a5568'], borderWidth: 0, borderRadius: 3, spacing: 2 }] |
| }, |
| options: { |
| responsive: false, cutout: '70%', |
| plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => ` ${c.label}: ${fmt(c.raw)}` } } } |
| } |
| }); |
| |
| const total = pos + neg + neu; |
| document.getElementById('sentimentLegend').innerHTML = [ |
| { label: 'Positive', count: pos, color: '#10b981' }, |
| { label: 'Negative', count: neg, color: '#ef4444' }, |
| { label: 'Neutral', count: neu, color: '#4a5568' }, |
| ].map(i => ` |
| <div class="gauge-item"> |
| <div class="gauge-dot" style="background:${i.color}"></div> |
| <span class="gauge-item-label">${i.label}</span> |
| <span class="gauge-item-val">${fmt(i.count)} <span style="color:var(--text-tertiary);font-size:10px;">${pct(i.count/total)}</span></span> |
| </div> |
| `).join(''); |
| } |
| |
| function renderSourceChart() { |
| destroyChart('sourceChart'); |
| const ctx = getCtx('sourceChart'); |
| const sources = {}; |
| _posts.forEach(p => { sources[p.source] = (sources[p.source] || 0) + 1; }); |
| const sorted = Object.entries(sources).sort((a,b) => b[1]-a[1]); |
| const colors = ['#5b9cf6','#10b981','#f59e0b','#8b5cf6','#06b6d4','#ef4444']; |
| _charts.sourceChart = new Chart(ctx, { |
| type: 'bar', |
| data: { |
| labels: sorted.map(s => s[0]), |
| datasets: [{ data: sorted.map(s => s[1]), backgroundColor: colors, borderRadius: 4, borderSkipped: false }] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, indexAxis: 'y', |
| plugins: { legend: { display: false }, tooltip: { callbacks: { label: c => ` ${c.raw} posts` } } }, |
| scales: { |
| x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } }, |
| y: { grid: { display: false }, ticks: { color: '#8b9ab4', font: { size: 12 } } } |
| } |
| } |
| }); |
| } |
| |
| function renderTopCrisis() { |
| const posts = _data.crisis?.top_crisis_posts?.slice(0, 4) || []; |
| document.getElementById('topCrisisList').innerHTML = posts.map(p => { |
| const lvl = p.alert_level || 'medium'; |
| return `<div class="crisis-alert ${lvl}"> |
| <div class="crisis-icon">${{critical:'π΄',high:'π ',medium:'π‘',low:'π’'}[lvl]||'π‘'}</div> |
| <div class="crisis-content"> |
| <div class="crisis-title">${p.source || 'Unknown'} Β· ${p.triggered_signals?.[0]?.signal?.replace(/_/g,' ') || 'signal'}</div> |
| <div class="crisis-desc">${esc(p.text)}</div> |
| <div class="crisis-time">${relTime(p.timestamp)}</div> |
| </div> |
| <div class="crisis-score ${lvl}">${Math.round(p.crisis_score||0)}</div> |
| </div>`; |
| }).join('') || '<div style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:12px;">No crisis signals detected</div>'; |
| } |
| |
| function renderPostFeed(filter = null) { |
| const container = document.getElementById('postFeed'); |
| let posts = _posts.slice(0, 100); |
| if (filter) posts = posts.filter(p => p.sentiment === filter || p.true_label === filter); |
| container.innerHTML = posts.slice(0, 30).map(p => postHTML(p)).join(''); |
| } |
| |
| function filterPosts(sentiment, el) { |
| _currentFilter = sentiment; |
| document.querySelectorAll('#filterBadges .source-badge').forEach(b => b.classList.remove('active')); |
| el.classList.add('active'); |
| renderPostFeed(sentiment); |
| } |
| |
| function filterFeedPosts(sentiment, el) { |
| document.querySelectorAll('#view-feed .source-badge').forEach(b => b.classList.remove('active')); |
| el.classList.add('active'); |
| const container = document.getElementById('fullPostFeed'); |
| let posts = _posts.slice(0, 200); |
| if (sentiment) posts = posts.filter(p => (p.sentiment || p.true_label) === sentiment); |
| container.innerHTML = posts.map(p => postHTML(p)).join(''); |
| } |
| |
| function postHTML(p) { |
| const sent = p.sentiment || p.true_label || 'neutral'; |
| return `<div class="post-item"> |
| <div class="post-meta"> |
| <span class="post-source">${p.source||'β'}</span> |
| <span class="sentiment-pill ${sent}">${sent}</span> |
| ${p.topic_name ? `<span class="post-source">${p.topic_name}</span>` : ''} |
| </div> |
| <div class="post-text">${esc(p.text)}</div> |
| <div class="post-time">${relTime(p.timestamp)}</div> |
| </div>`; |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| TRENDS VIEW |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderTrendsView() { |
| const t = _data.trends || {}; |
| const trend = t.trend || {}; |
| const series = t.time_series || []; |
| const forecast = t.forecast || []; |
| const anomalies = t.anomalies || []; |
| |
| document.getElementById('t-kpi-7d').textContent = pct(trend.avg_7d); |
| document.getElementById('t-kpi-30d').textContent = pct(trend.avg_30d); |
| const dirMap = { improving: 'β Improving', declining: 'β Declining', stable: 'β Stable' }; |
| document.getElementById('t-kpi-dir').textContent = dirMap[trend.direction] || 'β'; |
| document.getElementById('t-kpi-anomalies').textContent = anomalies.length; |
| |
| // Forecast chart |
| destroyChart('forecastChart'); |
| const ctx = getCtx('forecastChart'); |
| if (!ctx) return; |
| |
| const allDates = [...series.map(d => d.date), ...forecast.map(d => d.date)]; |
| const actualData = series.map(d => ({ x: d.date, y: (d.sentiment * 100).toFixed(1) })); |
| const forecastData = [ |
| { x: series[series.length-1]?.date, y: (series[series.length-1]?.sentiment * 100).toFixed(1) }, |
| ...forecast.map(d => ({ x: d.date, y: (d.sentiment * 100).toFixed(1) })) |
| ]; |
| const upperBand = [ |
| { x: series[series.length-1]?.date, y: null }, |
| ...forecast.map(d => ({ x: d.date, y: (d.upper * 100).toFixed(1) })) |
| ]; |
| const lowerBand = [ |
| { x: series[series.length-1]?.date, y: null }, |
| ...forecast.map(d => ({ x: d.date, y: (d.lower * 100).toFixed(1) })) |
| ]; |
| |
| _charts.forecastChart = new Chart(ctx, { |
| type: 'line', |
| data: { |
| datasets: [ |
| { label: 'Sentiment', data: actualData, borderColor: '#5b9cf6', borderWidth: 2, fill: false, tension: 0.4, pointRadius: 0 }, |
| { label: 'Forecast', data: forecastData, borderColor: '#8b5cf6', borderWidth: 2, borderDash: [6,3], fill: false, tension: 0.3, pointRadius: 0 }, |
| { label: 'Upper', data: upperBand, borderColor: 'transparent', backgroundColor: 'rgba(139,92,246,0.08)', fill: '+1', pointRadius: 0 }, |
| { label: 'Lower', data: lowerBand, borderColor: 'transparent', fill: false, pointRadius: 0 }, |
| ] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, |
| plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false } }, |
| scales: { |
| x: { type: 'time', time: { unit: 'week' }, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } }, |
| y: { min: 20, max: 100, grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 }, callback: v => v + '%' } } |
| } |
| } |
| }); |
| |
| // Volume chart |
| destroyChart('volumeChart'); |
| const vCtx = getCtx('volumeChart'); |
| _charts.volumeChart = new Chart(vCtx, { |
| type: 'bar', |
| data: { |
| labels: series.map(d => d.date), |
| datasets: [{ label: 'Posts', data: series.map(d => d.volume), backgroundColor: 'rgba(91,156,246,0.4)', borderColor: '#5b9cf6', borderWidth: 1, borderRadius: 2 }] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, |
| plugins: { legend: { display: false } }, |
| scales: { |
| x: { type: 'time', time: { unit: 'week' }, grid: { display: false }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } }, |
| y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } } |
| } |
| } |
| }); |
| |
| // Anomaly list |
| document.getElementById('anomalyList').innerHTML = anomalies.length |
| ? anomalies.map(a => ` |
| <div class="crisis-alert ${a.severity || 'medium'}" style="margin-bottom:8px;"> |
| <div class="crisis-icon">${a.direction === 'spike' ? 'β' : 'β'}</div> |
| <div class="crisis-content"> |
| <div class="crisis-title">${a.date} β ${a.direction === 'spike' ? 'Positive Spike' : 'Sentiment Dip'}</div> |
| <div class="crisis-desc">Z-score: ${a.z_score} Β· Sentiment: ${pct(a.sentiment)}</div> |
| </div> |
| </div> |
| `).join('') |
| : '<div style="padding:20px;text-align:center;color:var(--text-tertiary);font-size:12px;">No significant anomalies detected in window</div>'; |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| TOPICS VIEW |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| let _selectedTopic = null; |
| |
| function renderTopicsView() { |
| const topics = _data.topics || []; |
| const total = topics.reduce((s, t) => s + t.post_count, 0); |
| document.getElementById('topicTotalPosts').textContent = `${total} posts`; |
| |
| const chipContainer = document.getElementById('topicChips'); |
| chipContainer.innerHTML = topics.map((t, i) => { |
| const sentColor = { positive: '#10b981', negative: '#ef4444', neutral: '#4a5568', crisis: '#ef4444' }[t.dominant_sentiment] || '#4a5568'; |
| const pct_v = t.post_count / total; |
| return `<div class="topic-chip" id="chip-${i}" onclick="selectTopic(${i})"> |
| <div class="topic-chip-name">${t.name}</div> |
| <div class="topic-chip-meta"> |
| <span class="topic-count">${t.post_count} posts</span> |
| <div class="topic-sentiment-bar"> |
| <div class="topic-sentiment-fill" style="width:${Math.round(pct_v*100*4)}%;background:${sentColor};"></div> |
| </div> |
| </div> |
| </div>`; |
| }).join(''); |
| |
| renderBubbleChart(topics); |
| } |
| |
| function selectTopic(i) { |
| const topics = _data.topics || []; |
| const t = topics[i]; |
| if (!t) return; |
| |
| document.querySelectorAll('.topic-chip').forEach(c => c.classList.remove('selected')); |
| document.getElementById(`chip-${i}`)?.classList.add('selected'); |
| |
| const card = document.getElementById('topicDetailCard'); |
| card.style.display = 'block'; |
| card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| |
| document.getElementById('topicDetailName').textContent = t.name; |
| document.getElementById('topicDetailCount').textContent = `${t.post_count} posts Β· ${t.percentage}%`; |
| |
| const kwColors = ['#5b9cf6','#10b981','#f59e0b','#8b5cf6','#06b6d4','#ef4444','#f472b6','#34d399']; |
| document.getElementById('topicKeywords').innerHTML = t.keywords.map((kw, ki) => ` |
| <span style="background:${kwColors[ki%kwColors.length]}18;border:1px solid ${kwColors[ki%kwColors.length]}40;color:${kwColors[ki%kwColors.length]};padding:4px 10px;border-radius:4px;font-size:12px;font-family:var(--font-mono);">${kw}</span> |
| `).join(''); |
| |
| document.getElementById('topicExamples').innerHTML = (t.examples || []).map(ex => ` |
| <div style="padding:8px;background:var(--bg-elevated);border-radius:6px;margin-bottom:6px;font-size:12px;color:var(--text-secondary);line-height:1.5;">${esc(ex.substring(0,140))}${ex.length > 140 ? 'β¦' : ''}</div> |
| `).join(''); |
| } |
| |
| function renderBubbleChart(topics) { |
| const svg = d3.select('#topic-bubble-svg'); |
| svg.selectAll('*').remove(); |
| |
| const W = document.getElementById('topic-bubble-svg').parentElement.clientWidth - 40; |
| const H = 340; |
| svg.attr('viewBox', `0 0 ${W} ${H}`); |
| |
| const maxCount = Math.max(...topics.map(t => t.post_count)); |
| const r = d3.scaleSqrt().domain([0, maxCount]).range([20, 60]); |
| const sentColors = { positive: '#10b981', negative: '#ef4444', neutral: '#4a5568', crisis: '#ef4444' }; |
| |
| const simulation = d3.forceSimulation(topics) |
| .force('center', d3.forceCenter(W/2, H/2)) |
| .force('charge', d3.forceManyBody().strength(5)) |
| .force('collision', d3.forceCollide().radius(d => r(d.post_count) + 4)) |
| .stop(); |
| |
| for (let i = 0; i < 120; i++) simulation.tick(); |
| |
| const node = svg.selectAll('g').data(topics).enter().append('g') |
| .attr('transform', d => `translate(${Math.max(r(d.post_count), Math.min(W-r(d.post_count), d.x||W/2))},${Math.max(r(d.post_count), Math.min(H-r(d.post_count), d.y||H/2))})`) |
| .style('cursor', 'pointer') |
| .on('click', (ev, d) => selectTopic(topics.indexOf(d))); |
| |
| node.append('circle') |
| .attr('r', d => r(d.post_count)) |
| .attr('fill', d => (sentColors[d.dominant_sentiment] || '#4a5568') + '20') |
| .attr('stroke', d => sentColors[d.dominant_sentiment] || '#4a5568') |
| .attr('stroke-width', 1.5); |
| |
| node.append('text') |
| .text(d => d.name.split(' ')[0]) |
| .attr('text-anchor', 'middle') |
| .attr('dy', '-0.2em') |
| .attr('fill', '#f0f4ff') |
| .attr('font-family', 'Syne, sans-serif') |
| .attr('font-weight', '700') |
| .attr('font-size', d => Math.max(9, Math.min(13, r(d.post_count) / 4))); |
| |
| node.append('text') |
| .text(d => d.post_count + ' posts') |
| .attr('text-anchor', 'middle') |
| .attr('dy', '1.1em') |
| .attr('fill', '#8b9ab4') |
| .attr('font-family', 'DM Mono, monospace') |
| .attr('font-size', d => Math.max(8, Math.min(10, r(d.post_count) / 5))); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| CRISIS VIEW |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderCrisisView() { |
| const c = _data.crisis || {}; |
| const alertMap = { low:'π’ LOW', medium:'π‘ MEDIUM', high:'π HIGH', critical:'π΄ CRITICAL' }; |
| document.getElementById('c-overall').textContent = alertMap[c.overall_alert_level] || 'π’ LOW'; |
| document.getElementById('c-total').textContent = c.total_crisis_posts || 0; |
| document.getElementById('c-active').textContent = c.active_crises || 0; |
| const topSig = Object.keys(c.signal_frequency || {})[0] || 'β'; |
| document.getElementById('c-top-signal').textContent = topSig.replace(/_/g,' '); |
| |
| document.getElementById('crisisList').innerHTML = (c.top_crisis_posts || []).slice(0,10).map(p => { |
| const lvl = p.alert_level || 'medium'; |
| return `<div class="crisis-alert ${lvl}"> |
| <div class="crisis-icon">${{critical:'π΄',high:'π ',medium:'π‘',low:'π’'}[lvl]||'π‘'}</div> |
| <div class="crisis-content"> |
| <div class="crisis-title">${p.source||'Unknown'} Β· Score ${Math.round(p.crisis_score||0)}</div> |
| <div class="crisis-desc">${esc(p.text)}</div> |
| <div class="crisis-time">${relTime(p.timestamp)} Β· ${(p.triggered_signals||[]).map(s=>s.signal?.replace(/_/g,' ')).join(', ')}</div> |
| </div> |
| </div>`; |
| }).join('') || '<div style="padding:20px;text-align:center;color:var(--text-tertiary);">No crisis signals</div>'; |
| |
| // Signal chart |
| destroyChart('signalChart'); |
| const ctx = getCtx('signalChart'); |
| const signals = c.signal_frequency || {}; |
| const sigLabels = Object.keys(signals).map(k => k.replace(/_/g,' ')); |
| const sigValues = Object.values(signals); |
| _charts.signalChart = new Chart(ctx, { |
| type: 'bar', |
| data: { |
| labels: sigLabels, |
| datasets: [{ data: sigValues, backgroundColor: sigValues.map((_, i) => ['#ef4444','#f59e0b','#8b5cf6','#06b6d4','#10b981','#5b9cf6','#f472b6','#34d399'][i%8] + 'aa'), borderRadius: 4 }] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, indexAxis: 'y', |
| plugins: { legend: { display: false } }, |
| scales: { |
| x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#4a5568', font: { family: 'DM Mono', size: 10 } } }, |
| y: { grid: { display: false }, ticks: { color: '#8b9ab4', font: { size: 11 } } } |
| } |
| } |
| }); |
| |
| // Recommended actions |
| const actions = [c.summary || 'Monitor sentiment trends for escalation.']; |
| document.getElementById('crisisActions').innerHTML = actions.map(a => ` |
| <div class="crisis-alert medium"> |
| <div class="crisis-icon">π</div> |
| <div class="crisis-content"><div class="crisis-desc">${esc(a)}</div></div> |
| </div> |
| `).join(''); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| COMPETITORS VIEW |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderCompetitorView() { |
| const comp = _data.competitors || {}; |
| const brand = comp.brand || 'TechFlow'; |
| const brandSent = comp.brand_sentiment || 0.72; |
| const competitors = comp.competitors || {}; |
| const sov = comp.market_share_of_voice || {}; |
| |
| // Competitor rows |
| const allBrands = [ |
| { name: brand, score: brandSent, own: true, trend: 'β' }, |
| ...Object.entries(competitors).map(([name, data]) => ({ |
| name, score: data.sentiment_score || 0, own: false, trend: { up:'β', down:'β', stable:'β' }[data.trend] || 'β' |
| })) |
| ].sort((a, b) => b.score - a.score); |
| |
| const maxScore = Math.max(...allBrands.map(b => b.score)); |
| const brandColors = ['#5b9cf6','#10b981','#f59e0b','#8b5cf6','#06b6d4']; |
| |
| document.getElementById('competitorRows').innerHTML = allBrands.map((b, i) => ` |
| <div class="competitor-row"> |
| <div class="competitor-name ${b.own ? 'own' : ''}">${b.name}</div> |
| <div class="comp-bar-wrap"> |
| <div class="comp-bar" style="width:${Math.round(b.score/maxScore*100)}%;background:${brandColors[i%5]};"></div> |
| </div> |
| <div class="comp-score" style="color:${brandColors[i%5]}">${pct(b.score)}</div> |
| <div class="comp-trend">${b.trend}</div> |
| </div> |
| `).join(''); |
| |
| // SoV chart |
| destroyChart('sovChart'); |
| const sovCtx = getCtx('sovChart'); |
| const sovLabels = Object.keys(sov); |
| const sovValues = Object.values(sov); |
| if (sovLabels.length && sovCtx) { |
| _charts.sovChart = new Chart(sovCtx, { |
| type: 'doughnut', |
| data: { |
| labels: sovLabels, |
| datasets: [{ data: sovValues, backgroundColor: ['#f59e0b','#8b5cf6','#10b981','#ef4444'], borderWidth: 0, borderRadius: 3, spacing: 2 }] |
| }, |
| options: { |
| responsive: true, maintainAspectRatio: false, cutout: '65%', |
| plugins: { legend: { position: 'right', labels: { color: '#8b9ab4', font: { family: 'DM Mono', size: 11 }, boxWidth: 10 } } } |
| } |
| }); |
| } |
| |
| // Opportunities |
| const opps = comp.opportunities || []; |
| document.getElementById('opportunityList').innerHTML = opps.length |
| ? opps.map(o => ` |
| <div class="opportunity-card ${o.priority}"> |
| <div class="opp-tag ${o.priority}">${o.priority?.toUpperCase()} PRIORITY Β· ${o.competitor}</div> |
| <div class="opp-text">${esc(o.opportunity)}</div> |
| <div class="opp-action">β ${esc(o.action)}</div> |
| </div> |
| `).join('') |
| : '<div style="color:var(--text-tertiary);font-size:12px;padding:20px;">No competitive opportunities identified.</div>'; |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| LIVE ANALYZER |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function renderAnalyzerView() { |
| const mode = _data.meta?.model_mode || 'demo'; |
| document.getElementById('analyzerModelBadge').textContent = `${mode} mode`; |
| |
| const examples = [ |
| { label: 'π‘ Angry customer', text: 'This is completely unacceptable. The platform has been down for 6 hours with no updates from support. I am disputing the charge with my bank.' }, |
| { label: 'π Loyal advocate', text: 'Absolutely incredible platform. The sentiment analysis saved us during a product recall last year. The customer support team is responsive and the dashboard is gorgeous.' }, |
| { label: 'π Mixed review', text: 'The features are solid but the loading times have gotten worse since the last update. Support responded quickly when I raised the issue, so there is that.' }, |
| { label: 'β‘ Switch signal', text: 'Been using RivalOne for 2 years but seriously considering switching. Their pricing jumped 40% and the API documentation is outdated. Evaluating alternatives this quarter.' }, |
| ]; |
| |
| document.getElementById('exampleList').innerHTML = examples.map(ex => ` |
| <div class="post-item" onclick="setAnalyzerText(${JSON.stringify(ex.text)})" style="cursor:pointer;"> |
| <div class="post-meta"><span class="post-source">${ex.label}</span></div> |
| <div class="post-text">${esc(ex.text.substring(0,120))}β¦</div> |
| </div> |
| `).join(''); |
| } |
| |
| function setAnalyzerText(text) { |
| document.getElementById('analyzerInput').value = text; |
| document.getElementById('analyzerResult').classList.remove('visible'); |
| } |
| |
| async function runAnalysis() { |
| const text = document.getElementById('analyzerInput').value.trim(); |
| if (!text) return; |
| |
| const btn = document.getElementById('analyzeBtn'); |
| btn.textContent = 'β Analyzingβ¦'; |
| btn.disabled = true; |
| |
| try { |
| const res = await fetch(`${API}/analyze`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ text, include_aspects: true, include_crisis: true }), |
| }); |
| const data = await res.json(); |
| renderAnalysisResult(data); |
| } catch { |
| // Demo fallback |
| renderAnalysisResult(getDemoAnalysis(text)); |
| } finally { |
| btn.textContent = 'β‘ Analyze'; |
| btn.disabled = false; |
| } |
| } |
| |
| function renderAnalysisResult(data) { |
| const sent = data.sentiment?.label || 'neutral'; |
| const conf = data.sentiment?.confidence || 0.75; |
| const crisis = data.crisis || {}; |
| |
| const sentColors = { positive: '#10b981', negative: '#ef4444', neutral: '#8b9ab4', crisis: '#ef4444' }; |
| const badge = document.getElementById('resultBadge'); |
| badge.textContent = sent.toUpperCase(); |
| badge.style.background = (sentColors[sent] || '#4a5568') + '20'; |
| badge.style.color = sentColors[sent] || '#8b9ab4'; |
| badge.style.border = `1px solid ${(sentColors[sent] || '#4a5568')}40`; |
| |
| document.getElementById('resultConf').textContent = `${Math.round(conf * 100)}% confidence Β· ${data.sentiment?.mode || 'model'}`; |
| |
| const crisisBox = document.getElementById('crisisResultBox'); |
| if (crisis.score > 0) { |
| crisisBox.innerHTML = `<div class="crisis-alert ${crisis.alert_level || 'low'}" style="margin-top:8px;"> |
| <div class="crisis-icon">${{critical:'π΄',high:'π ',medium:'π‘',low:'π’'}[crisis.alert_level]||'π’'}</div> |
| <div class="crisis-content"> |
| <div class="crisis-title">Crisis Score: ${crisis.score} Β· ${(crisis.alert_level||'low').toUpperCase()}</div> |
| <div class="crisis-desc">${esc(crisis.recommended_action || '')}</div> |
| </div> |
| </div>`; |
| } else crisisBox.innerHTML = ''; |
| |
| const aspects = data.aspects || {}; |
| const aspectEl = document.getElementById('aspectGrid'); |
| if (Object.keys(aspects).length) { |
| const sentColor = s => ({ positive: '#10b981', negative: '#ef4444', neutral: '#8b9ab4' }[s] || '#8b9ab4'); |
| aspectEl.innerHTML = Object.entries(aspects).map(([name, info]) => ` |
| <div class="aspect-item"> |
| <div class="aspect-name">${name}</div> |
| <div class="aspect-sentiment" style="color:${sentColor(info.sentiment)}">${info.sentiment}</div> |
| <div style="font-size:10px;color:var(--text-tertiary);margin-top:2px;font-family:var(--font-mono);">${info.keywords?.join(', ')}</div> |
| </div> |
| `).join(''); |
| } else { |
| aspectEl.innerHTML = '<div style="grid-column:1/-1;color:var(--text-tertiary);font-size:12px;">No specific aspects detected</div>'; |
| } |
| |
| document.getElementById('analyzerResult').classList.add('visible'); |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| UTILS |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function pct(v) { return Math.round((v||0) * 100) + '%'; } |
| function fmt(n) { return n >= 1000 ? (n/1000).toFixed(1) + 'K' : String(n||0); } |
| function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); } |
| function relTime(ts) { |
| if (!ts) return 'β'; |
| const diff = (Date.now() - new Date(ts).getTime()) / 1000; |
| if (diff < 60) return 'just now'; |
| if (diff < 3600) return Math.floor(diff/60) + 'm ago'; |
| if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; |
| return Math.floor(diff/86400) + 'd ago'; |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| DEMO DATA (when backend offline) |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| function getDemoData() { |
| const series = Array.from({length:90}, (_,i) => { |
| const date = new Date(Date.now() - (89-i)*86400000); |
| const s = 0.62 + Math.random()*0.12 + (i > 50 ? 0.05 : 0) + (i >= 42 && i <= 45 ? -0.22 : 0); |
| return { date: date.toISOString().slice(0,10), sentiment: Math.max(0.1, Math.min(0.99, s)), volume: 80+Math.floor(Math.random()*60), positive: 0, negative: 0 }; |
| }); |
| const forecast = Array.from({length:14}, (_,i) => { |
| const date = new Date(Date.now() + (i+1)*86400000); |
| const s = 0.74 + i*0.003 + (Math.random()-0.5)*0.04; |
| return { date: date.toISOString().slice(0,10), sentiment: Math.min(0.99, s), lower: s-0.07, upper: s+0.07 }; |
| }); |
| return { |
| meta: { model_mode: 'demo', total_posts: 406 }, |
| summary: { overall_sentiment: 0.72, avg_7d_sentiment: 0.74, avg_30d_sentiment: 0.70, delta: 0.04, trend_direction: 'improving', total_volume: 11820, avg_daily_volume: 131.3, positive_count: 248, negative_count: 84, neutral_count: 74, positive_pct: 61.1, negative_pct: 20.7, nps_estimate: 40.4, crisis_alert: 'medium', volume_trend: 'growing' }, |
| topics: [ |
| { id:0, name:'Performance & Speed', keywords:['slow','load','speed','latency','fast'], post_count:82, percentage:20.2, dominant_sentiment:'negative', sentiment_distribution:{positive:20,negative:45,neutral:17}, examples:['Loading times are unacceptable. 8 seconds on every refresh.','The API response time has degraded significantly post-update.'] }, |
| { id:1, name:'Customer Support', keywords:['support','response','team','help','ticket'], post_count:74, percentage:18.2, dominant_sentiment:'positive', sentiment_distribution:{positive:48,negative:14,neutral:12}, examples:['Support responded within minutes. Rare level of care.','Ghosted us for 3 days during a critical monitoring window.'] }, |
| { id:2, name:'Pricing & Billing', keywords:['price','billing','cost','subscription','fee'], post_count:63, percentage:15.5, dominant_sentiment:'negative', sentiment_distribution:{positive:12,negative:38,neutral:13}, examples:['Pricing jumped 40% at renewal with no notice.','Excellent value for the pricing tier.'] }, |
| { id:3, name:'UI & Design', keywords:['dashboard','interface','design','ui','navigation'], post_count:58, percentage:14.3, dominant_sentiment:'positive', sentiment_distribution:{positive:42,negative:8,neutral:8}, examples:['Dashboard is gorgeous. My team actually looks forward to weekly reviews.','Too many clicks to access advanced features.'] }, |
| { id:4, name:'Features & Integrations', keywords:['feature','api','integration','export','report'], post_count:51, percentage:12.6, dominant_sentiment:'positive', sentiment_distribution:{positive:35,negative:10,neutral:6}, examples:['The competitor tracking module is a game-changer.','Integrations are shallow. No bi-directional actions.'] }, |
| { id:5, name:'Data Quality & Accuracy', keywords:['accuracy','data','model','insight','reliable'], post_count:42, percentage:10.3, dominant_sentiment:'neutral', sentiment_distribution:{positive:18,negative:16,neutral:8}, examples:['BERT-powered analysis significantly more accurate than alternatives.','Trend forecasting was way off during product launch.'] }, |
| { id:6, name:'Onboarding & Docs', keywords:['setup','onboard','documentation','guide','install'], post_count:36, percentage:8.9, dominant_sentiment:'positive', sentiment_distribution:{positive:22,negative:8,neutral:6}, examples:['Onboarding documentation is detailed and well-written.','Took us a week to get basic pipelines running.'] }, |
| ], |
| trends: { time_series: series, forecast, trend: { direction:'improving', slope:0.00082, current_sentiment:0.72, avg_7d:0.74, avg_30d:0.70, delta_7d_vs_30d:0.04, volume_trend:'growing', total_volume:11820, avg_daily_volume:131.3 }, anomalies: [{ date: series[45]?.date, sentiment:0.48, z_score:-2.3, direction:'dip', severity:'high' }] }, |
| crisis: { overall_alert_level:'medium', total_crisis_posts:18, active_crises:3, top_crisis_posts:[ |
| { id:'c0', text:'ZERO stars. Complete system outage for 6 hours with no status page updates.', source:'Twitter', alert_level:'high', crisis_score:12.5, timestamp:new Date(Date.now()-7*86400000).toISOString(), triggered_signals:[{signal:'service_failure'},{signal:'outrage'}] }, |
| { id:'c1', text:'They charged me twice and the billing team has not responded in 4 days. Disputing with my bank.', source:'Trustpilot', alert_level:'high', crisis_score:10.0, timestamp:new Date(Date.now()-3*86400000).toISOString(), triggered_signals:[{signal:'financial'},{signal:'exodus_intent'}] }, |
| { id:'c2', text:'Data breach. My private information appeared in another user\'s dashboard report.', source:'Reddit', alert_level:'critical', crisis_score:10.0, timestamp:new Date(Date.now()-8*86400000).toISOString(), triggered_signals:[{signal:'data_breach'}] }, |
| ], signal_frequency:{ service_failure:8, financial:6, outrage:5, exodus_intent:4, mass_complaint:3, viral_threat:2 }, summary:'HIGH ALERT: Elevated negative signals around service failure. Assign response team immediately.' }, |
| competitors: { brand:'TechFlow', brand_sentiment:0.72, competitors:{ RivalOne:{ sentiment_score:0.61, mention_count:28, trend:'down', sentiment_distribution:{positive:17,negative:8,neutral:3} }, CompeteX:{ sentiment_score:0.68, mention_count:22, trend:'stable', sentiment_distribution:{positive:15,negative:5,neutral:2} }, AltStream:{ sentiment_score:0.55, mention_count:14, trend:'down', sentiment_distribution:{positive:8,negative:5,neutral:1} } }, market_share_of_voice:{ RivalOne:6.9, CompeteX:5.4, AltStream:3.4 }, opportunities:[{ competitor:'RivalOne', opportunity:'RivalOne shows declining sentiment (61%). Users actively looking for alternatives.', action:'Create targeted comparison landing page highlighting response time and accuracy advantages.', priority:'high' },{ competitor:'AltStream', opportunity:'AltStream weak at 55% positive sentiment. Multiple exodus signals detected.', action:'Target AltStream users with migration offer and free data import tool.', priority:'high' },{ competitor:'CompeteX', opportunity:'Users compare CompeteX on pricing dimension (4 mentions).', action:'Strengthen pricing transparency and value messaging in top-of-funnel content.', priority:'medium' }] }, |
| }; |
| } |
| |
| function getDemoPosts() { |
| const sentiments = ['positive','positive','positive','negative','neutral','crisis']; |
| const sources = ['Twitter','Reddit','G2','Trustpilot','ProductHunt','LinkedIn']; |
| const texts = [ |
| 'Absolutely love the new dashboard update β real-time insights have changed how our team operates.', |
| 'The sentiment analysis caught a product issue before it became a PR crisis. Incredible.', |
| 'Setup was smooth. Was up and running in under an hour. Onboarding flow is excellent.', |
| 'The export feature crashes with datasets over 10,000 rows. Very frustrating.', |
| 'Pricing jumped 40% at renewal with no notice. This kind of thing destroys trust.', |
| 'Switched from a competitor. Migration took longer than expected but worth it.', |
| 'ZERO stars. System outage for 6 hours with no status page updates. Unacceptable.', |
| 'Customer support responded within minutes. Rare to see this level of care.', |
| 'The topic clustering feature alone is worth the subscription price.', |
| 'Loading times are unacceptable. Dashboard takes 8 seconds to render.', |
| 'Mobile app works flawlessly. Can monitor brand health on the go.', |
| 'Documentation is outdated. Several API endpoints described don\'t match behavior.', |
| 'The BERT-powered sentiment analysis is significantly more accurate than alternatives.', |
| 'Too many false positives in crisis detection. Alert fatigue is real.', |
| 'The competitor tracking module is a game-changer for our strategy team.', |
| 'Data breach. My private information appeared in another user\'s dashboard.', |
| ]; |
| return Array.from({length:200}, (_,i) => ({ |
| id: `demo_${i}`, |
| text: texts[i % texts.length], |
| sentiment: sentiments[i % sentiments.length], |
| true_label: sentiments[i % sentiments.length], |
| source: sources[i % sources.length], |
| timestamp: new Date(Date.now() - Math.random()*90*86400000).toISOString(), |
| likes: Math.floor(Math.random()*200), |
| topic_name: ['Performance & Speed','Customer Support','Pricing & Billing','UI & Design','Features & Integrations'][i%5], |
| })); |
| } |
| |
| function getDemoAnalysis(text) { |
| const neg = ['slow','crash','terrible','awful','hate','scam','breach','unacceptable'].some(w => text.toLowerCase().includes(w)); |
| const pos = ['love','great','excellent','amazing','best','perfect','incredible'].some(w => text.toLowerCase().includes(w)); |
| const label = neg ? 'negative' : pos ? 'positive' : 'neutral'; |
| const aspects = {}; |
| if (text.toLowerCase().includes('slow') || text.toLowerCase().includes('load')) aspects['Performance'] = { mentioned:true, sentiment:'negative', keywords:['slow'], score:0.82 }; |
| if (text.toLowerCase().includes('support') || text.toLowerCase().includes('team')) aspects['Support'] = { mentioned:true, sentiment: pos ? 'positive' : 'negative', keywords:['support'], score:0.75 }; |
| if (text.toLowerCase().includes('price') || text.toLowerCase().includes('billing')) aspects['Pricing'] = { mentioned:true, sentiment:'negative', keywords:['pricing'], score:0.79 }; |
| const crisisScore = (text.toLowerCase().includes('breach') || text.toLowerCase().includes('scam') || text.toLowerCase().includes('lawsuit')) ? 12 : (neg ? 4 : 0); |
| return { sentiment:{ label, confidence:0.82, mode:'demo' }, aspects, crisis:{ score:crisisScore, alert_level: crisisScore>10?'high':crisisScore>4?'medium':'low', recommended_action: crisisScore>10 ? 'Escalate to communications team.' : 'Monitor for escalation.', is_crisis: crisisScore > 8 } }; |
| } |
| |
| /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| INIT |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| document.addEventListener('DOMContentLoaded', loadDashboard); |
| </script> |
| </body> |
| </html> |
|
|