| <!doctype html> |
| <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"> |
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
| |
| <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> |
|
|
| |
| <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"> |
| |
| <div class="split-2-col pe-lg-3"> |
| <h6 class="mb-3">Video Count by Archetype</h6> |
|
|
| |
| <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> |
|
|
| |
| <script id="pieLabelsJSON" type="application/json">[ |
| {% for c in clusters %}"{{ c.trend_archetype|replace('"','\\"') }}"{% if not loop.last %},{% endif %}{% endfor %} |
| ]</script> |
| <script id="pieDataJSON" type="application/json">[ |
| {% for c in clusters %}{{ (c.video_count or 0)|int }}{% if not loop.last %},{% endif %}{% endfor %} |
| ]</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)", |
| "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 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> |
|
|
| |
| <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> |
|
|
| |
| |
| <script id="metricsOrderJSON" type="application/json"> |
| {{ clusters | map(attribute='trend_archetype') | list | tojson }} |
| </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">[ |
| {% for c in clusters %} |
| { |
| "archetype": "{{ c.trend_archetype|replace('"','\\"') }}", |
| "velocity": [{{ c.velocity_1_3h or 0 }}, {{ c.velocity_3_6h or 0 }}, {{ c.velocity_6_12h or 0 }}, {{ c.velocity_12h_1d or 0 }}, {{ c.velocity_1d_3d or 0 }}], |
| "acceleration": [{{ c.acceleration_1_6h or 0 }}, {{ c.acceleration_6_12h or 0 }}, {{ c.acceleration_12h_1d or 0 }}, {{ c.acceleration_1d_3d or 0 }}], |
| "engagement": [{{ c.engagement_ratio_3h or 0 }}, {{ c.engagement_ratio_6h or 0 }}, {{ c.engagement_ratio_1d or 0 }}, {{ c.engagement_ratio_3d or 0 }}] |
| }{% if not loop.last %},{% endif %} |
| {% endfor %} |
| ]</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"> |
| |
| <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> |
|
|
| |
| <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; } |
| }; |
| |
| |
| const ORDER = parseJSON('metricsOrderJSON', []); |
| const KEYS = parseJSON('metricsKeysJSON', {}); |
| const KEYLABELS = parseJSON('metricsKeyLabelsJSON', {}); |
| const ROWS = parseJSON('metricsRowsJSON', []); |
| |
| |
| 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" |
| }; |
| |
| |
| 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> |
|
|
| |
| <script id="clustersJSON" type="application/json"> |
| {{ (clusters | default([], true)) | tojson | safe }} |
| </script> |
| <script> |
| |
| 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> |
|
|
| |
| <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="{{ url_for('static', filename='js/archetypes_section.js') }}"></script> |
| </div> |
|
|
| |
| <div class="tab-pane fade" id="viral-topics" role="tabpanel" aria-labelledby="viral-topics-tab"> |
| |
| <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> |
|
|
| |
| <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"> |
|
|
| |
| <div id="vt-topcards" class="row g-3 mb-3"> |
| |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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;"> |
| |
| </div> |
| </div> |
|
|
| |
| <div id="vt-empty" class="text-muted small" style="display:none;">No topics available for this archetype.</div> |
| </div> |
| </div> |
|
|
| |
| {% 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"> |
| {{ (_topics | default([], true)) | tojson | safe }} |
| </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> |
|
|
| |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1"></script> |
|
|
| |
| <script> |
| (function ready(fn){ |
| document.readyState !== "loading" ? fn() : document.addEventListener("DOMContentLoaded", fn); |
| })(() => { |
| const ROOT = document.getElementById("viral-topics"); |
| if (!ROOT) return; |
| |
| |
| 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; |
| |
| |
| 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", []); |
| |
| |
| 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 : [] |
| })); |
| |
| |
| const byArch = ORDER.reduce((acc, name) => (acc[name] = [], acc), {}); |
| topics.forEach(t => { if (byArch[t.archetype]) byArch[t.archetype].push(t); }); |
| |
| |
| let selectedArch = ORDER.find(a => (byArch[a] && byArch[a].length)) || ORDER[0]; |
| |
| |
| 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)})`; |
| }; |
| |
| |
| 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(); |
| }); |
| }); |
| } |
| |
| |
| function top3For(archetype){ |
| const rows = (byArch[archetype] || []).slice() |
| .sort((a,b) => b.topic_viral_score - a.topic_viral_score); |
| return rows.slice(0,3); |
| } |
| |
| |
| 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> |
|
|
| |
| <div class="tab-pane fade" id="nascent-trends" role="tabpanel" aria-labelledby="nascent-trends-tab"> |
|
|
| |
| <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> |
|
|
| |
| <div class="tab-content"> |
|
|
| |
| <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"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
|
|
| |
| {% 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', []) |
| ) %} |
|
|
| |
| <script id="ntSummaryJSON" type="application/json"> |
| {{ (_nt_summary | default({}, true)) | tojson | safe }} |
| </script> |
|
|
| <script id="nvSummaryJSON" type="application/json"> |
| {{ (_nv_summary | default({}, true)) | tojson | safe }} |
| </script> |
|
|
| <script id="nascentTopicsJSON" type="application/json"> |
| {{ (_nt_list | default([], true)) | tojson | safe }} |
| </script> |
|
|
| <script id="nascentVideosJSON" type="application/json"> |
| {{ (_nv_list | default([], true)) | tojson | safe }} |
| </script> |
| |
| <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); } |
| }; |
| |
| |
| 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'); |
| |
| |
| const ntSummary=getJSON('ntSummaryJSON',{}), nvSummary=getJSON('nvSummaryJSON',{}); |
| const ntList=getJSON('nascentTopicsJSON',[]), nvList=getJSON('nascentVideosJSON',[]); |
| |
| |
| 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()] : []); |
| |
| |
| 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 || ''); |
| |
| |
| liList(ntInsights, ntSummary.key_insights || []); |
| liList(ntActions, ntSummary.recommended_actions || []); |
| liList(ntRisks, ntSummary.risks_watchouts || []); |
| |
| |
| 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); |
| |
| |
| 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>`; |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| 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> |
|
|