| | <!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> |
| |
|