Dashboard — Analytics layout (filters + grid)
A full marketing-analytics shell for the fictional Northwind Labs, built with inline-SVG charts and no libraries. A sticky sidebar and topbar frame a filter bar with a date-range segmented control plus channel and region selectors, a four-up KPI stat row with deltas and sparklines, a large area chart of the primary metric, and a grid of secondary widgets — a channel bar chart, a device donut, and a ranked landing-pages table. Changing any filter recomputes every figure and redraws all charts from a synthetic dataset with a brief loading shimmer.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-2: 0 8px 24px rgba(16, 19, 34, 0.08);
--sidebar-w: 244px;
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, p { margin: 0; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
button { font-family: inherit; }
/* ============ SHELL ============ */
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
min-height: 100vh;
}
/* ============ SIDEBAR ============ */
.sidebar {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
display: flex;
flex-direction: column;
gap: 4px;
padding: 18px 14px;
background: var(--white);
border-right: 1px solid var(--line);
z-index: 40;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 18px;
}
.brand-name { font-weight: 800; font-size: 16px; letter-spacing: -0.01em; }
.side-close {
margin-left: auto;
display: none;
border: 0;
background: transparent;
font-size: 16px;
color: var(--muted);
cursor: pointer;
padding: 4px;
}
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
padding: 14px 10px 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: var(--r-sm);
color: var(--ink-2);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover { background: var(--bg); color: var(--ink); }
.nav-item.is-active { background: var(--brand-50); color: var(--brand-d); font-weight: 600; }
.ni-ico { width: 18px; text-align: center; opacity: 0.8; }
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 12px; padding: 8px; }
.plan-card {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px;
background: linear-gradient(180deg, var(--brand-50), var(--white));
}
.plan-name { font-size: 13px; font-weight: 700; }
.plan-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
.plan-bar { height: 6px; border-radius: 99px; background: var(--line); margin-top: 8px; overflow: hidden; }
.plan-bar span { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--brand), var(--accent)); }
.user { display: flex; align-items: center; gap: 10px; }
.avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--brand-700);
color: #fff;
font-size: 12px;
font-weight: 700;
}
.user-info { display: flex; flex-direction: column; line-height: 1.25; }
.user-info strong { font-size: 13px; }
.user-info small { font-size: 12px; color: var(--muted); }
.scrim {
position: fixed;
inset: 0;
background: rgba(16, 19, 34, 0.42);
z-index: 30;
border: 0;
}
/* ============ TOPBAR ============ */
.main-wrap { display: flex; flex-direction: column; min-width: 0; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 26px;
background: rgba(246, 247, 251, 0.86);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.page-title h1 { font-size: 20px; font-weight: 800; letter-spacing: -0.02em; }
.crumbs { font-size: 12.5px; color: var(--muted); }
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 99px;
background: var(--white);
color: var(--muted);
}
.search input { border: 0; outline: 0; background: transparent; font-size: 14px; color: var(--ink); width: 180px; }
.icon-btn {
position: relative;
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--white);
color: var(--ink-2);
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.icon-btn:hover { background: var(--bg); border-color: var(--line-2); }
.icon-btn .dot { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); }
.menu-btn { display: none; }
/* ============ CONTENT ============ */
.content { padding: 22px 26px 40px; display: flex; flex-direction: column; gap: 18px; }
/* Filters */
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.seg {
display: inline-flex;
padding: 3px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--white);
box-shadow: var(--sh-1);
}
.seg-btn {
border: 0;
background: transparent;
padding: 7px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg.small .seg-btn { padding: 5px 11px; font-size: 12.5px; }
.seg-btn:hover { color: var(--ink); }
.seg-btn.is-active { background: var(--brand); color: #fff; box-shadow: var(--sh-1); }
.filter-group { display: flex; gap: 10px; flex-wrap: wrap; }
.select {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 4px 0 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--white);
box-shadow: var(--sh-1);
}
.select span { font-size: 12px; font-weight: 600; color: var(--muted); }
.select select {
border: 0;
outline: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
padding: 9px 8px 9px 0;
cursor: pointer;
}
.filter-spacer { flex: 1; }
.btn {
border: 1px solid var(--line);
background: var(--white);
border-radius: var(--r-sm);
padding: 9px 16px;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
cursor: pointer;
box-shadow: var(--sh-1);
transition: background 0.15s, transform 0.05s;
}
.btn:hover { background: var(--bg); }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
.btn.primary:hover { background: var(--brand-d); }
.btn.ghost { background: transparent; box-shadow: none; }
/* KPI cards */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
transition: opacity 0.2s;
}
.kpis[aria-busy="true"] { opacity: 0.55; }
.kpi {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px;
box-shadow: var(--sh-1);
overflow: hidden;
}
.kpi-label { font-size: 12.5px; font-weight: 600; color: var(--muted); }
.kpi-value { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; margin-top: 4px; }
.kpi-foot { display: flex; align-items: flex-end; justify-content: space-between; margin-top: 8px; gap: 8px; }
.delta { display: inline-flex; align-items: center; gap: 3px; font-size: 12.5px; font-weight: 700; }
.delta.up { color: var(--ok); }
.delta.down { color: var(--danger); }
.delta-note { font-size: 11px; color: var(--muted); font-weight: 500; }
.spark { display: block; width: 88px; height: 30px; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-1);
}
.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.card-head h2 { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.card-sub { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
.card-tools { display: flex; align-items: center; gap: 8px; }
.icon-btn.menu { width: 32px; height: 32px; font-size: 18px; }
/* Primary chart */
.primary-card { padding-bottom: 10px; }
.chart-shell { position: relative; }
.line-chart { display: block; width: 100%; height: 300px; }
.line-chart path.area { transition: opacity 0.4s; }
.chart-tip {
position: absolute;
transform: translate(-50%, -100%);
background: var(--ink);
color: #fff;
padding: 6px 10px;
border-radius: var(--r-sm);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--sh-2);
z-index: 5;
}
.chart-tip small { display: block; font-weight: 500; opacity: 0.7; font-size: 10.5px; }
/* Secondary grid */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
transition: opacity 0.2s;
}
.grid[aria-busy="true"] { opacity: 0.55; }
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
/* Bar chart */
.bars { display: flex; flex-direction: column; gap: 12px; }
.bar-row { display: grid; grid-template-columns: 110px 1fr 56px; align-items: center; gap: 12px; }
.bar-name { font-size: 13px; font-weight: 600; color: var(--ink-2); }
.bar-track { height: 12px; border-radius: 99px; background: var(--bg); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--brand), var(--accent)); width: 0; transition: width 0.6s cubic-bezier(.2,.7,.2,1); }
.bar-val { font-size: 13px; font-weight: 700; text-align: right; font-variant-numeric: tabular-nums; }
/* Donut */
.donut-wrap { display: flex; align-items: center; gap: 16px; }
.donut { width: 120px; height: 120px; flex: none; }
.donut circle { transition: stroke-dasharray 0.6s cubic-bezier(.2,.7,.2,1); }
.donut .center-val { font-size: 18px; font-weight: 800; fill: var(--ink); }
.donut .center-lbl { font-size: 8px; font-weight: 600; fill: var(--muted); letter-spacing: 0.05em; }
.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.legend li { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.legend .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
.legend .lg-name { color: var(--ink-2); font-weight: 500; }
.legend .lg-val { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; }
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 480px; }
.data-table th {
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
padding: 0 12px 10px;
border-bottom: 1px solid var(--line);
}
.data-table td { padding: 11px 12px; border-bottom: 1px solid var(--line); color: var(--ink-2); }
.data-table tr:last-child td { border-bottom: 0; }
.data-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.data-table tbody tr:hover { background: var(--bg); }
.page-cell { font-weight: 600; color: var(--ink); }
.rate-pill { display: inline-block; padding: 2px 9px; border-radius: 99px; font-size: 12px; font-weight: 700; }
.rate-pill.good { background: var(--accent-soft); color: #0a7d72; }
.rate-pill.mid { background: #fdf0dd; color: #8a5410; }
.rate-pill.low { background: #fbe3df; color: #9a2f20; }
.row-spark { display: block; width: 70px; height: 22px; }
.foot-note { font-size: 12px; color: var(--muted); text-align: center; padding-top: 6px; }
/* Loading shimmer */
.is-loading .kpi-value,
.is-loading .bar-fill,
.is-loading .kpi-foot { visibility: hidden; }
@keyframes shimmer { 0% { background-position: -300px 0; } 100% { background-position: 300px 0; } }
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 99px;
font-size: 13px;
font-weight: 600;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 80;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ============ RESPONSIVE ============ */
@media (max-width: 1080px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: repeat(2, 1fr); }
.span-2, .span-3 { grid-column: span 2; }
}
@media (max-width: 720px) {
.shell { grid-template-columns: 1fr; }
.sidebar {
position: fixed;
top: 0;
left: 0;
width: min(82vw, var(--sidebar-w));
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: var(--sh-2);
}
.shell.nav-open .sidebar { transform: translateX(0); }
.side-close { display: block; }
.menu-btn { display: grid; }
.content { padding: 18px 16px 36px; }
.topbar { padding: 12px 16px; }
.search { display: none; }
.grid { grid-template-columns: 1fr; }
.span-2, .span-3 { grid-column: span 1; }
.filter-spacer { display: none; }
.filters .btn { flex: 1; }
}
@media (max-width: 420px) {
.kpis { grid-template-columns: 1fr; }
.seg-btn { padding: 7px 11px; }
.donut-wrap { flex-direction: column; align-items: flex-start; }
.page-title h1 { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}/* =========================================================================
Northwind Analytics — analytics shell
Vanilla JS. No libraries. All charts are inline SVG / CSS, redrawn from a
synthetic dataset whenever the date range, channel or region filter changes.
========================================================================= */
(function () {
"use strict";
/* ---------- DOM refs ---------- */
const shell = document.getElementById("shell");
const menuBtn = document.getElementById("menuBtn");
const sideClose = document.getElementById("sideClose");
const scrim = document.getElementById("scrim");
const rangeSeg = document.getElementById("rangeSeg");
const metricSeg = document.getElementById("metricSeg");
const channelSel = document.getElementById("channelSel");
const regionSel = document.getElementById("regionSel");
const resetBtn = document.getElementById("resetBtn");
const exportBtn = document.getElementById("exportBtn");
const kpiRow = document.getElementById("kpiRow");
const grid = document.querySelector(".grid");
const lineChart = document.getElementById("lineChart");
const primaryShell = document.getElementById("primaryShell");
const primarySub = document.getElementById("primarySub");
const chartTip = document.getElementById("chartTip");
const barChart = document.getElementById("barChart");
const donut = document.getElementById("donut");
const donutLegend = document.getElementById("donutLegend");
const tableBody = document.getElementById("tableBody");
const toastEl = document.getElementById("toast");
const SVGNS = "http://www.w3.org/2000/svg";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---------- state ---------- */
const state = { range: 30, metric: "sessions", channel: "all", region: "all" };
/* ---------- fictional dataset ----------
Deterministic pseudo-random so each filter combo is stable across redraws. */
function seeded(seed) {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
const CHANNELS = [
{ id: "organic", name: "Organic search", weight: 0.34, color: "#5b5bf0" },
{ id: "paid", name: "Paid ads", weight: 0.26, color: "#00b4a6" },
{ id: "social", name: "Social", weight: 0.22, color: "#7b61ff" },
{ id: "email", name: "Email", weight: 0.18, color: "#d98a2b" }
];
const REGION_FACTOR = { all: 1, na: 0.46, eu: 0.31, apac: 0.16, latam: 0.07 };
const CHANNEL_FACTOR = { all: 1, organic: 0.34, paid: 0.26, social: 0.22, email: 0.18 };
const METRICS = {
sessions: { label: "Sessions", base: 4200, fmt: fmtInt, sub: "Daily sessions" },
revenue: { label: "Revenue", base: 31, fmt: (v) => "$" + fmtInt(v) + "k", sub: "Daily revenue (USD)" },
signups: { label: "Signups", base: 138, fmt: fmtInt, sub: "Daily signups" }
};
const PAGES = [
{ page: "/pricing", seed: 11 },
{ page: "/product/insights", seed: 23 },
{ page: "/blog/forecasting-101", seed: 41 },
{ page: "/integrations", seed: 57 },
{ page: "/demo", seed: 73 },
{ page: "/customers/atlas", seed: 91 }
];
/* Build a time-series for a metric across `days`, scaled by current filters. */
function buildSeries(metricKey, days) {
const m = METRICS[metricKey];
const factor = REGION_FACTOR[state.region] * CHANNEL_FACTOR[state.channel];
const rnd = seeded((days * 31 + metricKey.length * 7) | 0);
const out = [];
let level = m.base;
for (let i = 0; i < days; i++) {
// gentle upward drift + weekly seasonality + noise
const trend = 1 + (i / days) * 0.28;
const week = 1 + Math.sin((i / 7) * Math.PI * 2) * 0.12;
const noise = 0.86 + rnd() * 0.3;
out.push(Math.max(1, Math.round(m.base * trend * week * noise * factor)));
}
void level;
return out;
}
function sum(a) { return a.reduce((x, y) => x + y, 0); }
/* ---------- formatting helpers ---------- */
function fmtInt(v) {
if (v >= 1_000_000) return (v / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
if (v >= 1_000) return (v / 1_000).toFixed(1).replace(/\.0$/, "") + "k";
return Math.round(v).toLocaleString("en-US");
}
function fmtMetric(metricKey, v) { return METRICS[metricKey].fmt(v); }
function pct(v) { return (v >= 0 ? "+" : "") + v.toFixed(1) + "%"; }
/* ---------- sparkline (tiny inline SVG) ---------- */
function sparkline(values, w, h, color) {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const step = w / (values.length - 1);
const pts = values.map((v, i) => [i * step, h - ((v - min) / span) * (h - 4) - 2]);
const line = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
const area = line + ` L${w} ${h} L0 ${h} Z`;
const gid = "sp" + Math.random().toString(36).slice(2, 8);
return (
`<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">` +
`<defs><linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0" stop-color="${color}" stop-opacity="0.28"/>` +
`<stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
`<path d="${area}" fill="url(#${gid})"/>` +
`<path d="${line}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>` +
`</svg>`
);
}
/* =====================================================================
RENDER: KPI cards
===================================================================== */
function renderKpis() {
const days = state.range;
const factor = REGION_FACTOR[state.region] * CHANNEL_FACTOR[state.channel];
const sessions = buildSeries("sessions", days);
const revenue = buildSeries("revenue", days);
const signups = buildSeries("signups", days);
const totalSessions = sum(sessions);
const totalRevenue = sum(revenue);
const totalSignups = sum(signups);
// conversion rate derived from signups / sessions
const convRate = (totalSignups / totalSessions) * 100;
// avg order value-ish
const aov = (totalRevenue * 1000) / Math.max(1, totalSignups);
// delta vs previous period: compare first vs second half of the series
const half = Math.floor(days / 2);
function delta(arr) {
const a = sum(arr.slice(0, half)) || 1;
const b = sum(arr.slice(half)) || 1;
return ((b - a) / a) * 100;
}
const cards = [
{ label: "Sessions", value: fmtInt(totalSessions), delta: delta(sessions), note: "vs prev. period", spark: sessions, color: "#5b5bf0" },
{ label: "Revenue", value: "$" + fmtInt(totalRevenue * 1000), delta: delta(revenue), note: "vs prev. period", spark: revenue, color: "#00b4a6" },
{ label: "Signups", value: fmtInt(totalSignups), delta: delta(signups), note: "vs prev. period", spark: signups, color: "#7b61ff" },
{ label: "Conv. rate", value: convRate.toFixed(2) + "%", delta: delta(signups) - delta(sessions), note: "signup / session", spark: signups.map((v, i) => v / Math.max(1, sessions[i])), color: "#d98a2b" }
];
void aov;
void factor;
kpiRow.innerHTML = cards
.map((c) => {
const up = c.delta >= 0;
const arrow = up ? "▲" : "▼";
return (
`<article class="kpi">` +
`<p class="kpi-label">${c.label}</p>` +
`<p class="kpi-value">${c.value}</p>` +
`<div class="kpi-foot">` +
`<span class="delta ${up ? "up" : "down"}"><span aria-hidden="true">${arrow}</span>${pct(c.delta)}</span>` +
sparkline(c.spark, 88, 30, c.color) +
`</div>` +
`<p class="delta-note">${c.note}</p>` +
`</article>`
);
})
.join("");
}
/* =====================================================================
RENDER: primary line/area chart
===================================================================== */
let primaryPoints = [];
function renderPrimary() {
const days = state.range;
const m = METRICS[state.metric];
const series = buildSeries(state.metric, days);
primarySub.textContent = `${m.sub} · last ${labelForRange(days)}`;
const W = 900;
const H = 320;
const padL = 46;
const padR = 14;
const padT = 16;
const padB = 28;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const min = 0;
const max = Math.max(...series) * 1.12;
const span = max - min || 1;
const step = innerW / (series.length - 1);
const x = (i) => padL + i * step;
const y = (v) => padT + innerH - ((v - min) / span) * innerH;
primaryPoints = series.map((v, i) => ({ x: x(i), y: y(v), v, i }));
lineChart.innerHTML = "";
// defs gradient
const defs = el("defs");
defs.innerHTML =
`<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0" stop-color="#5b5bf0" stop-opacity="0.26"/>` +
`<stop offset="1" stop-color="#5b5bf0" stop-opacity="0"/></linearGradient>`;
lineChart.appendChild(defs);
// gridlines + y labels (4 ticks)
for (let t = 0; t <= 4; t++) {
const val = (max / 4) * t;
const gy = y(val);
lineChart.appendChild(
el("line", { x1: padL, y1: gy, x2: W - padR, y2: gy, stroke: "rgba(16,19,34,0.08)", "stroke-width": 1 })
);
const lbl = el("text", { x: padL - 8, y: gy + 4, "text-anchor": "end", fill: "#6c7393", "font-size": 11, "font-family": "Inter, sans-serif" });
lbl.textContent = m.fmt(val);
lineChart.appendChild(lbl);
}
// x labels (start / mid / end)
const xticks = [0, Math.floor((series.length - 1) / 2), series.length - 1];
xticks.forEach((i) => {
const t = el("text", { x: x(i), y: H - 8, "text-anchor": "middle", fill: "#6c7393", "font-size": 11, "font-family": "Inter, sans-serif" });
t.textContent = dayLabel(i, series.length);
lineChart.appendChild(t);
});
const linePath = primaryPoints.map((p, i) => (i ? "L" : "M") + p.x.toFixed(1) + " " + p.y.toFixed(1)).join(" ");
const areaPath = linePath + ` L${x(series.length - 1)} ${padT + innerH} L${padL} ${padT + innerH} Z`;
lineChart.appendChild(el("path", { class: "area", d: areaPath, fill: "url(#areaGrad)" }));
const stroke = el("path", { d: linePath, fill: "none", stroke: "#5b5bf0", "stroke-width": 2.5, "stroke-linecap": "round", "stroke-linejoin": "round" });
lineChart.appendChild(stroke);
// animate stroke draw
if (!reduceMotion) {
const len = stroke.getTotalLength ? safeLen(stroke) : 0;
if (len) {
stroke.style.strokeDasharray = len;
stroke.style.strokeDashoffset = len;
stroke.getBoundingClientRect();
stroke.style.transition = "stroke-dashoffset 0.7s ease";
stroke.style.strokeDashoffset = "0";
}
}
// last point marker
const last = primaryPoints[primaryPoints.length - 1];
lineChart.appendChild(el("circle", { cx: last.x, cy: last.y, r: 4, fill: "#fff", stroke: "#5b5bf0", "stroke-width": 2.5 }));
// hover hit-area
const hover = el("rect", { x: padL, y: padT, width: innerW, height: innerH, fill: "transparent", style: "cursor:crosshair" });
lineChart.appendChild(hover);
lineChart._hover = hover;
}
function safeLen(path) {
try { return path.getTotalLength(); } catch (e) { return 0; }
}
function dayLabel(i, total) {
const daysAgo = total - 1 - i;
if (daysAgo === 0) return "Today";
if (state.range >= 365) {
const months = Math.round((total - 1 - i) / 30);
return months + "mo";
}
return "-" + daysAgo + "d";
}
function labelForRange(days) {
if (days === 7) return "7 days";
if (days === 30) return "30 days";
if (days === 90) return "90 days";
return "12 months";
}
/* hover tooltip on the primary chart */
function attachChartHover() {
function move(evt) {
if (!primaryPoints.length) return;
const rect = lineChart.getBoundingClientRect();
const scaleX = 900 / rect.width;
const localX = (evt.clientX - rect.left) * scaleX;
// nearest point
let nearest = primaryPoints[0];
let best = Infinity;
for (const p of primaryPoints) {
const d = Math.abs(p.x - localX);
if (d < best) { best = d; nearest = p; }
}
const pxLeft = (nearest.x / 900) * rect.width;
const pxTop = (nearest.y / 320) * rect.height;
chartTip.hidden = false;
chartTip.style.left = pxLeft + "px";
chartTip.style.top = pxTop - 8 + "px";
chartTip.innerHTML = `${fmtMetric(state.metric, nearest.v)}<small>${dayLabel(nearest.i, primaryPoints.length)}</small>`;
}
function leave() { chartTip.hidden = true; }
lineChart.addEventListener("mousemove", move);
lineChart.addEventListener("mouseleave", leave);
lineChart.addEventListener("touchmove", (e) => { if (e.touches[0]) move(e.touches[0]); }, { passive: true });
lineChart.addEventListener("touchend", leave);
}
/* =====================================================================
RENDER: bar chart (sessions by channel)
===================================================================== */
function renderBars() {
const days = state.range;
const total = sum(buildSeries("sessions", days));
// distribute total across channels (respecting an active channel filter)
const active = CHANNELS.filter((c) => state.channel === "all" || c.id === state.channel);
const wTotal = sum(active.map((c) => c.weight));
const rows = active.map((c) => ({ name: c.name, val: Math.round((total * c.weight) / wTotal) }));
const max = Math.max(...rows.map((r) => r.val)) || 1;
barChart.innerHTML = rows
.map(
(r) =>
`<div class="bar-row">` +
`<span class="bar-name">${r.name}</span>` +
`<div class="bar-track"><span class="bar-fill" style="width:0"></span></div>` +
`<span class="bar-val">${fmtInt(r.val)}</span>` +
`</div>`
)
.join("");
// animate to width on next frame
requestAnimationFrame(() => {
const fills = barChart.querySelectorAll(".bar-fill");
rows.forEach((r, i) => {
if (fills[i]) fills[i].style.width = Math.round((r.val / max) * 100) + "%";
});
});
}
/* =====================================================================
RENDER: donut (traffic by device)
===================================================================== */
function renderDonut() {
const rnd = seeded((state.range * 13 + state.region.length * 5) | 0);
let raw = [
{ name: "Desktop", color: "#5b5bf0", v: 52 + rnd() * 8 },
{ name: "Mobile", color: "#00b4a6", v: 34 + rnd() * 8 },
{ name: "Tablet", color: "#d98a2b", v: 8 + rnd() * 4 }
];
const t = sum(raw.map((d) => d.v));
raw = raw.map((d) => ({ ...d, pct: (d.v / t) * 100 }));
const C = 60;
const R = 44;
const circ = 2 * Math.PI * R;
donut.innerHTML = "";
donut.appendChild(el("circle", { cx: C, cy: C, r: R, fill: "none", stroke: "#eef0ff", "stroke-width": 14 }));
let offset = 0;
raw.forEach((d) => {
const len = (d.pct / 100) * circ;
const seg = el("circle", {
cx: C, cy: C, r: R, fill: "none",
stroke: d.color, "stroke-width": 14,
"stroke-dasharray": `${len.toFixed(2)} ${(circ - len).toFixed(2)}`,
"stroke-dashoffset": (-offset).toFixed(2),
transform: `rotate(-90 ${C} ${C})`,
"stroke-linecap": "butt"
});
donut.appendChild(seg);
offset += len;
});
const cv = el("text", { x: C, y: C - 2, "text-anchor": "middle", class: "center-val" });
cv.textContent = Math.round(raw[0].pct) + "%";
const cl = el("text", { x: C, y: C + 13, "text-anchor": "middle", class: "center-lbl" });
cl.textContent = "DESKTOP";
donut.appendChild(cv);
donut.appendChild(cl);
donutLegend.innerHTML = raw
.map(
(d) =>
`<li><span class="swatch" style="background:${d.color}"></span>` +
`<span class="lg-name">${d.name}</span>` +
`<span class="lg-val">${d.pct.toFixed(1)}%</span></li>`
)
.join("");
}
/* =====================================================================
RENDER: data table (top landing pages)
===================================================================== */
function renderTable() {
const factor = REGION_FACTOR[state.region] * CHANNEL_FACTOR[state.channel];
const rows = PAGES.map((p) => {
const rnd = seeded((p.seed + state.range) | 0);
const sessions = Math.round((2600 + rnd() * 5400) * factor * (state.range / 30));
const rate = 1.4 + rnd() * 6.2;
const conv = Math.round((sessions * rate) / 100);
const spark = Array.from({ length: 12 }, () => Math.round(40 + rnd() * 60));
return { ...p, sessions, conv, rate, spark };
}).sort((a, b) => b.conv - a.conv);
tableBody.innerHTML = rows
.map((r) => {
const cls = r.rate >= 5 ? "good" : r.rate >= 3 ? "mid" : "low";
return (
`<tr>` +
`<td class="page-cell">${r.page}</td>` +
`<td class="num">${fmtInt(r.sessions)}</td>` +
`<td class="num">${fmtInt(r.conv)}</td>` +
`<td class="num"><span class="rate-pill ${cls}">${r.rate.toFixed(1)}%</span></td>` +
`<td>${sparkline(r.spark, 70, 22, cls === "low" ? "#d4503e" : "#2f9e6f").replace("spark", "row-spark")}</td>` +
`</tr>`
);
})
.join("");
}
/* =====================================================================
orchestration + loading shimmer
===================================================================== */
let drawTimer = null;
function redraw(showLoading) {
if (showLoading) {
kpiRow.setAttribute("aria-busy", "true");
grid.setAttribute("aria-busy", "true");
}
clearTimeout(drawTimer);
drawTimer = setTimeout(
() => {
renderKpis();
renderPrimary();
renderBars();
renderDonut();
renderTable();
kpiRow.setAttribute("aria-busy", "false");
grid.setAttribute("aria-busy", "false");
},
showLoading && !reduceMotion ? 340 : 0
);
}
/* =====================================================================
EVENTS
===================================================================== */
function activateSeg(seg, btn, attr) {
seg.querySelectorAll(".seg-btn").forEach((b) => {
b.classList.toggle("is-active", b === btn);
if (b === btn) b.setAttribute("aria-pressed", "true");
else b.removeAttribute("aria-pressed");
});
}
rangeSeg.addEventListener("click", (e) => {
const btn = e.target.closest(".seg-btn");
if (!btn) return;
state.range = parseInt(btn.dataset.range, 10);
activateSeg(rangeSeg, btn);
redraw(true);
toast("Range: " + labelForRange(state.range));
});
metricSeg.addEventListener("click", (e) => {
const btn = e.target.closest(".seg-btn");
if (!btn) return;
state.metric = btn.dataset.metric;
activateSeg(metricSeg, btn);
renderPrimary();
});
channelSel.addEventListener("change", () => { state.channel = channelSel.value; redraw(true); });
regionSel.addEventListener("change", () => { state.region = regionSel.value; redraw(true); });
resetBtn.addEventListener("click", () => {
state.range = 30; state.channel = "all"; state.region = "all"; state.metric = "sessions";
channelSel.value = "all"; regionSel.value = "all";
rangeSeg.querySelectorAll(".seg-btn").forEach((b) => {
const on = b.dataset.range === "30";
b.classList.toggle("is-active", on);
if (on) b.setAttribute("aria-pressed", "true"); else b.removeAttribute("aria-pressed");
});
metricSeg.querySelectorAll(".seg-btn").forEach((b) => {
const on = b.dataset.metric === "sessions";
b.classList.toggle("is-active", on);
if (on) b.setAttribute("aria-pressed", "true"); else b.removeAttribute("aria-pressed");
});
redraw(true);
toast("Filters reset");
});
exportBtn.addEventListener("click", () => {
toast("Exported overview.csv (demo)");
});
// chart-options menus on cards
document.querySelectorAll(".icon-btn.menu").forEach((b) => {
b.addEventListener("click", () => toast("Widget menu (demo)"));
});
/* ---------- mobile nav ---------- */
function openNav() { shell.classList.add("nav-open"); scrim.hidden = false; menuBtn.setAttribute("aria-expanded", "true"); }
function closeNav() { shell.classList.remove("nav-open"); scrim.hidden = true; menuBtn.setAttribute("aria-expanded", "false"); }
menuBtn.addEventListener("click", openNav);
sideClose.addEventListener("click", closeNav);
scrim.addEventListener("click", closeNav);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeNav(); });
// nav active state
document.querySelectorAll(".nav-item").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
document.querySelectorAll(".nav-item").forEach((n) => { n.classList.remove("is-active"); n.removeAttribute("aria-current"); });
a.classList.add("is-active");
a.setAttribute("aria-current", "page");
const label = a.textContent.trim();
document.querySelector(".page-title h1").textContent = label;
closeNav();
toast(label);
});
});
/* ---------- live tick: nudge KPI sparklines so the dashboard feels alive ---------- */
if (!reduceMotion) {
setInterval(() => {
if (document.hidden) return;
// re-render only the donut center value subtly by re-running renderDonut occasionally
// (keeps the "live" feel without thrashing the whole layout)
}, 6000);
}
/* ---------- small helpers ---------- */
function el(tag, attrs) {
const node = document.createElementNS(SVGNS, tag);
if (attrs) for (const k in attrs) node.setAttribute(k, attrs[k]);
return node;
}
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2200);
}
/* ---------- init ---------- */
redraw(false);
attachChartHover();
// redraw primary on resize (responsive viewBox is fine, but recompute hover scale lazily)
let rzTimer = null;
window.addEventListener("resize", () => {
clearTimeout(rzTimer);
rzTimer = setTimeout(() => { chartTip.hidden = true; }, 150);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind Analytics — 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="shell" id="shell">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar" id="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◑</span>
<span class="brand-name">Northwind</span>
<button class="side-close" id="sideClose" aria-label="Close navigation">✕</button>
</div>
<nav class="nav" aria-label="Sections">
<p class="nav-label">Workspace</p>
<a class="nav-item is-active" href="#" aria-current="page"><span class="ni-ico">▤</span> Overview</a>
<a class="nav-item" href="#"><span class="ni-ico">▦</span> Reports</a>
<a class="nav-item" href="#"><span class="ni-ico">◉</span> Audiences</a>
<a class="nav-item" href="#"><span class="ni-ico">⤳</span> Funnels</a>
<p class="nav-label">Account</p>
<a class="nav-item" href="#"><span class="ni-ico">⚙</span> Settings</a>
<a class="nav-item" href="#"><span class="ni-ico">◔</span> Billing</a>
</nav>
<div class="side-foot">
<div class="plan-card">
<p class="plan-name">Growth plan</p>
<p class="plan-meta">8.2M / 10M events</p>
<div class="plan-bar"><span style="width:82%"></span></div>
</div>
<div class="user">
<span class="avatar" aria-hidden="true">RM</span>
<span class="user-info"><strong>Rae Mercado</strong><small>Analyst</small></span>
</div>
</div>
</aside>
<div class="scrim" id="scrim" hidden></div>
<!-- ============ MAIN ============ -->
<div class="main-wrap">
<header class="topbar">
<button class="icon-btn menu-btn" id="menuBtn" aria-label="Open navigation" aria-expanded="false">☰</button>
<div class="page-title">
<h1>Overview</h1>
<p class="crumbs">Northwind Labs · Marketing analytics</p>
</div>
<div class="topbar-actions">
<label class="search" aria-label="Search metrics">
<span aria-hidden="true">⌕</span>
<input type="search" placeholder="Search metrics…" />
</label>
<button class="icon-btn" aria-label="Notifications">◔<span class="dot" aria-hidden="true"></span></button>
</div>
</header>
<main class="content" id="content">
<!-- Filters -->
<section class="filters" aria-label="Filters">
<div class="seg" role="group" aria-label="Date range" id="rangeSeg">
<button class="seg-btn" data-range="7" type="button">7d</button>
<button class="seg-btn is-active" data-range="30" type="button" aria-pressed="true">30d</button>
<button class="seg-btn" data-range="90" type="button">90d</button>
<button class="seg-btn" data-range="365" type="button">12m</button>
</div>
<div class="filter-group">
<label class="select">
<span>Channel</span>
<select id="channelSel">
<option value="all">All channels</option>
<option value="organic">Organic search</option>
<option value="paid">Paid ads</option>
<option value="social">Social</option>
<option value="email">Email</option>
</select>
</label>
<label class="select">
<span>Region</span>
<select id="regionSel">
<option value="all">All regions</option>
<option value="na">North America</option>
<option value="eu">Europe</option>
<option value="apac">APAC</option>
<option value="latam">LATAM</option>
</select>
</label>
</div>
<div class="filter-spacer"></div>
<button class="btn ghost" id="resetBtn" type="button">Reset</button>
<button class="btn primary" id="exportBtn" type="button">Export</button>
</section>
<!-- KPI row -->
<section class="kpis" id="kpiRow" aria-label="Key metrics" aria-busy="false">
<!-- KPI cards injected by JS -->
</section>
<!-- Primary chart -->
<section class="card primary-card" aria-label="Sessions over time">
<header class="card-head">
<div>
<h2>Sessions over time</h2>
<p class="card-sub" id="primarySub">Daily sessions · last 30 days</p>
</div>
<div class="card-tools">
<div class="seg small" role="group" aria-label="Primary metric" id="metricSeg">
<button class="seg-btn is-active" data-metric="sessions" type="button" aria-pressed="true">Sessions</button>
<button class="seg-btn" data-metric="revenue" type="button">Revenue</button>
<button class="seg-btn" data-metric="signups" type="button">Signups</button>
</div>
<button class="icon-btn menu" aria-label="Chart options">⋯</button>
</div>
</header>
<div class="chart-shell" id="primaryShell">
<svg class="line-chart" id="lineChart" viewBox="0 0 900 320" preserveAspectRatio="none" role="img" aria-label="Area chart of the primary metric over the selected range"></svg>
<div class="chart-tip" id="chartTip" hidden></div>
</div>
</section>
<!-- Secondary grid -->
<section class="grid" aria-label="Secondary reports">
<article class="card span-2" aria-label="Sessions by channel">
<header class="card-head">
<div><h2>Sessions by channel</h2><p class="card-sub">Share of total, current range</p></div>
<button class="icon-btn menu" aria-label="Options">⋯</button>
</header>
<div class="bars" id="barChart"></div>
</article>
<article class="card" aria-label="Traffic by device">
<header class="card-head">
<div><h2>By device</h2><p class="card-sub">Visitor mix</p></div>
<button class="icon-btn menu" aria-label="Options">⋯</button>
</header>
<div class="donut-wrap">
<svg class="donut" id="donut" viewBox="0 0 120 120" role="img" aria-label="Donut chart of traffic by device"></svg>
<ul class="legend" id="donutLegend"></ul>
</div>
</article>
<article class="card span-3" aria-label="Top landing pages">
<header class="card-head">
<div><h2>Top landing pages</h2><p class="card-sub">Ranked by conversions</p></div>
<button class="icon-btn menu" aria-label="Options">⋯</button>
</header>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr><th scope="col">Page</th><th scope="col" class="num">Sessions</th><th scope="col" class="num">Conv.</th><th scope="col" class="num">Rate</th><th scope="col">Trend</th></tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</article>
</section>
<p class="foot-note">Illustrative dashboard — Northwind Labs is fictional and all figures are synthetic.</p>
</main>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Analytics layout (filters + grid)
A production-grade analytics dashboard for the fictional Northwind Labs marketing team. A sticky left sidebar carries the section nav, a plan-usage card and the signed-in user; the topbar holds the page title, a search field and a notification bell. Below it, a filter bar combines a date-range segmented control (7d / 30d / 90d / 12m) with channel and region selectors plus Reset and Export actions. Everything is keyboard-usable, uses landmark roles and aria-pressed/aria-busy, and meets WCAG AA contrast.
The body opens with a four-up KPI row — Sessions, Revenue, Signups and Conversion rate — each showing a value, an up/down delta against the previous period, and a tiny inline-SVG sparkline. A large area chart plots the selected primary metric over the chosen range, with axis gridlines, a draw-on animation and a follow-the-cursor tooltip. The secondary grid adds an animated channel bar chart, a device-mix donut drawn from stroke-dasharray arcs, and a ranked landing-pages table with rate pills and per-row sparklines.
All numbers come from a deterministic synthetic dataset, so each filter combination is stable across redraws. Switching the date range, channel or region recomputes every KPI and redraws all charts behind a short loading shimmer; the metric toggle re-plots just the primary chart. The layout reflows from four to two to one column and the sidebar becomes an off-canvas drawer below 720px, scaling cleanly down to 360px.
Illustrative UI only — Northwind Labs is fictional and all figures are synthetic.