Streaming — Content Dashboard
A cinematic dark admin dashboard for the fictional Nebula streaming studio, built in HTML, CSS, and vanilla JS. Four KPI cards track active viewers, watch hours, retention, and churn with inline sparklines and trend badges. A full SVG viewership chart redraws on timeframe toggle and metric tabs with hover tooltips, beside a regional heat list, a sortable-feel top titles table, and an episode retention curve. Tapping any title slides in a drill drawer with per-title stats and a mini chart. No libraries, fully responsive.
MCP
Code
:root {
--bg: #0b0b0f;
--surface: #15151c;
--surface-2: #1e1e27;
--ink: #f4f4f7;
--ink-2: #b6b7c3;
--muted: #83859a;
--brand: #e50914;
--accent: #ffffff;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.16);
--good: #2ecc71;
--bad: #ff5470;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 18px;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
--glow: 0 0 0 1px var(--line-2), 0 14px 40px rgba(229, 9, 20, 0.18);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(229, 9, 20, 0.12), transparent 60%),
radial-gradient(900px 500px at -10% 10%, rgba(80, 90, 220, 0.1), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
h1, h2, h3 { margin: 0; letter-spacing: -0.01em; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
.app {
display: grid;
grid-template-columns: 248px 1fr;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 22px 16px;
background: linear-gradient(180deg, var(--surface), rgba(21, 21, 28, 0.6));
border-right: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 8px 18px;
}
.brand__mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: var(--r-sm);
background: linear-gradient(140deg, var(--brand), #b00710);
color: #fff;
font-weight: 800;
font-size: 20px;
box-shadow: 0 6px 20px rgba(229, 9, 20, 0.45);
}
.brand__name { font-weight: 800; font-size: 18px; }
.brand__sub { color: var(--muted); font-weight: 600; margin-left: 4px; font-size: 13px; }
.nav { display: flex; flex-direction: column; gap: 4px; }
.nav__item {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px;
border-radius: var(--r-sm);
color: var(--ink-2);
font-weight: 600;
font-size: 14px;
transition: background 0.18s, color 0.18s;
}
.nav__item:hover { background: var(--surface-2); color: var(--ink); }
.nav__item.is-active {
background: linear-gradient(90deg, rgba(229, 9, 20, 0.16), rgba(229, 9, 20, 0.02));
color: var(--ink);
box-shadow: inset 2px 0 0 var(--brand);
}
.nav__ico { width: 18px; text-align: center; opacity: 0.85; }
.sidebar__foot { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--line); }
.who { display: flex; align-items: center; gap: 10px; }
.who__avatar {
display: grid; place-items: center;
width: 36px; height: 36px; border-radius: 50%;
background: var(--surface-2); border: 1px solid var(--line-2);
font-weight: 700; font-size: 13px; color: var(--ink-2);
}
.who__meta { display: flex; flex-direction: column; line-height: 1.25; }
.who__meta strong { font-size: 13px; }
.who__meta small { color: var(--muted); font-size: 11.5px; }
/* Main */
.main { padding: 22px 26px 40px; min-width: 0; }
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.topbar__title { font-size: 26px; font-weight: 800; }
.topbar__crumb { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
.topbar__tools { display: flex; align-items: center; gap: 12px; }
.timeframe {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
}
.tf {
border: 0;
background: transparent;
color: var(--ink-2);
font-weight: 700;
font-size: 13px;
padding: 7px 14px;
border-radius: 999px;
transition: color 0.18s, background 0.18s;
}
.tf:hover { color: var(--ink); }
.tf.is-active { background: var(--brand); color: #fff; box-shadow: 0 6px 16px rgba(229, 9, 20, 0.4); }
.btn-export {
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
font-weight: 700;
font-size: 13px;
padding: 9px 16px;
border-radius: var(--r-sm);
transition: background 0.18s, border-color 0.18s, transform 0.1s;
}
.btn-export:hover { background: var(--surface-2); border-color: var(--accent); }
.btn-export:active { transform: translateY(1px); }
/* KPIs */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.kpi {
position: relative;
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 16px 16px 8px;
overflow: hidden;
transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s;
}
.kpi:hover { transform: translateY(-3px); box-shadow: var(--glow); border-color: var(--line-2); }
.kpi__top { display: flex; align-items: center; justify-content: space-between; }
.kpi__label { color: var(--ink-2); font-size: 12.5px; font-weight: 600; }
.kpi__badge {
font-size: 11.5px; font-weight: 800;
padding: 3px 8px; border-radius: 999px;
}
.kpi__badge.up { color: var(--good); background: rgba(46, 204, 113, 0.14); }
.kpi__badge.down { color: var(--bad); background: rgba(255, 84, 112, 0.14); }
.kpi__value { font-size: 30px; font-weight: 800; margin: 8px 0 4px; letter-spacing: -0.02em; }
.kpi__spark { height: 32px; }
.kpi__spark svg { width: 100%; height: 100%; display: block; }
.kpi__spark .spark { fill: none; stroke: var(--brand); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; opacity: 0.85; }
.kpi[data-kpi="churn"] .spark { stroke: var(--bad); }
.kpi[data-kpi="retention"] .spark { stroke: var(--good); }
/* Grid */
.grid {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 16px;
}
.card {
background: linear-gradient(180deg, var(--surface), rgba(30, 30, 39, 0.65));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
}
.card--chart { grid-column: 1; grid-row: 1; }
.card--regions { grid-column: 2; grid-row: 1; }
.card--table { grid-column: 1; grid-row: 2; }
.card--retention { grid-column: 2; grid-row: 2; }
.card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.card__title { font-size: 16px; font-weight: 700; }
.card__sub { margin: 3px 0 0; color: var(--muted); font-size: 12.5px; }
.metric-tabs { display: inline-flex; gap: 4px; background: var(--bg); border: 1px solid var(--line); border-radius: 999px; padding: 3px; }
.mt {
border: 0; background: transparent; color: var(--ink-2);
font-size: 12px; font-weight: 700; padding: 6px 11px; border-radius: 999px;
transition: color 0.15s, background 0.15s;
}
.mt:hover { color: var(--ink); }
.mt.is-active { background: var(--surface-2); color: var(--ink); box-shadow: inset 0 0 0 1px var(--line-2); }
/* Charts */
.chart-wrap { position: relative; }
.chart { width: 100%; height: 240px; display: block; overflow: visible; }
.chart-wrap--ret .chart { height: 200px; }
.chart .grid-lines line { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 5; }
.chart .grid-lines text { fill: var(--muted); font-size: 10px; }
.line {
fill: none; stroke: var(--brand); stroke-width: 2.5;
stroke-linecap: round; stroke-linejoin: round;
filter: drop-shadow(0 6px 14px rgba(229, 9, 20, 0.35));
transition: d 0.4s ease;
}
.area { fill: url(#grad); opacity: 0.9; transition: d 0.4s ease; }
.line--ret { stroke: #5b8def; filter: drop-shadow(0 6px 14px rgba(91, 141, 239, 0.3)); }
.area--ret { fill: url(#gradRet); }
.dot { fill: var(--bg); stroke: var(--brand); stroke-width: 2.5; cursor: pointer; transition: r 0.12s; }
.dot:hover, .dot.is-on { r: 6; fill: var(--brand); }
.chart-x { display: flex; justify-content: space-between; margin-top: 6px; color: var(--muted); font-size: 11px; }
.chart-x span { flex: 1; text-align: center; }
.tooltip {
position: absolute;
transform: translate(-50%, -120%);
background: #000;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 7px 10px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
box-shadow: var(--shadow);
z-index: 5;
}
.tooltip.is-on { opacity: 1; }
.tooltip b { color: var(--brand); }
/* Regions */
.regions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 13px; }
.region { display: grid; grid-template-columns: 1fr auto; gap: 4px 10px; }
.region__top { display: flex; align-items: center; justify-content: space-between; grid-column: 1 / -1; }
.region__name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.region__flag { font-size: 15px; }
.region__pct { font-size: 13px; font-weight: 700; color: var(--ink-2); }
.region__bar { grid-column: 1 / -1; height: 7px; border-radius: 999px; background: var(--bg); overflow: hidden; }
.region__fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--brand), #ff5c5c);
width: 0; transition: width 0.6s cubic-bezier(.2,.8,.2,1);
}
/* Table */
.table-scroll { overflow-x: auto; }
.titles { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.titles th {
text-align: left; color: var(--muted); font-weight: 700; font-size: 11.5px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 8px 10px; border-bottom: 1px solid var(--line);
}
.titles th.num, .titles td.num { text-align: right; }
.titles tbody tr {
cursor: pointer; transition: background 0.15s;
}
.titles tbody tr:hover { background: var(--surface-2); }
.titles tbody tr:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.titles td { padding: 11px 10px; border-bottom: 1px solid var(--line); }
.titles .rank { color: var(--muted); font-weight: 800; }
.t-title { display: flex; align-items: center; gap: 11px; }
.t-poster {
width: 30px; height: 42px; border-radius: 5px; flex: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);
}
.t-name { font-weight: 600; }
.t-tag {
font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 5px;
background: var(--surface-2); border: 1px solid var(--line); color: var(--ink-2);
}
.t-tag.series { color: #8fb6ff; border-color: rgba(143, 182, 255, 0.3); }
.t-tag.film { color: #ffcf7a; border-color: rgba(255, 207, 122, 0.3); }
.t-trend { font-weight: 800; }
.t-trend.up { color: var(--good); }
.t-trend.down { color: var(--bad); }
/* Drawer */
.drawer {
position: fixed; inset: 0; z-index: 50;
visibility: hidden; pointer-events: none;
}
.drawer.is-open { visibility: visible; pointer-events: auto; }
.drawer__scrim {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.6);
opacity: 0; transition: opacity 0.25s;
}
.drawer.is-open .drawer__scrim { opacity: 1; }
.drawer__panel {
position: absolute; top: 0; right: 0; bottom: 0;
width: min(420px, 92vw);
background: var(--surface);
border-left: 1px solid var(--line-2);
box-shadow: var(--shadow);
padding: 22px;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(.2,.8,.2,1);
overflow-y: auto;
}
.drawer.is-open .drawer__panel { transform: translateX(0); }
.drawer__head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.drawer__poster { width: 56px; height: 78px; border-radius: var(--r-sm); flex: none; box-shadow: 0 8px 20px rgba(0,0,0,0.5); }
.drawer__kicker { margin: 0; color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.drawer__title { font-size: 20px; font-weight: 800; line-height: 1.2; }
.drawer__close {
margin-left: auto; align-self: flex-start;
width: 34px; height: 34px; border-radius: 50%;
border: 1px solid var(--line-2); background: var(--surface-2); color: var(--ink);
font-size: 15px; transition: background 0.15s;
}
.drawer__close:hover { background: var(--brand); }
.drawer__stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 22px; }
.dstat { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 13px; }
.dstat__label { color: var(--muted); font-size: 11.5px; font-weight: 600; }
.dstat__val { font-size: 21px; font-weight: 800; margin-top: 4px; }
.dstat__val small { font-size: 12px; font-weight: 700; margin-left: 4px; }
.dstat__val small.up { color: var(--good); }
.dstat__val small.down { color: var(--bad); }
.drawer__chart .chart--mini { height: 110px; margin-top: 8px; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 26px;
transform: translate(-50%, 24px);
background: #000; color: var(--ink);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 11px 20px;
font-size: 13.5px; font-weight: 600;
box-shadow: var(--shadow);
opacity: 0; pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 80;
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
/* Responsive */
@media (max-width: 1080px) {
.grid { grid-template-columns: 1fr; }
.card--chart, .card--regions, .card--table, .card--retention { grid-column: 1; grid-row: auto; }
.kpis { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 860px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: static; height: auto; flex-direction: row; align-items: center;
overflow-x: auto; gap: 6px; padding: 12px 14px;
}
.sidebar__foot, .brand__sub { display: none; }
.nav { flex-direction: row; }
.brand { padding: 0 8px 0 0; }
}
@media (max-width: 520px) {
.main { padding: 16px 14px 32px; }
.topbar__title { font-size: 21px; }
.topbar__tools { width: 100%; justify-content: space-between; }
.kpis { grid-template-columns: 1fr 1fr; gap: 10px; }
.kpi__value { font-size: 24px; }
.metric-tabs { width: 100%; justify-content: space-between; }
.titles td:nth-child(6), .titles th:nth-child(6) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}(function () {
"use strict";
/* ---------- Toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-on"); }, 2400);
}
/* ---------- Deterministic pseudo-random ---------- */
function seeded(seed) {
var s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646; };
}
function gradient(seed) {
var r = seeded(seed);
var h1 = Math.floor(r() * 360), h2 = (h1 + 40 + Math.floor(r() * 80)) % 360;
return "linear-gradient(145deg, hsl(" + h1 + " 62% 32%), hsl(" + h2 + " 58% 18%))";
}
function fmt(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
return String(Math.round(n));
}
/* ---------- Data model ---------- */
var RANGES = {
"7d": { points: 7, label: "last 7 days", step: 1, unit: "day" },
"30d": { points: 15, label: "last 30 days", step: 2, unit: "day" },
"90d": { points: 13, label: "last 90 days", step: 7, unit: "wk" },
"12m": { points: 12, label: "last 12 months", step: 1, unit: "mo" }
};
var DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// KPI baselines per range: [value, deltaPct]
var KPI_DATA = {
"7d": { viewers: [184200, 4.2], hours: [1.92e6, 6.1], retention: [71.4, 1.8], churn: [2.3, -0.4] },
"30d": { viewers: [173800, 2.9], hours: [7.8e6, 3.4], retention: [69.8, 0.9], churn: [2.6, -0.2] },
"90d": { viewers: [168400, 1.4], hours: [21.4e6, 2.1], retention: [68.2, 0.4], churn: [3.1, 0.3] },
"12m": { viewers: [159000, 12.6], hours: [82.6e6, 18.2], retention: [66.5, 3.2], churn: [3.4, -0.6] }
};
var TITLES = [
{ name: "Voidrunner", type: "series", views: 4.82e6, hours: 9.1e6, compl: 88, trend: 14, seed: 11 },
{ name: "The Glasshouse", type: "series", views: 3.91e6, hours: 7.4e6, compl: 82, trend: 9, seed: 23 },
{ name: "Pale Horizon", type: "film", views: 3.44e6, hours: 5.2e6, compl: 76, trend: -3, seed: 31 },
{ name: "Saltwater Kings", type: "series", views: 2.98e6, hours: 6.0e6, compl: 79, trend: 6, seed: 47 },
{ name: "Nightmarket", type: "film", views: 2.61e6, hours: 3.9e6, compl: 71, trend: 21, seed: 53 },
{ name: "Echo Division", type: "series", views: 2.20e6, hours: 4.6e6, compl: 84, trend: 2, seed: 67 },
{ name: "Cinder & Smoke", type: "film", views: 1.86e6, hours: 2.7e6, compl: 68, trend: -7, seed: 71 },
{ name: "Harbor Lights", type: "series", views: 1.52e6, hours: 3.1e6, compl: 80, trend: 4, seed: 89 }
];
var REGIONS = [
{ name: "North America", flag: "🌎", pct: 31 },
{ name: "Europe", flag: "🌍", pct: 26 },
{ name: "Latin America", flag: "🌎", pct: 18 },
{ name: "Asia Pacific", flag: "🌏", pct: 16 },
{ name: "MEA", flag: "🌍", pct: 9 }
];
var state = { range: "7d", metric: "hours" };
/* ---------- Series generation ---------- */
function makeSeries(range, metric) {
var cfg = RANGES[range];
var seedBase = (range.charCodeAt(0) + metric.charCodeAt(0)) * 97;
var r = seeded(seedBase);
var n = cfg.points;
var base, amp;
if (metric === "hours") { base = 240; amp = 90; }
else if (metric === "viewers") { base = 170; amp = 70; }
else { base = 75; amp = 12; } // completion %
var out = [], trend = 0;
for (var i = 0; i < n; i++) {
trend += (r() - 0.45) * amp * 0.4;
var wave = Math.sin(i / 1.7 + seedBase) * amp * 0.45;
var v = base + trend + wave + (r() - 0.5) * amp * 0.3;
v = Math.max(base * 0.35, v);
if (metric === "completion") v = Math.min(96, Math.max(52, v));
out.push(v);
}
return out;
}
function xLabels(range, n) {
var cfg = RANGES[range], labels = [];
if (range === "7d") return DAY_LABELS.slice(0, n);
if (range === "12m") return MONTHS.slice();
for (var i = 0; i < n; i++) {
if (cfg.unit === "wk") labels.push("W" + (i + 1));
else labels.push("D" + (i * cfg.step + 1));
}
return labels;
}
/* ---------- SVG path builders ---------- */
function buildPath(values, w, h, pad) {
var n = values.length;
var max = Math.max.apply(null, values) * 1.08;
var min = Math.min.apply(null, values) * 0.85;
var span = max - min || 1;
var innerW = w - pad * 2, innerH = h - pad * 2;
var pts = values.map(function (v, i) {
var x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
var y = pad + innerH - ((v - min) / span) * innerH;
return [x, y];
});
// smooth-ish line via straight segments
var d = pts.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = d + " L" + pts[n - 1][0].toFixed(1) + " " + (h - pad) + " L" + pts[0][0].toFixed(1) + " " + (h - pad) + " Z";
return { line: d, area: area, pts: pts, max: max, min: min };
}
function sparkPath(values, w, h) {
var max = Math.max.apply(null, values), min = Math.min.apply(null, values), span = max - min || 1;
return values.map(function (v, i) {
var x = (i / (values.length - 1)) * w;
var y = h - ((v - min) / span) * (h - 4) - 2;
return (i ? "L" : "M") + x.toFixed(1) + " " + y.toFixed(1);
}).join(" ");
}
/* ---------- SVG gradient defs ---------- */
function ensureDefs(svg, id, color) {
if (svg.querySelector("#" + id)) return;
var ns = "http://www.w3.org/2000/svg";
var defs = document.createElementNS(ns, "defs");
var grad = document.createElementNS(ns, "linearGradient");
grad.setAttribute("id", id);
grad.setAttribute("x1", "0"); grad.setAttribute("y1", "0");
grad.setAttribute("x2", "0"); grad.setAttribute("y2", "1");
var s1 = document.createElementNS(ns, "stop");
s1.setAttribute("offset", "0"); s1.setAttribute("stop-color", color); s1.setAttribute("stop-opacity", "0.32");
var s2 = document.createElementNS(ns, "stop");
s2.setAttribute("offset", "1"); s2.setAttribute("stop-color", color); s2.setAttribute("stop-opacity", "0");
grad.appendChild(s1); grad.appendChild(s2); defs.appendChild(grad);
svg.insertBefore(defs, svg.firstChild);
}
/* ---------- KPI render ---------- */
function renderKPIs() {
var data = KPI_DATA[state.range];
document.querySelectorAll(".kpi").forEach(function (card) {
var key = card.getAttribute("data-kpi");
var d = data[key];
var valEl = card.querySelector("[data-value]");
var deltaEl = card.querySelector("[data-delta]");
var spark = card.querySelector(".spark");
var display;
if (key === "viewers") display = fmt(d[0]);
else if (key === "hours") display = fmt(d[0]) + " h";
else display = d[0].toFixed(1) + "%";
valEl.textContent = display;
var pos = d[1] >= 0;
deltaEl.textContent = (pos ? "+" : "") + d[1].toFixed(1) + "%";
// churn: down is good; flip badge color logic
var good = key === "churn" ? d[1] < 0 : d[1] >= 0;
deltaEl.classList.toggle("up", good);
deltaEl.classList.toggle("down", !good);
if (spark) spark.setAttribute("d", sparkPath(makeSeries(state.range, key === "churn" ? "completion" : key === "retention" ? "completion" : key === "viewers" ? "viewers" : "hours").slice(0, 12), 120, 32));
});
}
/* ---------- Main viewership chart ---------- */
var chart = document.getElementById("chart");
var chartLine = document.getElementById("chartLine");
var chartArea = document.getElementById("chartArea");
var gridLines = document.getElementById("gridLines");
var chartDots = document.getElementById("chartDots");
var chartX = document.getElementById("chartX");
var chartSub = document.getElementById("chartSub");
var tooltip = document.getElementById("tooltip");
var chartWrap = chart.parentElement;
var W = 640, H = 240, PAD = 16;
function metricLabel(m) {
return m === "hours" ? "watch hours" : m === "viewers" ? "viewers" : "completion %";
}
function metricUnit(m, v) {
if (m === "completion") return v.toFixed(1) + "%";
if (m === "viewers") return fmt(v * 1000);
return fmt(v * 1000) + " h";
}
function renderChart() {
ensureDefs(chart, "grad", "#e50914");
var values = makeSeries(state.range, state.metric);
var labels = xLabels(state.range, values.length);
var p = buildPath(values, W, H, PAD);
chartLine.setAttribute("d", p.line);
chartArea.setAttribute("d", p.area);
// gridlines (4 horizontal)
var ns = "http://www.w3.org/2000/svg";
gridLines.innerHTML = "";
for (var g = 0; g <= 3; g++) {
var y = PAD + ((H - PAD * 2) / 3) * g;
var ln = document.createElementNS(ns, "line");
ln.setAttribute("x1", PAD); ln.setAttribute("x2", W - PAD);
ln.setAttribute("y1", y); ln.setAttribute("y2", y);
gridLines.appendChild(ln);
}
// dots
chartDots.innerHTML = "";
p.pts.forEach(function (pt, i) {
var c = document.createElementNS(ns, "circle");
c.setAttribute("cx", pt[0]); c.setAttribute("cy", pt[1]); c.setAttribute("r", 4);
c.setAttribute("class", "dot");
c.setAttribute("tabindex", "0");
c.setAttribute("role", "button");
var label = labels[i] + ": " + metricUnit(state.metric, values[i]);
c.setAttribute("aria-label", label);
function show() {
chartDots.querySelectorAll(".dot").forEach(function (d) { d.classList.remove("is-on"); });
c.classList.add("is-on");
var rect = chart.getBoundingClientRect();
var wrapRect = chartWrap.getBoundingClientRect();
var sx = rect.width / W, sy = rect.height / H;
tooltip.style.left = (rect.left - wrapRect.left + pt[0] * sx) + "px";
tooltip.style.top = (rect.top - wrapRect.top + pt[1] * sy) + "px";
tooltip.innerHTML = labels[i] + " · <b>" + metricUnit(state.metric, values[i]) + "</b>";
tooltip.classList.add("is-on");
}
c.addEventListener("mouseenter", show);
c.addEventListener("focus", show);
c.addEventListener("blur", hideTip);
chartDots.appendChild(c);
});
chart.addEventListener("mouseleave", hideTip);
// x labels
chartX.innerHTML = "";
labels.forEach(function (l) {
var s = document.createElement("span"); s.textContent = l; chartX.appendChild(s);
});
chartSub.textContent = "Daily " + metricLabel(state.metric) + " · " + RANGES[state.range].label;
}
function hideTip() {
tooltip.classList.remove("is-on");
chartDots.querySelectorAll(".dot").forEach(function (d) { d.classList.remove("is-on"); });
}
/* ---------- Retention curve ---------- */
var retChart = document.getElementById("retChart");
var retLine = document.getElementById("retLine");
var retArea = document.getElementById("retArea");
var retGrid = document.getElementById("retGrid");
var retX = document.getElementById("retX");
var retSub = document.getElementById("retSub");
function renderRetention() {
ensureDefs(retChart, "gradRet", "#5b8def");
// retention shifts slightly per range to feel live
var bump = { "7d": 0, "30d": -2, "90d": -4, "12m": 3 }[state.range];
var marks = [100, 94, 88, 83, 79, 75, 72, 69, 66, 63, 60];
var values = marks.map(function (v, i) { return Math.max(40, v + bump - i * 0.3); });
var RW = 640, RH = 200, RP = 14;
var p = buildPath(values, RW, RH, RP);
retLine.setAttribute("d", p.line);
retArea.setAttribute("d", p.area);
var ns = "http://www.w3.org/2000/svg";
retGrid.innerHTML = "";
for (var g = 0; g <= 3; g++) {
var y = RP + ((RH - RP * 2) / 3) * g;
var ln = document.createElementNS(ns, "line");
ln.setAttribute("x1", RP); ln.setAttribute("x2", RW - RP);
ln.setAttribute("y1", y); ln.setAttribute("y2", y);
retGrid.appendChild(ln);
}
retX.innerHTML = "";
["0%", "20%", "40%", "60%", "80%", "100%"].forEach(function (l) {
var s = document.createElement("span"); s.textContent = l; retX.appendChild(s);
});
retSub.textContent = "Viewers remaining over an episode · " + RANGES[state.range].label;
}
/* ---------- Regions ---------- */
function renderRegions() {
var ul = document.getElementById("regions");
ul.innerHTML = "";
var jitter = { "7d": 0, "30d": 1, "90d": -1, "12m": 2 }[state.range];
REGIONS.forEach(function (reg, i) {
var pct = Math.max(4, reg.pct + (i === 0 ? jitter : i === 3 ? -jitter : 0));
var li = document.createElement("li");
li.className = "region";
li.innerHTML =
'<div class="region__top">' +
'<span class="region__name"><span class="region__flag" aria-hidden="true">' + reg.flag + '</span>' + reg.name + '</span>' +
'<span class="region__pct">' + pct + '%</span>' +
'</div>' +
'<div class="region__bar"><div class="region__fill"></div></div>';
ul.appendChild(li);
var fill = li.querySelector(".region__fill");
requestAnimationFrame(function () { fill.style.width = (pct / 31 * 100) + "%"; });
});
}
/* ---------- Titles table ---------- */
function renderTitles() {
var body = document.getElementById("titlesBody");
body.innerHTML = "";
var mult = { "7d": 1, "30d": 4.1, "90d": 11.8, "12m": 46 }[state.range];
TITLES.forEach(function (t, i) {
var tr = document.createElement("tr");
tr.setAttribute("tabindex", "0");
tr.setAttribute("role", "button");
tr.setAttribute("aria-label", "Drill into " + t.name);
var trendCls = t.trend >= 0 ? "up" : "down";
var trendTxt = (t.trend >= 0 ? "▲ " : "▼ ") + Math.abs(t.trend) + "%";
tr.innerHTML =
'<td class="rank">' + (i + 1) + '</td>' +
'<td><div class="t-title">' +
'<span class="t-poster" style="background:' + gradient(t.seed) + '"></span>' +
'<span class="t-name">' + t.name + '</span></div></td>' +
'<td><span class="t-tag ' + t.type + '">' + (t.type === "series" ? "Series" : "Film") + '</span></td>' +
'<td class="num">' + fmt(t.views * mult) + '</td>' +
'<td class="num">' + fmt(t.hours * mult) + ' h</td>' +
'<td class="num">' + t.compl + '%</td>' +
'<td class="num"><span class="t-trend ' + trendCls + '">' + trendTxt + '</span></td>';
tr.addEventListener("click", function () { openDrawer(t, mult); });
tr.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openDrawer(t, mult); }
});
body.appendChild(tr);
});
}
/* ---------- Drawer drill ---------- */
var drawer = document.getElementById("drawer");
var lastFocus = null;
function openDrawer(t, mult) {
lastFocus = document.activeElement;
document.getElementById("drawerType").textContent = t.type === "series" ? "Series" : "Film";
document.getElementById("drawerTitle").textContent = t.name;
document.getElementById("drawerPoster").style.background = gradient(t.seed);
var stats = [
{ label: "Total views", val: fmt(t.views * mult), delta: t.trend, suffix: "" },
{ label: "Watch hours", val: fmt(t.hours * mult), delta: Math.round(t.trend * 0.8), suffix: "" },
{ label: "Completion", val: t.compl + "%", delta: t.trend >= 0 ? 2 : -2, suffix: "" },
{ label: "Avg. rating", val: (3.6 + (t.compl / 100) * 1.3).toFixed(1), delta: 0, suffix: "/5" }
];
document.getElementById("drawerStats").innerHTML = stats.map(function (s) {
var dcls = s.delta > 0 ? "up" : s.delta < 0 ? "down" : "";
var dtxt = s.delta !== 0 ? ' <small class="' + dcls + '">' + (s.delta > 0 ? "+" : "") + s.delta + "%</small>" : "";
return '<div class="dstat"><div class="dstat__label">' + s.label + '</div>' +
'<div class="dstat__val">' + s.val + s.suffix + dtxt + '</div></div>';
}).join("");
// mini chart
var mvals = makeSeries("7d", "hours").map(function (v, i) { return v * (0.6 + (t.compl / 100)) + t.seed; });
var dp = buildPath(mvals, 320, 110, 8);
ensureDefs(document.getElementById("drawerChart"), "grad", "#e50914");
document.getElementById("drawerLine").setAttribute("d", dp.line);
document.getElementById("drawerArea").setAttribute("d", dp.area);
drawer.classList.add("is-open");
drawer.setAttribute("aria-hidden", "false");
document.querySelector(".drawer__close").focus();
document.addEventListener("keydown", escClose);
}
function closeDrawer() {
drawer.classList.remove("is-open");
drawer.setAttribute("aria-hidden", "true");
document.removeEventListener("keydown", escClose);
if (lastFocus) lastFocus.focus();
}
function escClose(e) { if (e.key === "Escape") closeDrawer(); }
drawer.querySelectorAll("[data-close]").forEach(function (el) {
el.addEventListener("click", closeDrawer);
});
/* ---------- Controls ---------- */
document.querySelectorAll(".tf").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.classList.contains("is-active")) return;
document.querySelectorAll(".tf").forEach(function (b) {
b.classList.remove("is-active"); b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-active"); btn.setAttribute("aria-selected", "true");
state.range = btn.getAttribute("data-range");
renderAll();
toast("Showing " + RANGES[state.range].label);
});
});
document.querySelectorAll(".mt").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.classList.contains("is-active")) return;
document.querySelectorAll(".mt").forEach(function (b) {
b.classList.remove("is-active"); b.setAttribute("aria-selected", "false");
});
btn.classList.add("is-active"); btn.setAttribute("aria-selected", "true");
state.metric = btn.getAttribute("data-metric");
hideTip();
renderChart();
});
});
document.getElementById("exportBtn").addEventListener("click", function () {
toast("Report queued — " + RANGES[state.range].label + " export emailed");
});
/* ---------- Render all ---------- */
function renderAll() {
renderKPIs();
renderChart();
renderRetention();
renderRegions();
renderTitles();
}
// resize: reposition tooltip-free, just rebuild dots positions
var rtimer;
window.addEventListener("resize", function () {
clearTimeout(rtimer);
rtimer = setTimeout(function () { hideTip(); }, 120);
});
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nebula — Content Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand__mark" aria-hidden="true">N</span>
<span class="brand__name">Nebula<span class="brand__sub">Studio</span></span>
</div>
<nav class="nav" aria-label="Sections">
<a class="nav__item is-active" href="#" aria-current="page"><span class="nav__ico" aria-hidden="true">▤</span>Overview</a>
<a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">▦</span>Catalog</a>
<a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">◷</span>Audience</a>
<a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">◍</span>Regions</a>
<a class="nav__item" href="#"><span class="nav__ico" aria-hidden="true">⚙</span>Settings</a>
</nav>
<div class="sidebar__foot">
<div class="who">
<span class="who__avatar" aria-hidden="true">AR</span>
<span class="who__meta"><strong>Ada Reyes</strong><small>Content Analyst</small></span>
</div>
</div>
</aside>
<!-- Main -->
<main class="main">
<header class="topbar">
<div>
<h1 class="topbar__title">Content Dashboard</h1>
<p class="topbar__crumb">Performance overview · fictional data</p>
</div>
<div class="topbar__tools">
<div class="timeframe" role="tablist" aria-label="Timeframe">
<button class="tf is-active" role="tab" aria-selected="true" data-range="7d">7D</button>
<button class="tf" role="tab" aria-selected="false" data-range="30d">30D</button>
<button class="tf" role="tab" aria-selected="false" data-range="90d">90D</button>
<button class="tf" role="tab" aria-selected="false" data-range="12m">12M</button>
</div>
<button class="btn-export" id="exportBtn" type="button">Export</button>
</div>
</header>
<!-- KPIs -->
<section class="kpis" aria-label="Key metrics">
<article class="kpi" data-kpi="viewers">
<div class="kpi__top"><span class="kpi__label">Active viewers</span><span class="kpi__badge up" data-delta>+0%</span></div>
<div class="kpi__value" data-value>—</div>
<div class="kpi__spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><path class="spark" d=""/></svg></div>
</article>
<article class="kpi" data-kpi="hours">
<div class="kpi__top"><span class="kpi__label">Watch hours</span><span class="kpi__badge up" data-delta>+0%</span></div>
<div class="kpi__value" data-value>—</div>
<div class="kpi__spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><path class="spark" d=""/></svg></div>
</article>
<article class="kpi" data-kpi="retention">
<div class="kpi__top"><span class="kpi__label">Retention</span><span class="kpi__badge up" data-delta>+0%</span></div>
<div class="kpi__value" data-value>—</div>
<div class="kpi__spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><path class="spark" d=""/></svg></div>
</article>
<article class="kpi" data-kpi="churn">
<div class="kpi__top"><span class="kpi__label">Churn</span><span class="kpi__badge down" data-delta>+0%</span></div>
<div class="kpi__value" data-value>—</div>
<div class="kpi__spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true"><path class="spark" d=""/></svg></div>
</article>
</section>
<div class="grid">
<!-- Viewership chart -->
<section class="card card--chart" aria-label="Viewership trend">
<header class="card__head">
<div>
<h2 class="card__title">Viewership</h2>
<p class="card__sub" id="chartSub">Daily watch hours · last 7 days</p>
</div>
<div class="metric-tabs" role="tablist" aria-label="Metric">
<button class="mt is-active" role="tab" aria-selected="true" data-metric="hours">Watch hours</button>
<button class="mt" role="tab" aria-selected="false" data-metric="viewers">Viewers</button>
<button class="mt" role="tab" aria-selected="false" data-metric="completion">Completion</button>
</div>
</header>
<div class="chart-wrap">
<svg class="chart" id="chart" viewBox="0 0 640 240" preserveAspectRatio="none" role="img" aria-label="Viewership line chart">
<g class="grid-lines" id="gridLines"></g>
<path class="area" id="chartArea" d="" />
<path class="line" id="chartLine" d="" />
<g id="chartDots"></g>
</svg>
<div class="chart-x" id="chartX" aria-hidden="true"></div>
<div class="tooltip" id="tooltip" role="status" aria-live="polite"></div>
</div>
</section>
<!-- Regional heat list -->
<section class="card card--regions" aria-label="Top regions">
<header class="card__head">
<div><h2 class="card__title">Regional heat</h2><p class="card__sub">Share of watch hours</p></div>
</header>
<ul class="regions" id="regions"></ul>
</section>
<!-- Top titles table -->
<section class="card card--table" aria-label="Top titles">
<header class="card__head">
<div><h2 class="card__title">Top titles</h2><p class="card__sub">Tap a row to drill into title metrics</p></div>
</header>
<div class="table-scroll">
<table class="titles">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Type</th>
<th scope="col" class="num">Views</th>
<th scope="col" class="num">Hours</th>
<th scope="col" class="num">Compl.</th>
<th scope="col" class="num">Trend</th>
</tr>
</thead>
<tbody id="titlesBody"></tbody>
</table>
</div>
</section>
<!-- Retention curve -->
<section class="card card--retention" aria-label="Retention curve">
<header class="card__head">
<div><h2 class="card__title">Retention curve</h2><p class="card__sub" id="retSub">Viewers remaining over an episode</p></div>
</header>
<div class="chart-wrap chart-wrap--ret">
<svg class="chart" id="retChart" viewBox="0 0 640 200" preserveAspectRatio="none" role="img" aria-label="Retention curve">
<g class="grid-lines" id="retGrid"></g>
<path class="area area--ret" id="retArea" d="" />
<path class="line line--ret" id="retLine" d="" />
</svg>
<div class="chart-x" id="retX" aria-hidden="true"></div>
</div>
</section>
</div>
</main>
</div>
<!-- Drill drawer -->
<div class="drawer" id="drawer" aria-hidden="true">
<div class="drawer__scrim" data-close></div>
<aside class="drawer__panel" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
<header class="drawer__head">
<div class="drawer__poster" id="drawerPoster" aria-hidden="true"></div>
<div>
<p class="drawer__kicker" id="drawerType">Series</p>
<h3 class="drawer__title" id="drawerTitle">Title</h3>
</div>
<button class="drawer__close" type="button" data-close aria-label="Close">✕</button>
</header>
<div class="drawer__stats" id="drawerStats"></div>
<div class="drawer__chart">
<p class="card__sub">7-day watch hours</p>
<svg viewBox="0 0 320 110" preserveAspectRatio="none" class="chart chart--mini" id="drawerChart" aria-hidden="true">
<path class="area" id="drawerArea" d=""/>
<path class="line" id="drawerLine" d=""/>
</svg>
</div>
</aside>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Content Dashboard
A dark-first analytics console for Nebula Studio, a fictional streaming service. The left rail carries the brand mark, primary navigation, and the signed-in analyst, while the main column opens on four KPI cards — active viewers, watch hours, retention, and churn — each with an inline SVG sparkline and an up/down delta badge that flips color correctly (a falling churn reads as positive). A timeframe pill group (7D / 30D / 90D / 12M) at the top redraws the whole dashboard, recomputing every figure, series, and bar.
The centerpiece is a hand-built SVG viewership chart with dashed gridlines, a gradient area fill, and interactive data dots that surface a positioned tooltip on hover or keyboard focus. Metric tabs swap the line between watch hours, viewers, and completion without touching the timeframe. Alongside it sit a regional heat list with animated share bars, a top titles table, and an episode retention curve that drifts with the selected range. Every number is generated from a seeded pseudo-random model so the data feels alive yet reproducible.
Selecting any row in the top titles table — by click or Enter/Space — slides in a focus-trapped drill drawer showing total views, watch hours, completion, and an average rating, plus a per-title mini chart. The drawer closes on Escape, scrim click, or the close button and returns focus to the originating row. Everything is vanilla JS with a shared toast() helper, real <button> controls with aria roles and visible focus rings, a layout that reflows from desktop down to ~360px, and motion disabled under prefers-reduced-motion.
Illustrative UI only — fictional titles, not a real streaming service.