Niveytha27's picture
UI and GenAI recommendation changes for Nascent Trends section
50aef51
<!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">
<!-- 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/[email protected]"></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">[
{% 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)", // 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">
{{ 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">
<!-- 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">
{{ (clusters | default([], true)) | tojson | safe }}
</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/[email protected]"></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">
{{ (_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>
<!-- Chart.js (skip if already loaded globally) -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></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,'&quot;')}">${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,'&quot;')}"
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,'&quot;')}">
${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 &amp; 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 &amp; 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">
{{ (_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>
<!-- 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,'&quot;');
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/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>