Download templates/dashboard.html from Niveytha27/YT_Trend_Discovery_Engine: direct link, hf CLI and curl.
- Browser
- Download file 53.2 kB
-
https://huggingface.co/spaces/Niveytha27/YT_Trend_Discovery_Engine/resolve/main/templates/dashboard.html
- Command line
-
hf download hf://spaces/Niveytha27/YT_Trend_Discovery_Engine/templates/dashboard.html
-
curl -L -o dashboard.html https://huggingface.co/spaces/Niveytha27/YT_Trend_Discovery_Engine/resolve/main/templates/dashboard.html
53.2 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | |
| <title>Trend Discovery Engine</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"> | |
| <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script> | |
| </head> | |
| <body class="bg-body"> | |
| <nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm"> | |
| <div class="container-fluid"> | |
| <a class="navbar-brand fw-semibold" href="#">Trend Discovery Engine</a> | |
| </div> | |
| </nav> | |
| <main class="container py-4"> | |
| <ul class="nav nav-tabs" id="main-tabs" role="tablist"> | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link active" id="archetypes-tab" | |
| data-bs-toggle="tab" data-bs-target="#archetypes" | |
| type="button" role="tab" aria-controls="archetypes" aria-selected="true"> | |
| Trend Archetypes | |
| </button> | |
| </li> | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link" id="viral-topics-tab" | |
| data-bs-toggle="tab" data-bs-target="#viral-topics" | |
| type="button" role="tab" aria-controls="viral-topics" aria-selected="false"> | |
| Viral Topics | |
| </button> | |
| </li> | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link" id="nascent-trends-tab" | |
| data-bs-toggle="tab" data-bs-target="#nascent-trends" | |
| type="button" role="tab" aria-controls="nascent-trends" aria-selected="false"> | |
| Nascent Trends | |
| </button> | |
| </li> | |
| </ul> | |
| <div class="tab-content pt-4"> | |
| <!-- TAB 1: Trend Archetypes --> | |
| <div class="tab-pane fade show active" id="archetypes" role="tabpanel" aria-labelledby="archetypes-tab"> | |
| <div class="d-flex align-items-center mb-3"> | |
| <h2 class="me-3 mb-0">Trend Archetypes</h2> | |
| </div> | |
| <!-- First area: Flip cards (single row, click to flip, color-coded) --> | |
| <div class="card mb-4"> | |
| <div class="card-header bg-light fw-semibold">Archetype Cards</div> | |
| <div class="card-body"> | |
| <div class="flip-row" id="flipRow"> | |
| {% for c in clusters %} | |
| <div class="flip-card" data-archetype="{{ c.trend_archetype }}" tabindex="0" aria-label="Flip card"> | |
| <div class="flip-card-inner"> | |
| <!-- FRONT --> | |
| <div class="flip-card-front"> | |
| <div class="flip-title">{{ c.trend_archetype }}</div> | |
| <div class="flip-content"> | |
| <div class="flip-subtitle">Description</div> | |
| <p class="small mt-2">{{ c.description }}</p> | |
| </div> | |
| </div> | |
| <!-- BACK --> | |
| <div class="flip-card-back"> | |
| <div class="flip-title">{{ c.trend_archetype }}</div> | |
| <div class="flip-content"> | |
| <div class="flip-subtitle">Recommendation</div> | |
| <p class="small mt-2">{{ c.strategic_recommendation }}</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| <div class="text-muted small mt-2">Hover over a card to flip. Scroll sideways if needed.</div> | |
| </div> | |
| </div> | |
| <!-- Second area: Split 50/50 β Pie | Sample videos by cluster --> | |
| <div class="card mb-4"> | |
| <div class="card-header bg-light fw-semibold">Distribution & Sample Videos</div> | |
| <div class="card-body"> | |
| <div class="split-2"> | |
| <!-- Left: Pie --> | |
| <div class="split-2-col pe-lg-3"> | |
| <h6 class="mb-3">Video Count by Archetype</h6> | |
| <!-- (remove these 2 lines if already loaded globally) --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> | |
| <div class="chart-box"> | |
| <canvas id="videoPieChart"></canvas> | |
| </div> | |
| <!-- JSON payloads for the pie (safe, no Jinja map) --> | |
| <script id="pieLabelsJSON" type="application/json"> | |
| </script> | |
| <script id="pieDataJSON" type="application/json"> | |
| </script> | |
| <script> | |
| (function(){ | |
| const canvas = document.getElementById('videoPieChart'); | |
| if (!canvas) return; | |
| const labels = JSON.parse(document.getElementById('pieLabelsJSON')?.textContent || '[]'); | |
| const values = JSON.parse(document.getElementById('pieDataJSON')?.textContent || '[]'); | |
| if (!labels.length || !values.length) { console.warn('[pie] no data'); return; } | |
| const colorMap = { | |
| "Explosive Viral Hit": "rgba(248,113,113,0.65)", // red-400 @ 65% | |
| "Momentum Builder": "rgba(252,211,77,0.65)", // amber-300 @ 65% | |
| "Consistent Performer": "rgba(147,197,253,0.70)", // blue-300 @ 70% | |
| "Gradual Climber": "rgba(134,239,172,0.70)", // green-300 @ 70% | |
| "Organic Riser": "rgba(196,181,253,0.70)" // violet-300 @ 70% | |
| }; | |
| const bg = labels.map(l => colorMap[l] || "rgba(107,114,128,0.9)"); | |
| const border = bg.map(c => c.replace(/0\.9\)$/, "1)")); | |
| const total = values.reduce((a,b)=>a+b,0); | |
| new Chart(canvas.getContext('2d'), { | |
| type: 'pie', | |
| plugins: [ChartDataLabels], | |
| data: { | |
| labels, | |
| datasets: [{ data: values, backgroundColor: bg, borderColor: border, borderWidth: 1 }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| datalabels: { | |
| color: '#000', | |
| font: { weight: 'bold', size: 13 }, | |
| formatter: (v) => (total ? (v/total*100).toFixed(1) : '0.0') + '%' | |
| }, | |
| legend: { | |
| position: 'bottom', | |
| labels: { boxWidth: 18, font: { size: 14 } } | |
| }, | |
| tooltip: { | |
| callbacks: { | |
| label: (ctx) => { | |
| const label = ctx.label || ''; | |
| const val = ctx.raw ?? 0; | |
| const pct = total ? ((val/total)*100).toFixed(1) : '0.0'; | |
| return `${label}: ${val.toLocaleString()} videos (${pct}%)`; | |
| } | |
| } | |
| }, | |
| title: { display: false } | |
| } | |
| } | |
| }); | |
| })(); | |
| </script> | |
| </div> | |
| <div class="split-2-sep d-none d-lg-block"></div> | |
| <!-- Right: All sample videos, grouped by archetype --> | |
| <div class="split-2-col ps-lg-3"> | |
| <h6 class="mb-3">Sample Videos by Archetype</h6> | |
| <div class="accordion accordion-flush" id="samplesAccordion"> | |
| {% for c in clusters %} | |
| {% set type_class = | |
| 'type-explosive' if c.trend_archetype == 'Explosive Viral Hit' else | |
| 'type-momentum' if c.trend_archetype == 'Momentum Builder' else | |
| 'type-consistent' if c.trend_archetype == 'Consistent Performer' else | |
| 'type-gradual' if c.trend_archetype == 'Gradual Climber' else | |
| 'type-organic' if c.trend_archetype == 'Organic Riser' else '' %} | |
| <div class="accordion-item"> | |
| <h2 class="accordion-header" id="head-{{ loop.index }}"> | |
| <button class="accordion-button collapsed sample-head {{ type_class }}" | |
| type="button" | |
| data-bs-toggle="collapse" | |
| data-bs-target="#collapse-{{ loop.index }}" | |
| aria-expanded="false" | |
| aria-controls="collapse-{{ loop.index }}"> | |
| <span class="fw-semibold">{{ c.trend_archetype }}</span> | |
| <span class="ms-2 text-muted small">({{ c.sample_videos|length }} videos)</span> | |
| </button> | |
| </h2> | |
| <div id="collapse-{{ loop.index }}" class="accordion-collapse collapse" | |
| aria-labelledby="head-{{ loop.index }}" | |
| data-bs-parent="#samplesAccordion"> | |
| <div class="accordion-body p-0"> | |
| <div class="table-responsive sample-table-wrap"> | |
| <table class="table table-sm table-striped table-hover mb-0 align-middle sample-table compact-table"> | |
| <thead class="table-header sticky-top"> | |
| <tr> | |
| <th style="width:64px;">Thumb</th> | |
| <th>Title</th> | |
| <th class="text-end" title="Velocity 1β3 hours">V1β3h</th> | |
| <th class="text-end" title="Velocity 12 hoursβ1 day">V12hβ1d</th> | |
| <th class="text-end" title="Velocity 1β3 days">V1β3d</th> | |
| <th class="text-end" title="Spike Index">Spike</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for v in c.sample_videos %} | |
| <tr> | |
| <td> | |
| <a href="https://www.youtube.com/watch?v={{ v.video_id }}" target="_blank" rel="noopener"> | |
| <img class="thumb-xs" src="{{ v.thumbnail_url }}" alt="{{ v.title }}" loading="lazy"> | |
| </a> | |
| </td> | |
| <td> | |
| <a class="link-dark text-decoration-none sample-title" | |
| href="https://www.youtube.com/watch?v={{ v.video_id }}" | |
| target="_blank" rel="noopener" title="{{ v.title }}"> | |
| {{ v.title }} | |
| </a> | |
| </td> | |
| <td class="metric-cell">{{ ((v.velocity_1_3h or 0)|round(0))|int }}</td> | |
| <td class="metric-cell">{{ ((v.velocity_12h_1d or 0)|round(0))|int }}</td> | |
| <td class="metric-cell">{{ ((v.velocity_1d_3d or 0)|round(0))|int }}</td> | |
| <td class="metric-cell">{{ (v.spike_index or 0)|round(2) }}</td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Third area: Metrics (Table | Line chart) with common filter --> | |
| <!-- ===== Area 3 JSON payloads (table + line chart) ===== --> | |
| <script id="metricsOrderJSON" type="application/json"> | |
| </script> | |
| <script id="metricsKeysJSON" type="application/json"> | |
| { | |
| "velocity": ["velocity_1_3h","velocity_3_6h","velocity_6_12h","velocity_12h_1d","velocity_1d_3d"], | |
| "acceleration": ["acceleration_1_6h","acceleration_6_12h","acceleration_12h_1d","acceleration_1d_3d"], | |
| "engagement": ["engagement_ratio_3h","engagement_ratio_6h","engagement_ratio_1d","engagement_ratio_3d"] | |
| } | |
| </script> | |
| <script id="metricsKeyLabelsJSON" type="application/json"> | |
| { | |
| "velocity": ["1β3h","3β6h","6β12h","12hβ1d","1β3d"], | |
| "acceleration": ["1β6h","6β12h","12hβ1d","1β3d"], | |
| "engagement": ["Eng 3h","Eng 6h","Eng 1d","Eng 3d"] | |
| } | |
| </script> | |
| <script id="metricsRowsJSON" type="application/json"> | |
| </script> | |
| <div class="card mb-4" id="area3-metrics"> | |
| <div class="card-header bg-light d-flex justify-content-between align-items-center"> | |
| <span class="fw-semibold">Cluster Summary Metrics</span> | |
| <div class="d-flex align-items-center gap-2"> | |
| <label for="metricGroupSelect" class="small text-muted mb-0">Metrics:</label> | |
| <select id="metricGroupSelect" class="form-select form-select-sm" style="min-width: 180px;"> | |
| <option value="velocity" selected>Velocity</option> | |
| <option value="acceleration">Acceleration</option> | |
| <option value="engagement">Engagement</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="card-body"> | |
| <div class="split-2"> | |
| <!-- Left: Table --> | |
| <div class="split-2-col pe-lg-3"> | |
| <h6 id="metricsTableTitle" class="mb-2">Velocity Metrics by Archetype</h6> | |
| <div class="table-responsive sample-table-wrap"> | |
| <table id="metricsTable" class="table table-sm table-striped table-hover mb-0 align-middle compact-table"> | |
| <tbody><tr><td class="text-muted">Loadingβ¦</td></tr></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="split-2-sep d-none d-lg-block"></div> | |
| <!-- Right: Line chart --> | |
| <div class="split-2-col ps-lg-3"> | |
| <h6 id="metricsChartTitle" class="mb-2">Velocity Metrics Trend</h6> | |
| <div class="linechart-box"> | |
| <canvas id="metricsLineChart"></canvas> | |
| </div> | |
| <div id="metricsChartNotice" class="small text-muted mt-2" style="display:none;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function metricsAreaJSON(){ | |
| const sel = document.getElementById('metricGroupSelect'); | |
| const tbl = document.getElementById('metricsTable'); | |
| const tHdr = document.getElementById('metricsTableTitle'); | |
| const cHdr = document.getElementById('metricsChartTitle'); | |
| const cvs = document.getElementById('metricsLineChart'); | |
| if (!sel || !tbl) return; | |
| const parseJSON = (id, fallback) => { | |
| const el = document.getElementById(id); | |
| if (!el) return fallback; | |
| try { return JSON.parse(el.textContent || JSON.stringify(fallback)); } | |
| catch { return fallback; } | |
| }; | |
| // payloads | |
| const ORDER = parseJSON('metricsOrderJSON', []); | |
| const KEYS = parseJSON('metricsKeysJSON', {}); | |
| const KEYLABELS = parseJSON('metricsKeyLabelsJSON', {}); | |
| const ROWS = parseJSON('metricsRowsJSON', []); | |
| // desired visual order | |
| const DESIRED = ["Explosive Viral Hit","Momentum Builder","Consistent Performer","Gradual Climber","Organic Riser"]; | |
| const FILL = { | |
| "Explosive Viral Hit":"rgba(248,113,113,0.65)", | |
| "Momentum Builder":"rgba(252,211,77,0.65)", | |
| "Consistent Performer":"rgba(147,197,253,0.70)", | |
| "Gradual Climber":"rgba(134,239,172,0.70)", | |
| "Organic Riser":"rgba(196,181,253,0.70)" | |
| }; | |
| const STROKE = { | |
| "Explosive Viral Hit":"#ef4444", | |
| "Momentum Builder":"#f59e0b", | |
| "Consistent Performer":"#3b82f6", | |
| "Gradual Climber":"#10b981", | |
| "Organic Riser":"#8b5cf6" | |
| }; | |
| // order rows consistently | |
| const byName = Object.fromEntries(ROWS.map(r => [r.archetype, r])); | |
| const rowsOrdered = DESIRED.map(n => byName[n]).filter(Boolean) | |
| .concat(ORDER.filter(n => !DESIRED.includes(n)).map(n => byName[n]).filter(Boolean)); | |
| const fmt = (v) => (typeof v === 'number' | |
| ? (Math.abs(v)>=1000 ? Math.round(v).toLocaleString() | |
| : Math.round(v*100)/100) | |
| : '-'); | |
| function renderTable(group){ | |
| const keys = KEYS[group] || []; | |
| let html = `<thead class="table-header sticky-top"><tr><th>Archetype</th>`; | |
| (KEYLABELS[group] || keys).forEach(l => html += `<th class="text-end">${l}</th>`); | |
| html += `</tr></thead><tbody>`; | |
| if (!rowsOrdered.length){ | |
| html += `<tr><td colspan="${keys.length+1}" class="text-muted">No cluster data.</td></tr>`; | |
| } else { | |
| rowsOrdered.forEach(r => { | |
| html += `<tr><td class="fw-semibold">${r.archetype}</td>`; | |
| (r[group] || []).forEach(v => { html += `<td class="metric-cell">${fmt(v)}</td>`; }); | |
| html += `</tr>`; | |
| }); | |
| } | |
| html += `</tbody>`; | |
| tbl.innerHTML = html; | |
| if (tHdr) tHdr.textContent = `${group[0].toUpperCase()+group.slice(1)} Metrics by Archetype`; | |
| } | |
| let chart = null; | |
| function renderChart(group){ | |
| if (!cvs || typeof Chart === 'undefined') return; | |
| const labels = KEYLABELS[group] || KEYS[group] || []; | |
| const datasets = rowsOrdered.map(r => ({ | |
| label: r.archetype, | |
| data: (r[group] || []).map(v => (typeof v === 'number' ? v : null)), | |
| borderColor: STROKE[r.archetype] || '#6b7280', | |
| backgroundColor: FILL[r.archetype] || 'rgba(156,163,175,0.35)', | |
| tension: 0.25, borderWidth: 2, pointRadius: 3, pointHoverRadius: 5, spanGaps: true | |
| })); | |
| if (chart) chart.destroy(); | |
| chart = new Chart(cvs.getContext('2d'), { | |
| type: 'line', | |
| data: { labels, datasets }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| interaction: { mode: 'index', intersect: false }, | |
| scales: { | |
| x: { ticks: { autoSkip: false } }, | |
| y: { | |
| beginAtZero: false, | |
| ticks: { callback: (v) => (Math.abs(v)>=1000 ? Math.round(v).toLocaleString() : v) }, | |
| grid: { color: 'rgba(0,0,0,0.06)' } | |
| } | |
| }, | |
| plugins: { | |
| legend: { position: 'top' }, | |
| tooltip: { | |
| callbacks: { | |
| label: (ctx) => `${ctx.dataset.label}: ${typeof ctx.parsed.y==='number' ? ctx.parsed.y.toLocaleString() : '-'}` | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| if (cHdr) cHdr.textContent = `${group[0].toUpperCase()+group.slice(1)} Metrics Trend`; | |
| } | |
| function update(){ | |
| const g = sel.value || 'velocity'; | |
| renderTable(g); | |
| renderChart(g); | |
| } | |
| sel.addEventListener('change', update); | |
| update(); | |
| })(); | |
| </script> | |
| <!-- ===== Data bootstrap (must be BEFORE the section JS) ===== --> | |
| <script id="clustersJSON" type="application/json"> | |
| </script> | |
| <script> | |
| // Make clusters available to the section JS | |
| window.clustersData = []; | |
| try { | |
| window.clustersData = JSON.parse(document.getElementById('clustersJSON').textContent || '[]'); | |
| console.info('[bootstrap] clustersData length =', window.clustersData.length); | |
| } catch (e) { | |
| console.error('[bootstrap] failed to parse clustersJSON', e); | |
| } | |
| </script> | |
| <!-- Chart libs (remove if already included globally) --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> | |
| <!-- Archetypes section JS --> | |
| <script src="{{ url_for('static', filename='js/archetypes_section.js') }}"></script> | |
| </div> | |
| <!-- TAB 2: Viral Topics --> | |
| <div class="tab-pane fade" id="viral-topics" role="tabpanel" aria-labelledby="viral-topics-tab"> | |
| <!-- Toolbar: single-select archetype chips --> | |
| <div class="card mb-3"> | |
| <div class="card-body"> | |
| <label class="form-label small mb-2">Choose Archetype</label> | |
| <div id="vt-archetype-chips" class="d-flex flex-wrap gap-2"></div> | |
| <div id="vt-chip-hint" class="text-muted small mt-1">Showing top 3 topics by Viral Score.</div> | |
| </div> | |
| </div> | |
| <!-- Main card: Top 3 topics + Radar + Sample Videos --> | |
| <div class="card"> | |
| <div class="card-header bg-light fw-semibold d-flex align-items-center justify-content-between"> | |
| <span>Top Topics</span> | |
| <span id="vt-archetype-badge" class="badge rounded-pill text-dark" style="display:none;">Archetype</span> | |
| </div> | |
| <div class="card-body"> | |
| <!-- Top 3 topic metric cards --> | |
| <div id="vt-topcards" class="row g-3 mb-3"> | |
| <!-- rendered by JS --> | |
| </div> | |
| <!-- Radar: overlay of 3 topics --> | |
| <div class="mb-3"> | |
| <h6 class="mb-2">Signal Profile (Top 3)</h6> | |
| <div style="position:relative; height:380px;"> | |
| <canvas id="vt-radar"></canvas> | |
| </div> | |
| </div> | |
| <!-- Sample videos: tabs per topic --> | |
| <div class="mt-3"> | |
| <ul id="vt-tabs" class="nav nav-tabs"></ul> | |
| <div id="vt-tabpanes" class="tab-content border-start border-end border-bottom p-3" style="max-height: 420px; overflow:auto;"> | |
| <!-- rendered by JS --> | |
| </div> | |
| </div> | |
| <!-- Empty state --> | |
| <div id="vt-empty" class="text-muted small" style="display:none;">No topics available for this archetype.</div> | |
| </div> | |
| </div> | |
| <!-- ===== JSON payloads ===== --> | |
| {% set _topics = ( | |
| viral_topics | |
| if viral_topics is defined | |
| else (data['section_2_Viral_Topics'] if data is defined and 'section_2_Viral_Topics' in data else []) | |
| ) %} | |
| <script id="topicsJSON" type="application/json"> | |
| </script> | |
| <script id="archetypeOrderJSON" type="application/json"> | |
| ["Explosive Viral Hit","Momentum Builder","Consistent Performer","Gradual Climber","Organic Riser"] | |
| </script> | |
| <script id="archetypeColorsJSON" type="application/json"> | |
| { | |
| "Explosive Viral Hit": {"fill":"#F87171","chip":"#FECACA","text":"#111827"}, | |
| "Momentum Builder": {"fill":"#FCD34D","chip":"#FDE68A","text":"#111827"}, | |
| "Consistent Performer":{"fill":"#93C5FD","chip":"#BFDBFE","text":"#111827"}, | |
| "Gradual Climber": {"fill":"#86EFAC","chip":"#BBF7D0","text":"#111827"}, | |
| "Organic Riser": {"fill":"#C4B5FD","chip":"#DDD6FE","text":"#111827"} | |
| } | |
| </script> | |
| <script id="topicSignalsKeysJSON" type="application/json"> | |
| ["z_velocity_1_3h","z_velocity_3_6h","z_acceleration_1_6h","z_engagement_ratio_1d","z_like_comment_ratio_1d","z_spike_index","z_view_growth_std"] | |
| </script> | |
| <!-- Chart.js (skip if already loaded globally) --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script> | |
| <!-- JS for this tab --> | |
| <script> | |
| (function ready(fn){ | |
| document.readyState !== "loading" ? fn() : document.addEventListener("DOMContentLoaded", fn); | |
| })(() => { | |
| const ROOT = document.getElementById("viral-topics"); | |
| if (!ROOT) return; // tab not on this page | |
| // Use ROOT.querySelector instead of document.getElementById | |
| const q = (sel) => ROOT.querySelector(sel); | |
| const chipsWrap = document.getElementById("vt-archetype-chips"); | |
| const badgeEl = document.getElementById("vt-archetype-badge"); | |
| const topCards = document.getElementById("vt-topcards"); | |
| const radarCv = document.getElementById("vt-radar"); | |
| const tabsEl = document.getElementById("vt-tabs"); | |
| const panesEl = document.getElementById("vt-tabpanes"); | |
| const emptyEl = document.getElementById("vt-empty"); | |
| if (!chipsWrap || !topCards || !radarCv || !tabsEl || !panesEl) return; | |
| // ----- JSON ----- | |
| const parseJSON = (id, fallback) => { | |
| const el = document.getElementById(id); | |
| if (!el) return fallback; | |
| try { return JSON.parse(el.textContent || JSON.stringify(fallback)); } | |
| catch { return fallback; } | |
| }; | |
| const RAW_TOPICS = parseJSON("topicsJSON", []); | |
| const ORDER = parseJSON("archetypeOrderJSON", []); | |
| const COLORS = parseJSON("archetypeColorsJSON", {}); | |
| const SIGNAL_KEYS = parseJSON("topicSignalsKeysJSON", []); | |
| // ----- Normalize ----- | |
| const titleCase = (s) => (s || "").replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); | |
| const topics = RAW_TOPICS.map(t => ({ | |
| archetype: t.trend_archetype_cluster || "", | |
| topic_name: t.topic_name || "", | |
| display_name: titleCase(t.topic_name || ""), | |
| video_count: Number(t.video_count || 0), | |
| median_views_3d: Number(t.median_views_3d || 0), | |
| virality_consistency: Number(t.virality_consistency || 0), | |
| topic_viral_score: Number(t.topic_viral_score || 0), | |
| signals: t.signals || {}, | |
| genai_summary: t.genai_summary || "", | |
| sample_videos: Array.isArray(t.sample_videos) ? t.sample_videos : [] | |
| })); | |
| // By archetype | |
| const byArch = ORDER.reduce((acc, name) => (acc[name] = [], acc), {}); | |
| topics.forEach(t => { if (byArch[t.archetype]) byArch[t.archetype].push(t); }); | |
| // ----- State ----- | |
| let selectedArch = ORDER.find(a => (byArch[a] && byArch[a].length)) || ORDER[0]; | |
| // ----- Utils ----- | |
| const metric = (v) => (typeof v === "number" && !Number.isNaN(v) | |
| ? (v >= 1000 ? Math.round(v).toLocaleString() : Math.round(v * 100) / 100) | |
| : "β"); | |
| function hexToRgb(hex){ | |
| const m = (hex||"").replace('#','').match(/^([a-f\d]{3}|[a-f\d]{6})$/i); | |
| if(!m) return {r:147,g:197,b:253}; | |
| const h = m[1].length===3 ? m[1].split('').map(c=>c+c).join('') : m[1]; | |
| return { r: parseInt(h.substr(0,2),16), g: parseInt(h.substr(2,2),16), b: parseInt(h.substr(4,2),16) }; | |
| } | |
| const withAlpha = (hex, a=0.35) => { | |
| const {r,g,b} = hexToRgb(hex); return `rgba(${r},${g},${b},${a})`; | |
| }; | |
| const lighten = (hex, amt=0.2) => { | |
| const {r,g,b} = hexToRgb(hex); | |
| const L = (c)=>Math.round(c + (255-c)*amt); | |
| return `rgb(${L(r)},${L(g)},${L(b)})`; | |
| }; | |
| // ----- Chips (single-select) ----- | |
| function renderChips(){ | |
| chipsWrap.innerHTML = ORDER.map(name => { | |
| const color = COLORS[name]?.chip || "#f3f4f6"; | |
| const text = COLORS[name]?.text || "#111827"; | |
| const active = (name === selectedArch); | |
| return ` | |
| <button type="button" class="btn btn-sm" | |
| data-arch="${name}" | |
| style="background:${active? color : 'transparent'}; color:${text}; | |
| border:1px solid rgba(0,0,0,.12); ${active ? 'box-shadow: inset 0 0 0 1px rgba(0,0,0,.12);' : ''}"> | |
| ${name} | |
| </button>`; | |
| }).join(""); | |
| chipsWrap.querySelectorAll('button[data-arch]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| selectedArch = btn.getAttribute('data-arch'); | |
| renderChips(); | |
| renderAll(); | |
| }); | |
| }); | |
| } | |
| // ----- Top 3 selection ----- | |
| function top3For(archetype){ | |
| const rows = (byArch[archetype] || []).slice() | |
| .sort((a,b) => b.topic_viral_score - a.topic_viral_score); | |
| return rows.slice(0,3); | |
| } | |
| // ----- Render metric cards ----- | |
| function renderCards(top3){ | |
| if (!top3.length){ | |
| topCards.innerHTML = ""; | |
| emptyEl.style.display = ""; | |
| return; | |
| } | |
| emptyEl.style.display = "none"; | |
| topCards.innerHTML = top3.map((t,i)=>` | |
| <div class="col-12 col-md-6 col-xl-4"> | |
| <div class="border rounded h-100 p-3"> | |
| <div class="d-flex align-items-start justify-content-between"> | |
| <div class="fw-semibold">${t.display_name}</div> | |
| <span class="badge bg-light text-dark">#${i+1}</span> | |
| </div> | |
| ${t.genai_summary ? `<div class="text-muted small mt-1 text-truncate-2" title="${t.genai_summary.replace(/"/g,'"')}">${t.genai_summary}</div>` : ``} | |
| <div class="row g-2 mt-2"> | |
| <div class="col-6"><div class="small text-muted">Viral Score</div><div class="fw-bold">${metric(t.topic_viral_score)}</div></div> | |
| <div class="col-6"><div class="small text-muted">Consistency</div><div class="fw-bold">${metric(t.virality_consistency)}</div></div> | |
| <div class="col-6"><div class="small text-muted">Median Views (3d)</div><div class="fw-bold">${metric(t.median_views_3d)}</div></div> | |
| <div class="col-6"><div class="small text-muted">Videos</div><div class="fw-bold">${t.video_count?.toLocaleString?.() || t.video_count}</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| `).join(""); | |
| } | |
| // ----- Radar: 3 datasets ----- | |
| let radarChart = null; | |
| function renderRadar(top3){ | |
| if (radarChart) { radarChart.destroy(); radarChart = null; } | |
| if (!top3.length) return; | |
| const base = COLORS[selectedArch]?.fill || "#93C5FD"; | |
| const c1 = base; | |
| const c2 = lighten(base, 0.18); | |
| const c3 = lighten(base, 0.36); | |
| const dsColors = [c1,c2,c3]; | |
| const datasets = top3.map((t, idx) => ({ | |
| label: t.display_name, | |
| data: SIGNAL_KEYS.map(k => Number(t.signals?.[k] ?? 0)), | |
| borderColor: dsColors[idx], | |
| backgroundColor: withAlpha(dsColors[idx], 0.25), | |
| borderWidth: 2, | |
| pointRadius: 2 | |
| })); | |
| const labels = SIGNAL_KEYS.map(k => k.replace(/^z_/, "").replace(/_/g," ").toUpperCase()); | |
| radarChart = new Chart(radarCv.getContext("2d"), { | |
| type: "radar", | |
| data: { labels, datasets }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| scales: { | |
| r: { | |
| angleLines: { color: "rgba(0,0,0,.08)" }, | |
| grid: { color: "rgba(0,0,0,.08)" }, | |
| suggestedMin: -1.5, suggestedMax: 2.5, | |
| ticks: { backdropColor: "transparent" } | |
| } | |
| }, | |
| plugins: { legend: { position: "top" } } | |
| } | |
| }); | |
| } | |
| // ----- Sample videos: tabs per topic ----- | |
| function renderTabs(top3){ | |
| if (!top3.length){ | |
| tabsEl.innerHTML = ""; | |
| panesEl.innerHTML = ""; | |
| return; | |
| } | |
| tabsEl.innerHTML = top3.map((t, i) => ` | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link ${i===0?'active':''}" id="tab-${i}" | |
| data-bs-toggle="tab" data-bs-target="#pane-${i}" type="button" | |
| role="tab" aria-controls="pane-${i}" aria-selected="${i===0}"> | |
| ${t.display_name} | |
| </button> | |
| </li> | |
| `).join(""); | |
| panesEl.innerHTML = top3.map((t, i) => ` | |
| <div class="tab-pane fade ${i===0?'show active':''}" id="pane-${i}" role="tabpanel" aria-labelledby="tab-${i}"> | |
| ${renderVideosTable(t)} | |
| </div> | |
| `).join(""); | |
| } | |
| function renderVideosTable(topic){ | |
| if (!topic.sample_videos.length){ | |
| return `<div class="text-muted small">No sample videos for this topic.</div>`; | |
| } | |
| const rows = topic.sample_videos.map(v => { | |
| const views = Number(v.viewCount_3d || 0); | |
| const eng = Number(v.engagement_ratio_1d || 0); | |
| const spike = Number(v.spike_index || 0); | |
| return ` | |
| <tr> | |
| <td style="width:64px;"> | |
| <a href="https://www.youtube.com/watch?v=${v.video_id}" target="_blank" rel="noopener"> | |
| <img src="${v.thumbnail_url}" alt="${(v.title||'').replace(/"/g,'"')}" | |
| loading="lazy" style="width:56px;height:32px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb;"> | |
| </a> | |
| </td> | |
| <td> | |
| <a class="link-dark text-decoration-none" | |
| href="https://www.youtube.com/watch?v=${v.video_id}" target="_blank" rel="noopener" | |
| title="${(v.title||'').replace(/"/g,'"')}"> | |
| ${v.title || "(untitled)"} | |
| </a> | |
| </td> | |
| <td class="text-end">${views >= 1000 ? Math.round(views).toLocaleString() : Math.round(views*100)/100}</td> | |
| <td class="text-end">${Math.round(eng*100)/100}</td> | |
| <td class="text-end">${Math.round(spike*100)/100}</td> | |
| </tr> | |
| `; | |
| }).join(""); | |
| return ` | |
| <div class="table-responsive"> | |
| <table class="table table-sm table-striped table-hover align-middle mb-0"> | |
| <thead class="table-light sticky-top" style="top:0; z-index:1;"> | |
| <tr> | |
| <th style="width:64px;">Thumb</th> | |
| <th>Title</th> | |
| <th class="text-end">Views (3d)</th> | |
| <th class="text-end">Eng. (1d)</th> | |
| <th class="text-end">Spike</th> | |
| </tr> | |
| </thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </div>`; | |
| } | |
| // ----- Header badge ----- | |
| function renderBadge(){ | |
| const cfg = COLORS[selectedArch] || {}; | |
| badgeEl.textContent = selectedArch; | |
| badgeEl.style.display = "inline-block"; | |
| badgeEl.style.background = cfg.chip || "#f3f4f6"; | |
| badgeEl.style.color = cfg.text || "#111827"; | |
| badgeEl.style.border = "1px solid rgba(0,0,0,.12)"; | |
| } | |
| // ----- Master render ----- | |
| function renderAll(){ | |
| renderBadge(); | |
| const top3 = top3For(selectedArch); | |
| renderCards(top3); | |
| renderRadar(top3); | |
| renderTabs(top3); | |
| } | |
| // init | |
| renderChips(); | |
| renderAll(); | |
| }); | |
| </script> | |
| </div> | |
| <!-- TAB 3: Nascent Trends --> | |
| <div class="tab-pane fade" id="nascent-trends" role="tabpanel" aria-labelledby="nascent-trends-tab"> | |
| <!-- Sub-tabs header --> | |
| <div class="card mb-3"> | |
| <div class="card-body py-2"> | |
| <ul class="nav nav-pills gap-2 subtabs" id="nascent-subtabs" role="tablist"> | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link active" id="nt-subtab" | |
| data-bs-toggle="tab" data-bs-target="#nt-pane" | |
| type="button" role="tab" aria-controls="nt-pane" aria-selected="true"> | |
| Nascent Topics | |
| </button> | |
| </li> | |
| <li class="nav-item" role="presentation"> | |
| <button class="nav-link" id="nv-subtab" | |
| data-bs-toggle="tab" data-bs-target="#nv-pane" | |
| type="button" role="tab" aria-controls="nv-pane" aria-selected="false"> | |
| Nascent Videos | |
| </button> | |
| </li> | |
| </ul> | |
| </div> | |
| </div> | |
| <!-- Sub-tabs content --> | |
| <div class="tab-content"> | |
| <!-- ========== Sub-tab A: Nascent Topics ========== --> | |
| <div class="tab-pane fade show active" id="nt-pane" role="tabpanel" aria-labelledby="nt-subtab"> | |
| <div class="card mb-4"> | |
| <div class="card-header bg-light fw-semibold">Nascent Topics</div> | |
| <div class="card-body p-3 p-lg-4"> | |
| <!-- Headline + executive summary (full width) --> | |
| <div class="nt-block mb-3"> | |
| <h5 id="nt-headline" class="mb-2">β</h5> | |
| <p id="nt-exec" class="nt-summary mb-0">β</p> | |
| </div> | |
| <!-- Accent cards row --> | |
| <div class="row g-3 mb-4"> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--indigo h-100"> | |
| <div class="card-header">Key Insights</div> | |
| <div class="card-body"><ul id="nt-insights" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--emerald h-100"> | |
| <div class="card-header">Recommended Actions</div> | |
| <div class="card-body"><ul id="nt-actions" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--rose h-100"> | |
| <div class="card-header">Risks & Watchouts</div> | |
| <div class="card-body"><ul id="nt-risks" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Priority Topics --> | |
| <h6 class="subtitle mb-2">Priority Topics</h6> | |
| <div class="table-wrap shadow-sm rounded-3 mb-4"> | |
| <table class="table table-sm align-middle mb-0"> | |
| <thead class="table-light"> | |
| <tr><th>Topic</th><th>Why Priority</th></tr> | |
| </thead> | |
| <tbody id="nt-priority"></tbody> | |
| </table> | |
| </div> | |
| <!-- All Topics (metrics) --> | |
| <h6 class="subtitle mb-2">All Topics β Metrics</h6> | |
| <div class="table-wrap shadow-sm rounded-3"> | |
| <table class="table table-sm table-striped table-hover align-middle mb-0"> | |
| <thead class="table-light sticky-top" style="top:0;z-index:1;"> | |
| <tr> | |
| <th>Topic</th> | |
| <th class="text-end">Videos</th> | |
| <th class="text-end">Early Burst (med)</th> | |
| <th class="text-end">Growth Ratio (med)</th> | |
| <th class="text-end">Eng. Quality (med)</th> | |
| <th class="text-end">Low Exposure (med)</th> | |
| </tr> | |
| </thead> | |
| <tbody id="nt-rows"> | |
| <tr><td colspan="6" class="text-muted">No nascent topics.</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ========== Sub-tab B: Nascent Videos ========== --> | |
| <div class="tab-pane fade" id="nv-pane" role="tabpanel" aria-labelledby="nv-subtab"> | |
| <div class="card"> | |
| <div class="card-header bg-light fw-semibold">Nascent Videos</div> | |
| <div class="card-body p-3 p-lg-4"> | |
| <!-- Headline + executive summary (full width) --> | |
| <div class="nt-block mb-3"> | |
| <h5 id="nv-headline" class="mb-2">β</h5> | |
| <p id="nv-exec" class="nt-summary mb-0">β</p> | |
| </div> | |
| <!-- Accent cards row --> | |
| <div class="row g-3 mb-4"> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--sky h-100"> | |
| <div class="card-header">Momentum Patterns</div> | |
| <div class="card-body"><ul id="nv-insights" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--amber h-100"> | |
| <div class="card-header">Strategic Recommendations</div> | |
| <div class="card-body"><ul id="nv-actions" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| <div class="col-12 col-lg-4"> | |
| <div class="card card-accent card-accent--rose h-100"> | |
| <div class="card-header">Risks & Watchouts</div> | |
| <div class="card-body"><ul id="nv-risks" class="list-tight mb-0"></ul></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Priority Videos --> | |
| <h6 class="subtitle mb-2">Priority Videos</h6> | |
| <div class="table-wrap shadow-sm rounded-3 mb-4"> | |
| <table class="table table-sm align-middle mb-0"> | |
| <thead class="table-light"> | |
| <tr><th>Title</th><th>Why</th></tr> | |
| </thead> | |
| <tbody id="nv-priority"></tbody> | |
| </table> | |
| </div> | |
| <!-- All Nascent Videos (metrics) --> | |
| <h6 class="subtitle mb-2">All Nascent Videos β Metrics</h6> | |
| <div class="table-wrap shadow-sm rounded-3"> | |
| <table class="table table-sm table-striped table-hover align-middle mb-0"> | |
| <thead class="table-light sticky-top" style="top:0;z-index:1;"> | |
| <tr> | |
| <th style="width:64px;">Thumb</th> | |
| <th>Title</th> | |
| <th class="text-end">Score</th> | |
| <th class="text-end">Early Burst</th> | |
| <th class="text-end">Growth Ratio</th> | |
| <th class="text-end">Eng. Quality</th> | |
| <th class="text-end">Low Exposure</th> | |
| <th class="text-end">Views (3d)</th> | |
| <th class="text-end">Eng. (1d)</th> | |
| <th class="text-end">Topic</th> | |
| <th>Category</th> | |
| </tr> | |
| </thead> | |
| <tbody id="nv-rows"> | |
| <tr><td colspan="11" class="text-muted">No nascent videos.</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> <!-- /.tab-content (subtabs) --> | |
| <!-- ===== JSON payloads (Nascent Trends) ===== --> | |
| {% set _nascent = ( | |
| data['section_3_Nascent_Trends'] | |
| if data is defined and 'section_3_Nascent_Trends' in data | |
| else {} | |
| ) %} | |
| {% set _nt_summary = ( | |
| nt_summary | |
| if nt_summary is defined | |
| else _nascent.get('Nascent_Topics_summary', {}) | |
| ) %} | |
| {% set _nv_summary = ( | |
| nv_summary | |
| if nv_summary is defined | |
| else _nascent.get('Nascent_Videos_summary', {}) | |
| ) %} | |
| {% set _nt_list = ( | |
| nt_list | |
| if nt_list is defined | |
| else _nt_summary.get('Nascent_Topics', []) | |
| ) %} | |
| {% set _nv_list = ( | |
| nv_list | |
| if nv_list is defined | |
| else _nv_summary.get('Nascent_Videos', []) | |
| ) %} | |
| <!-- Keep IDs aligned with nascent_section.js --> | |
| <script id="ntSummaryJSON" type="application/json"> | |
| </script> | |
| <script id="nvSummaryJSON" type="application/json"> | |
| </script> | |
| <script id="nascentTopicsJSON" type="application/json"> | |
| </script> | |
| <script id="nascentVideosJSON" type="application/json"> | |
| </script> | |
| <!-- Section 3 JS --> | |
| <script> | |
| (function ready(fn){document.readyState!=="loading"?fn():document.addEventListener("DOMContentLoaded",fn);})(() => { | |
| const PANES = Array.from(document.querySelectorAll('#nascent-trends')); | |
| const ROOT = PANES.find(p => p.querySelector('#ntSummaryJSON')) || PANES[0] || document; | |
| const q = (sel) => ROOT.querySelector(sel) || document.querySelector(sel); | |
| const getJSON = (id, fb=[]) => { | |
| const el = ROOT.querySelector('#'+id) || document.getElementById(id); | |
| if (!el) return Array.isArray(fb)?[]:(typeof fb==='object'?{}:fb); | |
| try { return JSON.parse(el.textContent); } catch { return Array.isArray(fb)?[]:(typeof fb==='object'?{}:fb); } | |
| }; | |
| // nodes | |
| const ntHeadline=q('#nt-headline'), ntExec=q('#nt-exec'), ntRows=q('#nt-rows'); | |
| const ntInsights=q('#nt-insights'), ntActions=q('#nt-actions'), ntRisks=q('#nt-risks'), ntPriority=q('#nt-priority'); | |
| const nvHeadline=q('#nv-headline'), nvExec=q('#nv-exec'), nvRows=q('#nv-rows'); | |
| const nvInsights=q('#nv-insights'), nvActions=q('#nv-actions'), nvRisks=q('#nv-risks'), nvPriorityTb=q('#nv-priority'); | |
| // data | |
| const ntSummary=getJSON('ntSummaryJSON',{}), nvSummary=getJSON('nvSummaryJSON',{}); | |
| const ntList=getJSON('nascentTopicsJSON',[]), nvList=getJSON('nascentVideosJSON',[]); | |
| // helpers | |
| const setText=(el,txt)=>{ if(el) el.textContent=(txt??'β'); }; | |
| const liList=(el,arr)=>{ if(!el) return; const a=Array.isArray(arr)?arr:[]; el.innerHTML=a.length?a.map(s=>`<li>${s}</li>`).join(''):`<li class="text-muted">β</li>`; }; | |
| const n2=(v)=>Number.isFinite(+v)?Math.round(+v*100)/100:'β'; | |
| const i0=(v)=>Number.isFinite(+v)?Math.round(+v).toLocaleString():'β'; | |
| const esc=(s)=>String(s||'').replace(/"/g,'"'); | |
| const asList = (v) => | |
| Array.isArray(v) ? v.filter(Boolean) | |
| : (typeof v === 'string' && v.trim() ? [v.trim()] : []); | |
| // Headlines + executive summaries (unchanged) | |
| setText(ntHeadline, ntSummary.summary_headline || 'Nascent Content Trends'); | |
| setText(ntExec, ntSummary.executive_summary || ''); | |
| setText(nvHeadline, nvSummary.summary_headline || 'Nascent Videos'); | |
| setText(nvExec, nvSummary.executive_summary || ''); | |
| // Accent cards | |
| liList(ntInsights, ntSummary.key_insights || []); | |
| liList(ntActions, ntSummary.recommended_actions || []); | |
| liList(ntRisks, ntSummary.risks_watchouts || []); | |
| // Accent cards β Nascent Videos | |
| const nvPatterns = asList(nvSummary.momentum_patterns || nvSummary.momentum_insights || nvSummary.patterns); | |
| const nvRecs = asList(nvSummary.strategic_recommendations || nvSummary.creative_playbook || nvSummary.recommendations || nvSummary.actions); | |
| const nvRisksArr = asList(nvSummary.risks_watchouts || nvSummary.risk_watchouts || nvSummary.risks || nvSummary.watchouts); | |
| liList(nvInsights, nvPatterns); | |
| liList(nvActions, nvRecs); | |
| liList(nvRisks, nvRisksArr); | |
| // Priority Topics table | |
| if(ntPriority){ | |
| const pr = Array.isArray(ntSummary.priority_topics)?ntSummary.priority_topics:[]; | |
| ntPriority.innerHTML = pr.length ? pr.map(t=>{ | |
| return `<tr><td>${esc(t?.topic_name)}</td><td>${esc(t?.why_priority)}</td></tr>`; | |
| }).join('') : `<tr><td colspan="2" class="text-muted">β</td></tr>`; | |
| } | |
| // All Topics metrics | |
| if(ntRows){ | |
| const rows=(ntList||[]).slice() | |
| .sort((a,b)=>(b.median_growth_ratio||0)-(a.median_growth_ratio||0)) | |
| .map(t=>` | |
| <tr> | |
| <td>${esc(t.topic_name)}</td> | |
| <td class="text-end">${i0(t.n_videos)}</td> | |
| <td class="text-end">${n2(t.median_early_burst)}</td> | |
| <td class="text-end">${n2(t.median_growth_ratio)}</td> | |
| <td class="text-end">${n2(t.median_engagement_quality)}</td> | |
| <td class="text-end">${n2(t.median_low_exposure)}</td> | |
| </tr>`).join(''); | |
| ntRows.innerHTML = rows || `<tr><td colspan="6" class="text-muted">No nascent topics.</td></tr>`; | |
| } | |
| // Priority Videos table (from exemplar_highlights) | |
| if(nvPriorityTb){ | |
| const ex = Array.isArray(nvSummary.exemplar_highlights)?nvSummary.exemplar_highlights:[]; | |
| nvPriorityTb.innerHTML = ex.length ? ex.map(e=>{ | |
| const title=esc(e?.title); | |
| const why =esc(e?.why_nascent || e?.why); | |
| const href = e?.video_id ? `https://www.youtube.com/watch?v=${e.video_id}` : null; | |
| const tcell= href ? `<a class="link-dark text-decoration-none" href="${href}" target="_blank" rel="noopener">${title}</a>` : title; | |
| return `<tr><td>${tcell}</td><td>${why}</td></tr>`; | |
| }).join('') : `<tr><td colspan="2" class="text-muted">β</td></tr>`; | |
| } | |
| // All Videos metrics | |
| if(nvRows){ | |
| const rows=(nvList||[]).slice() | |
| .sort((a,b)=>(b.nascent_video_score||0)-(a.nascent_video_score||0)) | |
| .map(v=>{ | |
| const title=esc(v.title); | |
| const href = v.video_id?`https://www.youtube.com/watch?v=${v.video_id}`:null; | |
| const thumb=v.thumbnail_url?`<img src="${v.thumbnail_url}" alt="${title}" loading="lazy" style="width:56px;height:32px;object-fit:cover;border-radius:6px;border:1px solid #e5e7eb;">`:''; | |
| const tcell=href?`<a class="link-dark text-decoration-none" href="${href}" target="_blank" rel="noopener" title="${title}">${title||'(untitled)'}</a>`:(title||'(untitled)'); | |
| return `<tr> | |
| <td style="width:64px;">${href?`<a href="${href}" target="_blank" rel="noopener">${thumb}</a>`:thumb}</td> | |
| <td>${tcell}</td> | |
| <td class="text-end">${n2(v.nascent_video_score)}</td> | |
| <td class="text-end">${n2(v.early_burst)}</td> | |
| <td class="text-end">${n2(v.growth_ratio)}</td> | |
| <td class="text-end">${n2(v.engagement_quality)}</td> | |
| <td class="text-end">${n2(v.low_exposure)}</td> | |
| <td class="text-end">${i0(v.viewCount_3d)}</td> | |
| <td class="text-end">${n2(v.engagement_ratio_1d)}</td> | |
| <td class="text-end">${(typeof v.bertopic_topic==='number'||typeof v.bertopic_topic==='string')?v.bertopic_topic:'β'}</td> | |
| <td>${esc(v.primary_content_category)}</td> | |
| </tr>`; | |
| }).join(''); | |
| nvRows.innerHTML = rows || `<tr><td colspan="11" class="text-muted">No nascent videos.</td></tr>`; | |
| } | |
| }); | |
| </script> | |
| </div> | |
| </div> | |
| </main> | |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> | |
| </body> | |
| </html> | |