|
<!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/[email protected]/dist/css/bootstrap.min.css"> |
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script> |
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]"></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/[email protected]"></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/[email protected]"></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/[email protected]/dist/js/bootstrap.bundle.min.js"></script> |
|
</body> |
|
</html> |
|
|