web UI: stats modal → maximalist "observatory" data dashboard
Rebuild the Library statistics modal from plain tiles + flat bars + tables into a real data dashboard, driven by the same /api/stats payload and the theme variables (so all 10 themes inherit it). - Metric cards with giant serif numbers that count up from zero on open (size/runtime spin up through their units), an accent top-rule, and a glowing hero tile. - A self-drawing SVG area chart for downloads-per-week: Catmull-Rom-smoothed line that animates in via stroke-dashoffset, a gradient area fill, gridlines, a date axis, and hover dots with tooltips. - A growing-column histogram for videos-by-upload-year with value labels. - A ranked channel leaderboard with animated meter fills, gold/silver/bronze ranks, and a Size/Count segmented toggle that re-animates on switch. - Reveal animations gate behind .sx-go (added post-layout) so they play once per open, not on re-render; honours prefers-reduced-motion; modal widened to 1040px. Verified: JS passes node --check, release builds clean, the live 8081 server serves it, and a headless screenshot of a harness running the *extracted* dashboard CSS+JS against a mock payload confirms the chart math, count-up, histogram and leaderboard all render correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c8cb700f7e
commit
6f61821d1e
1 changed files with 228 additions and 66 deletions
|
|
@ -327,6 +327,79 @@
|
||||||
|
|
||||||
@keyframes uiFade{from{opacity:0}to{opacity:1}}
|
@keyframes uiFade{from{opacity:0}to{opacity:1}}
|
||||||
@keyframes uiDrop{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:none}}
|
@keyframes uiDrop{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:none}}
|
||||||
|
/* ── Stats: "observatory" data dashboard (maximalist) ─────────────────────
|
||||||
|
Scoped under .sx-* and built entirely from the theme variables so it
|
||||||
|
inherits every theme. Reveal animations are gated behind .sx-go (added
|
||||||
|
after the DOM is in place) so they play once per open, not on re-render. */
|
||||||
|
.sx-body{position:relative;font-variant-numeric:tabular-nums}
|
||||||
|
.sx-body::before{content:'';position:absolute;inset:0;pointer-events:none;z-index:0;opacity:.55;
|
||||||
|
background-image:linear-gradient(var(--hair) 1px,transparent 1px),linear-gradient(90deg,var(--hair) 1px,transparent 1px);
|
||||||
|
background-size:34px 34px;
|
||||||
|
-webkit-mask-image:radial-gradient(120% 90% at 25% 0%,#000,transparent 72%);
|
||||||
|
mask-image:radial-gradient(120% 90% at 25% 0%,#000,transparent 72%)}
|
||||||
|
.sx-body>*{position:relative;z-index:1}
|
||||||
|
.sx-eyebrow{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--muted);font-weight:700}
|
||||||
|
.sx-sect{margin:20px 0 9px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
||||||
|
.sx-sect h3{font-family:var(--font-display);font-weight:400;font-size:1.5rem;letter-spacing:.01em;margin:0;line-height:1}
|
||||||
|
.sx-note{margin-left:auto;font-size:11px;color:var(--muted);letter-spacing:.02em;font-variant-numeric:tabular-nums}
|
||||||
|
.sx-empty{color:var(--muted);font-style:italic;font-family:var(--font-display);font-size:1.1rem;padding:10px 2px}
|
||||||
|
|
||||||
|
.sx-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-top:9px}
|
||||||
|
.sx-card{position:relative;overflow:hidden;border:1px solid var(--hair);border-radius:var(--radius);padding:13px 14px;
|
||||||
|
background:linear-gradient(158deg,color-mix(in srgb,var(--card) 92%,transparent),color-mix(in srgb,var(--panel) 78%,transparent));
|
||||||
|
transition:transform .2s,box-shadow .2s,border-color .2s}
|
||||||
|
.sx-card:hover{transform:translateY(-3px);border-color:var(--accent);box-shadow:var(--shadow)}
|
||||||
|
.sx-card::after{content:'';position:absolute;left:0;top:0;height:2px;width:100%;
|
||||||
|
background:linear-gradient(90deg,var(--accent),transparent)}
|
||||||
|
.sx-card .sx-k{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);font-weight:700}
|
||||||
|
.sx-card .sx-v{font-family:var(--font-display);font-weight:400;font-size:2.1rem;line-height:1.04;margin-top:5px;word-break:break-word}
|
||||||
|
.sx-card .sx-sub{font-size:11px;color:var(--muted);margin-top:3px}
|
||||||
|
.sx-hero{grid-column:span 2;
|
||||||
|
background:radial-gradient(150% 130% at 0% 0%,var(--glow),transparent 58%),linear-gradient(158deg,var(--card),var(--panel))}
|
||||||
|
.sx-hero .sx-v{font-size:3.6rem}
|
||||||
|
|
||||||
|
.sx-chart{width:100%;height:auto;display:block;overflow:visible}
|
||||||
|
.sx-chart .sx-line{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;
|
||||||
|
stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset 1.3s cubic-bezier(.65,0,.35,1)}
|
||||||
|
.sx-go .sx-chart .sx-line{stroke-dashoffset:0}
|
||||||
|
.sx-chart .sx-fill{opacity:0;transition:opacity .9s ease .45s}
|
||||||
|
.sx-go .sx-chart .sx-fill{opacity:1}
|
||||||
|
.sx-chart .sx-grid{stroke:var(--hair);stroke-width:1}
|
||||||
|
.sx-chart .sx-axis{fill:var(--muted);font-size:11px;font-family:var(--font-body)}
|
||||||
|
.sx-chart .sx-dot{fill:var(--bg);stroke:var(--accent);stroke-width:2;opacity:0;transition:opacity .4s ease .95s,r .15s}
|
||||||
|
.sx-go .sx-chart .sx-dot{opacity:1}
|
||||||
|
.sx-chart .sx-dot:hover{r:5.5;fill:var(--accent)}
|
||||||
|
|
||||||
|
.sx-2col{display:grid;grid-template-columns:1.05fr 1fr;gap:24px;align-items:start}
|
||||||
|
.sx-cols{display:flex;align-items:flex-end;gap:6px;height:150px;padding-top:20px}
|
||||||
|
.sx-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;min-width:0}
|
||||||
|
.sx-bar{width:100%;max-width:46px;height:0;border-radius:5px 5px 0 0;position:relative;
|
||||||
|
background:linear-gradient(180deg,var(--accent),color-mix(in srgb,var(--accent) 28%,transparent));
|
||||||
|
transition:height .95s cubic-bezier(.22,1,.36,1)}
|
||||||
|
.sx-bar::after{content:attr(data-v);position:absolute;top:-17px;left:-4px;right:-4px;text-align:center;
|
||||||
|
font-size:11px;font-weight:700;color:var(--text);opacity:0;transition:opacity .4s ease .6s}
|
||||||
|
.sx-go .sx-bar::after{opacity:1}
|
||||||
|
.sx-cl{font-size:10px;color:var(--muted);margin-top:6px;font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.sx-seg{margin-left:auto;display:inline-flex;border:1px solid var(--hair);border-radius:999px;overflow:hidden}
|
||||||
|
.sx-seg button{border:none;border-radius:0;background:transparent;color:var(--muted);font-size:11px;padding:4px 13px;font-weight:600}
|
||||||
|
.sx-seg button:hover{color:var(--text);background:transparent}
|
||||||
|
.sx-seg button.on,.sx-seg button.on:hover{background:var(--accent);color:#fff}
|
||||||
|
.sx-lb{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.sx-row{display:grid;grid-template-columns:22px 1fr auto;align-items:center;gap:10px}
|
||||||
|
.sx-rank{font-family:var(--font-display);font-size:1.2rem;color:var(--muted);text-align:center;line-height:1}
|
||||||
|
.sx-lb .sx-row:nth-child(1) .sx-rank{color:#f6c453}
|
||||||
|
.sx-lb .sx-row:nth-child(2) .sx-rank{color:#c7ced8}
|
||||||
|
.sx-lb .sx-row:nth-child(3) .sx-rank{color:#cd8b62}
|
||||||
|
.sx-track{position:relative;height:32px;border-radius:8px;overflow:hidden;display:flex;align-items:center;
|
||||||
|
background:color-mix(in srgb,var(--card) 55%,transparent);border:1px solid var(--hair)}
|
||||||
|
.sx-meter{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:8px;
|
||||||
|
background:linear-gradient(90deg,color-mix(in srgb,var(--accent) 60%,transparent),color-mix(in srgb,var(--accent) 14%,transparent));
|
||||||
|
transition:width 1s cubic-bezier(.22,1,.36,1)}
|
||||||
|
.sx-name{position:relative;z-index:1;padding:0 11px;font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.sx-val{font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||||
|
@media(max-width:760px){.sx-2col{grid-template-columns:1fr;gap:14px}.sx-hero{grid-column:span 2}}
|
||||||
|
|
||||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}}
|
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -2330,9 +2403,9 @@ async function saveSettings(btn){
|
||||||
/* ── Statistics ─────────────────────────────────────────────────── */
|
/* ── Statistics ─────────────────────────────────────────────────── */
|
||||||
async function openStats(){
|
async function openStats(){
|
||||||
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
||||||
bg.innerHTML=`<div class="modal" style="max-width:760px;width:100%">
|
bg.innerHTML=`<div class="modal sx-dash" style="max-width:1040px;width:100%">
|
||||||
<div class="modal-hdr"><h2>📊 Library statistics</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
<div class="modal-hdr"><h2>Library statistics</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||||
<div id="stats-body" style="overflow:auto;max-height:75vh"><em style="color:var(--muted)">Computing…</em></div>
|
<div id="stats-body" class="sx-body" style="overflow:auto;max-height:78vh;padding:2px 2px 6px"><em style="color:var(--muted)">Computing…</em></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(bg);
|
document.body.appendChild(bg);
|
||||||
try{
|
try{
|
||||||
|
|
@ -2347,75 +2420,164 @@ function fmtHours(secs){
|
||||||
return h>0?`${h}h ${m}m`:`${m}m`;
|
return h>0?`${h}h ${m}m`:`${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash the last stats payload so the leaderboard's Size/Count toggle can
|
||||||
|
// re-render without another round-trip.
|
||||||
|
let _statsR=null;
|
||||||
|
|
||||||
function renderStats(r){
|
function renderStats(r){
|
||||||
const body=document.getElementById('stats-body');if(!body)return;
|
const body=document.getElementById('stats-body');if(!body)return;
|
||||||
const tot=fmtHours(r.total_duration_secs);
|
_statsR=r;
|
||||||
|
const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
const wat=fmtHours(r.watched_duration_secs);
|
const wat=fmtHours(r.watched_duration_secs);
|
||||||
const tile=(label,value)=>`<div style="background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px 12px;flex:1;min-width:120px"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">${esc(label)}</div><div style="font-size:18px;font-weight:600;margin-top:2px">${esc(value)}</div></div>`;
|
const pct=r.total_videos?Math.round(r.watched_count/r.total_videos*100):0;
|
||||||
let h=`<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">
|
const weeks=r.downloads_per_week||[], years=r.videos_per_year||[];
|
||||||
${tile('Channels',r.total_channels.toLocaleString())}
|
const wkTotal=weeks.reduce((a,w)=>a+w.count,0), wkPeak=Math.max(0,...weeks.map(w=>w.count));
|
||||||
${tile('Videos',r.total_videos.toLocaleString())}
|
|
||||||
${tile('Playlists',r.total_playlists.toLocaleString())}
|
|
||||||
${tile('Disk used',fmtSize(r.total_size_bytes))}
|
|
||||||
${tile('Total runtime',tot)}
|
|
||||||
${tile('Watched',`${r.watched_count.toLocaleString()} · ${wat}`)}
|
|
||||||
${tile('Continue watching',r.continue_watching_count.toLocaleString())}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
// Downloads per week — horizontal bar chart.
|
// A metric card; the number counts up from 0 on reveal (data-target/kind
|
||||||
const weeks=r.downloads_per_week||[];
|
// are read by the post-render pass).
|
||||||
const maxWeek=Math.max(1,...weeks.map(w=>w.count));
|
const card=(k,target,kind,sub,hero)=>`<div class="sx-card${hero?' sx-hero':''}">
|
||||||
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Downloads — last ${weeks.length} weeks</h3>`;
|
<div class="sx-k">${esc(k)}</div>
|
||||||
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:80px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
|
<div class="sx-v" data-target="${target}" data-kind="${kind}">0</div>
|
||||||
for(const w of weeks){
|
${sub?`<div class="sx-sub">${esc(sub)}</div>`:''}</div>`;
|
||||||
const pct=(w.count/maxWeek)*100;
|
|
||||||
const d=new Date(w.week_start_unix*1000);
|
|
||||||
const lbl=`${d.getMonth()+1}/${d.getDate()}`;
|
|
||||||
const tip=`${lbl}: ${w.count} videos, ${fmtSize(w.size_bytes)}`;
|
|
||||||
h+=`<div title="${esc(tip)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
|
|
||||||
<div style="width:100%;height:${pct}%;background:var(--accent);border-radius:2px 2px 0 0;min-height:${w.count>0?'2px':'0'}"></div>
|
|
||||||
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(lbl)}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
h+=`</div>`;
|
|
||||||
|
|
||||||
// Uploads per year.
|
let h=`<div class="sx-eyebrow">Library observatory</div>
|
||||||
const years=r.videos_per_year||[];
|
<div class="sx-metrics">
|
||||||
if(years.length){
|
${card('Videos archived',r.total_videos,'int','across '+r.total_channels.toLocaleString()+' channels',true)}
|
||||||
const maxYear=Math.max(1,...years.map(y=>y.count));
|
${card('Disk used',r.total_size_bytes,'size','')}
|
||||||
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Videos by upload year</h3>`;
|
${card('Total runtime',r.total_duration_secs,'secs','')}
|
||||||
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:60px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
|
${card('Watched',r.watched_count,'int',pct+'% · '+wat)}
|
||||||
for(const y of years){
|
${card('Playlists',r.total_playlists,'int','')}
|
||||||
const pct=(y.count/maxYear)*100;
|
${card('Continue watching',r.continue_watching_count,'int','in progress')}
|
||||||
h+=`<div title="${esc(y.year+': '+y.count)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
|
</div>
|
||||||
<div style="width:100%;height:${pct}%;background:var(--accent);opacity:.7;border-radius:2px 2px 0 0;min-height:${y.count>0?'2px':'0'}"></div>
|
|
||||||
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(y.year)}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
h+=`</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two side-by-side top-N tables.
|
<div class="sx-sect"><h3>Download activity</h3>
|
||||||
const topTable=(title,rows)=>{
|
<span class="sx-note">${weeks.length} weeks · ${wkTotal.toLocaleString()} videos · peak ${wkPeak}/wk</span></div>
|
||||||
if(!rows||!rows.length)return'';
|
${sxArea(weeks)}
|
||||||
let t=`<h3 style="font-size:13px;margin:4px 0 6px">${esc(title)}</h3>
|
|
||||||
<table style="width:100%;font-size:12px;border-collapse:collapse;margin-bottom:14px"><tbody>`;
|
<div class="sx-2col">
|
||||||
for(const row of rows){
|
<div>
|
||||||
t+=`<tr style="border-bottom:1px solid var(--border)">
|
<div class="sx-sect"><h3>By upload year</h3></div>
|
||||||
<td style="padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px">${esc(row.name)}</td>
|
${sxCols(years)}
|
||||||
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${row.count} videos</td>
|
</div>
|
||||||
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtSize(row.size_bytes)}</td>
|
<div>
|
||||||
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtHours(row.duration_secs)}</td>
|
<div class="sx-sect"><h3>Top channels</h3>
|
||||||
</tr>`;
|
<span class="sx-seg">
|
||||||
}
|
<button class="on" onclick="sxBoardSwitch('size',this)">Size</button>
|
||||||
t+=`</tbody></table>`;
|
<button onclick="sxBoardSwitch('count',this)">Count</button>
|
||||||
return t;
|
</span></div>
|
||||||
};
|
<div id="sx-board">${sxBoard(r.top_channels_by_size,'size')}</div>
|
||||||
h+=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
</div>
|
||||||
<div>${topTable('Top channels by size',r.top_channels_by_size)}</div>
|
|
||||||
<div>${topTable('Top channels by count',r.top_channels_by_count)}</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
body.innerHTML=h;
|
body.innerHTML=h;
|
||||||
|
|
||||||
|
// Trigger the reveal once the DOM is laid out: flip .sx-go (drives the CSS
|
||||||
|
// transitions for the area line, columns and meters) and run the count-ups.
|
||||||
|
requestAnimationFrame(()=>{
|
||||||
|
body.classList.add('sx-go');
|
||||||
|
body.querySelectorAll('.sx-v').forEach(el=>{
|
||||||
|
const tgt=+el.dataset.target, kind=el.dataset.kind;
|
||||||
|
const fmt=v=> kind==='size'?fmtSize(v):kind==='secs'?fmtHours(v):Math.round(v).toLocaleString();
|
||||||
|
if(reduce) el.textContent=fmt(tgt); else sxCount(el,tgt,fmt);
|
||||||
|
});
|
||||||
|
body.querySelectorAll('.sx-bar').forEach(el=>el.style.height=el.dataset.h+'%');
|
||||||
|
body.querySelectorAll('.sx-meter').forEach(el=>el.style.width=el.dataset.w+'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease-out count-up for the headline numbers.
|
||||||
|
function sxCount(el,target,fmt,dur=1100){
|
||||||
|
const t0=performance.now();
|
||||||
|
(function tick(now){
|
||||||
|
const p=Math.min(1,(now-t0)/dur), e=1-Math.pow(1-p,3);
|
||||||
|
el.textContent=fmt(target*e);
|
||||||
|
if(p<1) requestAnimationFrame(tick); else el.textContent=fmt(target);
|
||||||
|
})(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catmull-Rom → cubic-bezier smoothing for the area line.
|
||||||
|
function sxSmooth(pts){
|
||||||
|
if(pts.length<2) return pts.length?`M${pts[0][0]},${pts[0][1]}`:'';
|
||||||
|
let d=`M${pts[0][0]},${pts[0][1]}`;
|
||||||
|
for(let i=0;i<pts.length-1;i++){
|
||||||
|
const p0=pts[i-1]||pts[i],p1=pts[i],p2=pts[i+1],p3=pts[i+2]||p2;
|
||||||
|
const c1x=p1[0]+(p2[0]-p0[0])/6, c1y=p1[1]+(p2[1]-p0[1])/6;
|
||||||
|
const c2x=p2[0]-(p3[0]-p1[0])/6, c2y=p2[1]-(p3[1]-p1[1])/6;
|
||||||
|
d+=` C${c1x.toFixed(1)},${c1y.toFixed(1)} ${c2x.toFixed(1)},${c2y.toFixed(1)} ${p2[0]},${p2[1]}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-drawing SVG area chart of downloads per week.
|
||||||
|
function sxArea(weeks){
|
||||||
|
if(!weeks.length) return '<div class="sx-empty">No download history yet.</div>';
|
||||||
|
const W=1000,H=220,padL=4,padR=4,padT=16,padB=24;
|
||||||
|
const max=Math.max(1,...weeks.map(w=>w.count)), n=weeks.length;
|
||||||
|
const x=i=> padL + (n===1?(W-padL-padR)/2:i*(W-padL-padR)/(n-1));
|
||||||
|
const y=c=> padT + (1-c/max)*(H-padT-padB);
|
||||||
|
const pts=weeks.map((w,i)=>[x(i),y(w.count)]);
|
||||||
|
const line=sxSmooth(pts);
|
||||||
|
const area=`${line} L${x(n-1).toFixed(1)},${H-padB} L${x(0).toFixed(1)},${H-padB} Z`;
|
||||||
|
let grid='',ax='';
|
||||||
|
for(let g=0;g<=3;g++){
|
||||||
|
const val=Math.round(max*(3-g)/3), yy=padT+(g/3)*(H-padT-padB);
|
||||||
|
grid+=`<line class="sx-grid" x1="${padL}" y1="${yy.toFixed(1)}" x2="${W-padR}" y2="${yy.toFixed(1)}"/>`;
|
||||||
|
ax+=`<text class="sx-axis" x="${padL}" y="${(yy-4).toFixed(1)}">${val}</text>`;
|
||||||
|
}
|
||||||
|
let xlab=''; const step=Math.max(1,Math.round(n/6));
|
||||||
|
for(let i=0;i<n;i+=step){
|
||||||
|
const d=new Date(weeks[i].week_start_unix*1000);
|
||||||
|
xlab+=`<text class="sx-axis" x="${x(i).toFixed(1)}" y="${H-7}" text-anchor="middle">${d.getMonth()+1}/${String(d.getDate()).padStart(2,'0')}</text>`;
|
||||||
|
}
|
||||||
|
let dots=''; weeks.forEach((w,i)=>{
|
||||||
|
const d=new Date(w.week_start_unix*1000);
|
||||||
|
dots+=`<circle class="sx-dot" cx="${x(i).toFixed(1)}" cy="${y(w.count).toFixed(1)}" r="3"><title>${esc((d.getMonth()+1)+'/'+d.getDate()+': '+w.count+' videos · '+fmtSize(w.size_bytes))}</title></circle>`;
|
||||||
|
});
|
||||||
|
return `<svg class="sx-chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Downloads per week">
|
||||||
|
<defs><linearGradient id="sxg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="var(--accent)" stop-opacity="0.42"/>
|
||||||
|
<stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
|
||||||
|
${grid}
|
||||||
|
<path class="sx-fill" d="${area}" fill="url(#sxg)"/>
|
||||||
|
<path class="sx-line" d="${line}" pathLength="1"/>
|
||||||
|
${dots}${ax}${xlab}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Growing-column histogram for videos-by-year.
|
||||||
|
function sxCols(years){
|
||||||
|
if(!years.length) return '<div class="sx-empty">No upload-year data.</div>';
|
||||||
|
const max=Math.max(1,...years.map(y=>y.count));
|
||||||
|
let h='<div class="sx-cols">';
|
||||||
|
for(const y of years){
|
||||||
|
const ph=(y.count/max)*100;
|
||||||
|
h+=`<div class="sx-col"><div class="sx-bar" data-h="${ph.toFixed(1)}" data-v="${y.count}"></div><div class="sx-cl">${esc(y.year)}</div></div>`;
|
||||||
|
}
|
||||||
|
return h+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked, animated channel leaderboard. metric: 'size' | 'count'.
|
||||||
|
function sxBoard(rows,metric){
|
||||||
|
if(!rows||!rows.length) return '<div class="sx-empty">No channels.</div>';
|
||||||
|
const val=row=> metric==='size'?row.size_bytes:row.count;
|
||||||
|
const lbl=row=> metric==='size'?fmtSize(row.size_bytes):row.count.toLocaleString()+' videos';
|
||||||
|
const max=Math.max(1,...rows.map(val));
|
||||||
|
let h='<div class="sx-lb">';
|
||||||
|
rows.forEach((row,i)=>{
|
||||||
|
const w=(val(row)/max)*100;
|
||||||
|
h+=`<div class="sx-row"><div class="sx-rank">${i+1}</div>
|
||||||
|
<div class="sx-track"><div class="sx-meter" data-w="${w.toFixed(1)}"></div><div class="sx-name" title="${esc(row.name)}">${esc(row.name)}</div></div>
|
||||||
|
<div class="sx-val">${esc(lbl(row))}</div></div>`;
|
||||||
|
});
|
||||||
|
return h+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segmented Size/Count toggle for the leaderboard.
|
||||||
|
function sxBoardSwitch(metric,btn){
|
||||||
|
const wrap=document.getElementById('sx-board'); if(!wrap||!_statsR) return;
|
||||||
|
btn.parentNode.querySelectorAll('button').forEach(b=>b.classList.toggle('on',b===btn));
|
||||||
|
const rows= metric==='count'?_statsR.top_channels_by_count:_statsR.top_channels_by_size;
|
||||||
|
wrap.innerHTML=sxBoard(rows,metric);
|
||||||
|
requestAnimationFrame(()=>wrap.querySelectorAll('.sx-meter').forEach(m=>m.style.width=m.dataset.w+'%'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Maintenance (library health) ───────────────────────────────── */
|
/* ── Maintenance (library health) ───────────────────────────────── */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue