Travel — Travel Site Dashboard
An editorial analytics dashboard for a fictional travel publication, where a reporting-period switch recomputes everything in vanilla JS. Four KPI cards report visitors, top-destination views, guide views and average read time with coloured deltas and inline SVG sparklines, a seasonality area-and-line chart plots monthly interest against bookings with a hover readout, a sortable top-guides table tracks views, saves and trend, and a destinations-by-region bar shows each area share of guide views.
MCP
Code
:root {
--bg: #fbf7f1;
--surface: #fffdfa;
--ink: #241f1a;
--muted: #6b6259;
--faint: #968b7e;
--teal: #1f8a8a;
--teal-deep: #166a6a;
--coral: #e8623f;
--coral-deep: #c84a2a;
--sand: #e7d8c3;
--amber: #d99425;
--line: rgba(36, 31, 26, 0.12);
--line-soft: rgba(36, 31, 26, 0.07);
--shadow: 0 1px 2px rgba(36, 31, 26, 0.05), 0 12px 30px -18px rgba(36, 31, 26, 0.35);
--radius: 16px;
--rail-w: 232px;
--serif: "Fraunces", Georgia, "Times New Roman", serif;
--sans: "Work Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: var(--sans);
background:
radial-gradient(1200px 500px at 100% -10%, rgba(31, 138, 138, 0.08), transparent 60%),
radial-gradient(900px 460px at -8% 8%, rgba(232, 98, 63, 0.07), transparent 55%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1, h2 { font-family: var(--serif); font-weight: 600; letter-spacing: -0.01em; margin: 0; }
.skip-link {
position: absolute;
left: 12px;
top: -56px;
background: var(--ink);
color: var(--bg);
padding: 10px 16px;
border-radius: 10px;
z-index: 50;
transition: top 0.18s ease;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2.5px solid var(--teal);
outline-offset: 2px;
border-radius: 8px;
}
/* ---------- Shell ---------- */
.shell {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
min-height: 100vh;
}
/* ---------- Rail ---------- */
.rail {
position: sticky;
top: 0;
align-self: start;
height: 100vh;
padding: 22px 18px;
display: flex;
flex-direction: column;
gap: 22px;
background:
linear-gradient(180deg, rgba(36, 31, 26, 0.02), transparent 30%),
var(--surface);
border-right: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark {
display: grid;
place-items: center;
width: 38px; height: 38px;
border-radius: 11px;
color: var(--surface);
background: linear-gradient(150deg, var(--teal), var(--teal-deep));
box-shadow: var(--shadow);
}
.brand-name { font-family: var(--serif); font-size: 1.32rem; font-weight: 700; letter-spacing: -0.02em; }
.rail-nav { display: flex; flex-direction: column; gap: 3px; }
.rail-link {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 11px;
color: var(--muted);
text-decoration: none;
font-weight: 500;
font-size: 0.94rem;
transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.rail-link:hover { background: rgba(31, 138, 138, 0.08); color: var(--ink); }
.rail-link.is-active {
background: linear-gradient(120deg, rgba(31, 138, 138, 0.14), rgba(232, 98, 63, 0.08));
color: var(--ink);
font-weight: 600;
}
.rail-ico {
width: 22px;
text-align: center;
color: var(--teal);
font-size: 0.95rem;
}
.rail-link.is-active .rail-ico { color: var(--coral); }
.rail-foot { margin-top: auto; }
.rail-card {
background: linear-gradient(160deg, rgba(31, 138, 138, 0.1), rgba(232, 98, 63, 0.06));
border: 1px solid var(--line-soft);
border-radius: 13px;
padding: 13px 14px;
}
.rail-card-k { margin: 0 0 4px; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); }
.rail-card-v { margin: 0; font-size: 0.95rem; color: var(--ink); display: flex; align-items: center; gap: 8px; }
.pulse {
width: 9px; height: 9px; border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 0 0 rgba(31, 138, 138, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(31, 138, 138, 0.45); }
70% { box-shadow: 0 0 0 9px rgba(31, 138, 138, 0); }
100% { box-shadow: 0 0 0 0 rgba(31, 138, 138, 0); }
}
/* ---------- Main ---------- */
.main { padding: 26px clamp(18px, 3.4vw, 40px) 40px; min-width: 0; }
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 6px;
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--coral-deep);
font-weight: 600;
}
.topbar h1 { font-size: clamp(1.7rem, 3.4vw, 2.3rem); line-height: 1.1; }
.sub { margin: 7px 0 0; color: var(--muted); max-width: 46ch; }
.topbar-tools { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.seg {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 4px;
box-shadow: var(--shadow);
}
.seg-btn {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: 600;
font-size: 0.86rem;
padding: 7px 14px;
border-radius: 9px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.seg-btn:hover { color: var(--ink); }
.seg-btn.is-active {
background: linear-gradient(150deg, var(--ink), #3a322a);
color: var(--bg);
}
.ghost-btn {
appearance: none;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 0.86rem;
padding: 9px 15px;
border-radius: 11px;
cursor: pointer;
box-shadow: var(--shadow);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.ghost-btn:hover { transform: translateY(-1px); border-color: var(--teal); }
.ghost-btn:active { transform: translateY(0); }
/* ---------- KPI cards ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 18px;
}
.kpi {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 16px 17px 14px;
box-shadow: var(--shadow);
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.kpi::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(var(--teal), var(--coral));
opacity: 0.85;
}
.kpi:hover { transform: translateY(-3px); box-shadow: 0 18px 38px -22px rgba(36, 31, 26, 0.5); }
.kpi-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.kpi-label { font-size: 0.82rem; color: var(--muted); font-weight: 500; }
.kpi-delta {
font-size: 0.76rem;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.kpi-delta.up { color: var(--teal-deep); background: rgba(31, 138, 138, 0.13); }
.kpi-delta.down { color: var(--coral-deep); background: rgba(232, 98, 63, 0.13); }
.kpi-value {
margin: 9px 0 8px;
font-family: var(--serif);
font-size: 1.92rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1;
}
.spark { width: 100%; height: 30px; display: block; }
.spark path.fill { opacity: 0.16; }
.spark path.stroke { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.kpi-foot { margin: 6px 0 0; font-size: 0.74rem; color: var(--faint); }
/* ---------- Grid of panels ---------- */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 18px;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px 19px;
box-shadow: var(--shadow);
min-width: 0;
}
.panel-wide { grid-column: 1 / -1; }
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel-head h2 { font-size: 1.18rem; }
.panel-sub { margin: 4px 0 0; font-size: 0.85rem; color: var(--muted); }
.panel-tag {
font-size: 0.72rem;
color: var(--teal-deep);
background: rgba(31, 138, 138, 0.12);
padding: 4px 10px;
border-radius: 999px;
font-weight: 600;
white-space: nowrap;
}
.chip-legend { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
font-size: 0.74rem;
font-weight: 600;
padding: 4px 11px 4px 22px;
border-radius: 999px;
position: relative;
color: var(--ink);
background: rgba(36, 31, 26, 0.04);
}
.chip::before {
content: "";
position: absolute;
left: 9px; top: 50%;
transform: translateY(-50%);
width: 8px; height: 8px;
border-radius: 50%;
}
.chip-teal::before { background: var(--teal); }
.chip-coral::before { background: var(--coral); }
/* ---------- Season chart ---------- */
.chart-wrap { margin: 0; }
.season-chart { width: 100%; height: auto; display: block; touch-action: none; }
.season-chart .grid-line { stroke: var(--line-soft); stroke-width: 1; }
.season-chart .axis-label { fill: var(--faint); font-size: 12px; font-family: var(--sans); }
.season-chart .interest-fill { fill: var(--teal); opacity: 0.12; }
.season-chart .interest-line { fill: none; stroke: var(--teal); stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; }
.season-chart .booking-line { fill: none; stroke: var(--coral); stroke-width: 2.4; stroke-linejoin: round; stroke-linecap: round; stroke-dasharray: 1 0; }
.season-chart .dot { transition: r 0.12s ease; }
.season-chart .hot-col { fill: transparent; cursor: pointer; }
.season-chart .hover-line { stroke: var(--ink); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; }
.chart-caption {
margin-top: 10px;
font-size: 0.84rem;
color: var(--muted);
min-height: 1.3em;
}
.chart-caption strong { color: var(--ink); }
/* ---------- Region bars ---------- */
.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 13px; }
.bar-row { display: grid; gap: 5px; }
.bar-top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.bar-name { font-weight: 600; font-size: 0.9rem; }
.bar-flag { margin-right: 6px; }
.bar-val { font-size: 0.82rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.bar-track {
height: 11px;
background: rgba(36, 31, 26, 0.06);
border-radius: 999px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 999px;
width: 0;
background: linear-gradient(90deg, var(--teal), #2fa3a3);
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.bar-row:nth-child(2n) .bar-fill { background: linear-gradient(90deg, var(--coral), #f08055); }
.bar-row:nth-child(3n) .bar-fill { background: linear-gradient(90deg, var(--amber), #e8ab48); }
/* ---------- Guides table ---------- */
.table-scroll { overflow-x: auto; }
.guides {
width: 100%;
border-collapse: collapse;
min-width: 540px;
}
.guides th, .guides td {
text-align: left;
padding: 11px 12px;
border-bottom: 1px solid var(--line-soft);
}
.guides th { padding-top: 0; }
.guides .num { text-align: right; }
.guides td.num { font-variant-numeric: tabular-nums; }
.sort {
appearance: none;
border: 0;
background: transparent;
font: inherit;
font-weight: 700;
font-size: 0.76rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 2px;
border-radius: 6px;
}
.th.num .sort, .num .sort { flex-direction: row-reverse; }
.sort:hover { color: var(--ink); }
.sort-ind { font-size: 0.7rem; opacity: 0.4; }
.sort[aria-sort="ascending"] .sort-ind::after { content: "▲"; opacity: 1; }
.sort[aria-sort="descending"] .sort-ind::after { content: "▼"; opacity: 1; }
.sort[aria-sort="ascending"], .sort[aria-sort="descending"] { color: var(--coral-deep); }
.guides tbody tr { transition: background 0.14s ease; }
.guides tbody tr:hover { background: rgba(31, 138, 138, 0.05); }
.guide-cell { display: flex; align-items: center; gap: 11px; }
.guide-thumb {
width: 38px; height: 30px;
border-radius: 7px;
flex: 0 0 auto;
box-shadow: inset 0 0 0 1px var(--line-soft);
}
.guide-title { font-weight: 600; line-height: 1.25; font-size: 0.92rem; }
.guide-tag { font-size: 0.74rem; color: var(--faint); }
.trend {
font-weight: 700;
font-variant-numeric: tabular-nums;
font-size: 0.86rem;
}
.trend.up { color: var(--teal-deep); }
.trend.down { color: var(--coral-deep); }
.trend.flat { color: var(--faint); }
.page-foot {
margin-top: 26px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
color: var(--faint);
font-size: 0.82rem;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 130%);
background: var(--ink);
color: var(--bg);
padding: 12px 20px;
border-radius: 12px;
font-weight: 500;
font-size: 0.9rem;
box-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.32s ease;
z-index: 60;
max-width: min(90vw, 380px);
}
.toast.show { transform: translate(-50%, 0); opacity: 1; }
/* ---------- Responsive ---------- */
@media (max-width: 1080px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: 1fr; }
}
@media (max-width: 820px) {
:root { --rail-w: 0px; }
.shell { grid-template-columns: 1fr; }
.rail {
position: static;
height: auto;
flex-direction: row;
align-items: center;
gap: 16px;
flex-wrap: wrap;
border-right: 0;
border-bottom: 1px solid var(--line);
padding: 14px 16px;
}
.rail-nav { flex-direction: row; flex-wrap: wrap; gap: 4px; }
.rail-link { padding: 8px 11px; }
.rail-foot { margin-top: 0; margin-left: auto; }
.rail-card { padding: 9px 12px; }
}
@media (max-width: 560px) {
.kpis { grid-template-columns: 1fr; }
.topbar { align-items: stretch; }
.topbar-tools { justify-content: space-between; }
.seg-btn { padding: 7px 11px; }
.kpi-value { font-size: 1.7rem; }
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.001ms !important; animation: none !important; scroll-behavior: auto; }
}/* Waymark — Travel Site Dashboard
Vanilla JS. A reporting-period switch recomputes KPIs, sparklines, the
seasonality chart and region bars; the top-guides table is sortable.
All data is fictional and generated deterministically per period. */
(function () {
"use strict";
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
var SVGNS = "http://www.w3.org/2000/svg";
/* ---------- Toast ---------- */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2600);
}
/* ---------- Formatters ---------- */
var nf = new Intl.NumberFormat("en-US");
function compact(n) {
if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 || n % 1e3 === 0 ? 0 : 1) + "k";
return nf.format(Math.round(n));
}
function mmss(totalSeconds) {
var m = Math.floor(totalSeconds / 60);
var s = Math.round(totalSeconds % 60);
return m + ":" + (s < 10 ? "0" : "") + s;
}
function signedPct(p) {
return (p > 0 ? "+" : p < 0 ? "−" : "") + Math.abs(p).toFixed(1) + "%";
}
/* ---------- Period model ----------
Each period defines a base scale + a deterministic seasonality phase so
the curves and KPIs shift believably when you switch range. */
var PERIODS = {
"7": { label: "last 7 days", scale: 0.21, dayCount: 7, read: 214, phase: 6, gain: 1.18 },
"28": { label: "last 28 days", scale: 1.0, dayCount: 28, read: 232, phase: 5, gain: 1.0 },
"90": { label: "last 90 days", scale: 3.05, dayCount: 90, read: 248, phase: 3, gain: 0.86 },
"365": { label: "last 12 months", scale: 12.4, dayCount: 365, read: 266, phase: 0, gain: 0.72 }
};
// Deterministic pseudo-random from an integer seed (no external libs).
function rng(seed) {
var s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
// Monthly seasonality shape (0..1), Northern-hemisphere summer-leaning travel.
var SEASON_SHAPE = [0.34, 0.30, 0.40, 0.55, 0.70, 0.86, 1.0, 0.97, 0.74, 0.58, 0.46, 0.62];
function buildModel(period) {
var p = PERIODS[period];
var r = rng(period.length * 97 + parseInt(period, 10));
// KPI base values scaled by period.
var visitors = Math.round(48200 * p.scale * (0.96 + r() * 0.08));
var destViews = Math.round(31400 * p.scale * (0.95 + r() * 0.1));
var guideViews = Math.round(73900 * p.scale * (0.97 + r() * 0.07));
var readTime = p.read; // seconds
// Deltas vs previous period — coupled to period gain so 7d looks hotter.
function delta(mid) {
return Math.round((mid * p.gain + (r() - 0.5) * 6) * 10) / 10;
}
var kpis = {
visitors: { value: visitors, display: compact(visitors), delta: delta(7.4), unit: "visitors" },
destinations:{ value: destViews, display: compact(destViews), delta: delta(4.2), unit: "views" },
guideViews: { value: guideViews, display: compact(guideViews), delta: delta(9.1), unit: "views" },
readTime: { value: readTime, display: mmss(readTime), delta: delta(-1.6), unit: "min:sec" }
};
// Sparkline series (12 points) per KPI, riding the seasonality shape.
function spark(base, vol) {
var pts = [];
for (var i = 0; i < 12; i++) {
var seasonal = SEASON_SHAPE[(i + p.phase) % 12];
var noise = (r() - 0.5) * vol;
pts.push(base * (0.55 + seasonal * 0.5) * (1 + noise));
}
return pts;
}
kpis.visitors.series = spark(1, 0.22);
kpis.destinations.series = spark(1, 0.18);
kpis.guideViews.series = spark(1, 0.2);
// read time is steadier
kpis.readTime.series = spark(1, 0.08);
// Seasonality chart: interest (index 0..100) + bookings (index 0..100).
var interest = [];
var bookings = [];
for (var m = 0; m < 12; m++) {
var shape = SEASON_SHAPE[m];
interest.push(Math.round(Math.max(8, Math.min(100, shape * 100 * (0.92 + r() * 0.12)))));
// bookings lag interest by ~1 month and sit a bit lower.
var lag = SEASON_SHAPE[(m + 11) % 12];
bookings.push(Math.round(Math.max(6, Math.min(100, lag * 78 * (0.9 + r() * 0.16)))));
}
// Destinations by region — share of guide views (%).
var regionsRaw = [
{ name: "Mediterranean", flag: "🌊" },
{ name: "Southeast Asia", flag: "🏝️" },
{ name: "Andes & Patagonia", flag: "🏔️" },
{ name: "Nordic & Arctic", flag: "❄️" },
{ name: "East Africa", flag: "🦒" },
{ name: "Pacific Coast", flag: "🌅" }
];
var weights = regionsRaw.map(function () { return 0.5 + r(); });
var wTotal = weights.reduce(function (a, b) { return a + b; }, 0);
var regions = regionsRaw.map(function (reg, i) {
var pct = (weights[i] / wTotal) * 100;
return { name: reg.name, flag: reg.flag, pct: pct, views: Math.round(guideViews * pct / 100) };
}).sort(function (a, b) { return b.pct - a.pct; });
// Top guides table.
var guidesBase = [
{ title: "48 Hours in Lisbon", tag: "City guide", grad: ["#1f8a8a", "#2fa3a3"] },
{ title: "Slow Train Through the Alps", tag: "Rail journey", grad: ["#e8623f", "#f08055"] },
{ title: "Kyoto in Cherry Season", tag: "Seasonal", grad: ["#d99425", "#e8ab48"] },
{ title: "Patagonia on Foot", tag: "Trek", grad: ["#166a6a", "#1f8a8a"] },
{ title: "The Amalfi Drive", tag: "Road trip", grad: ["#c84a2a", "#e8623f"] },
{ title: "Marrakech Market Map", tag: "Food & maps", grad: ["#a36b1f", "#d99425"] },
{ title: "Reykjavík to the Ring Road", tag: "Self-drive", grad: ["#2c6e7a", "#3f9aa8"] },
{ title: "Hidden Beaches of Palawan", tag: "Island guide", grad: ["#1f8a8a", "#52b3b3"] }
];
var guides = guidesBase.map(function (g, i) {
var views = Math.round((9400 - i * 760) * p.scale * (0.85 + r() * 0.35));
var saves = Math.round(views * (0.06 + r() * 0.05));
var read = Math.round((180 + r() * 160));
var trend = Math.round(((r() - 0.42) * 24) * 10) / 10;
return { title: g.title, tag: g.tag, grad: g.grad, views: views, saves: saves, read: read, trend: trend };
});
return { kpis: kpis, interest: interest, bookings: bookings, regions: regions, guides: guides };
}
/* ---------- Sparkline renderer ---------- */
function renderSpark(svg, series, color) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
var W = 120, H = 32, pad = 3;
var min = Math.min.apply(null, series);
var max = Math.max.apply(null, series);
var range = max - min || 1;
var n = series.length;
var xy = series.map(function (v, i) {
var x = pad + (i / (n - 1)) * (W - pad * 2);
var y = H - pad - ((v - min) / range) * (H - pad * 2);
return [x, y];
});
var line = xy.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = line + " L" + (W - pad).toFixed(1) + " " + (H - pad) + " L" + pad + " " + (H - pad) + " Z";
var fill = document.createElementNS(SVGNS, "path");
fill.setAttribute("class", "fill");
fill.setAttribute("d", area);
fill.setAttribute("fill", color);
svg.appendChild(fill);
var stroke = document.createElementNS(SVGNS, "path");
stroke.setAttribute("class", "stroke");
stroke.setAttribute("d", line);
stroke.setAttribute("stroke", color);
svg.appendChild(stroke);
var last = xy[xy.length - 1];
var dot = document.createElementNS(SVGNS, "circle");
dot.setAttribute("cx", last[0].toFixed(1));
dot.setAttribute("cy", last[1].toFixed(1));
dot.setAttribute("r", "2.4");
dot.setAttribute("fill", color);
svg.appendChild(dot);
}
/* ---------- KPI cards ---------- */
var kpiCards = Array.prototype.slice.call(document.querySelectorAll(".kpi"));
var TEAL = getComputedStyle(document.documentElement).getPropertyValue("--teal").trim() || "#1f8a8a";
var CORAL = getComputedStyle(document.documentElement).getPropertyValue("--coral").trim() || "#e8623f";
function renderKpis(model, periodLabel) {
kpiCards.forEach(function (card) {
var key = card.getAttribute("data-kpi");
var data = model.kpis[key];
if (!data) return;
card.querySelector("[data-value]").textContent = data.display;
var deltaEl = card.querySelector("[data-delta]");
var up = data.delta >= 0;
deltaEl.textContent = signedPct(data.delta);
deltaEl.classList.toggle("up", up);
deltaEl.classList.toggle("down", !up);
var footEl = card.querySelector("[data-foot]");
footEl.textContent = "vs previous period · " + periodLabel;
var spark = card.querySelector("[data-spark]");
renderSpark(spark, data.series, key === "readTime" ? CORAL : TEAL);
});
}
/* ---------- Seasonality chart ---------- */
var seasonChart = document.getElementById("seasonChart");
var seasonCaption = document.getElementById("seasonCaption");
var seasonGeom = null; // stored for hover
function renderSeason(model) {
while (seasonChart.firstChild) seasonChart.removeChild(seasonChart.firstChild);
var W = 720, H = 280;
var left = 40, right = 16, top = 18, bottom = 34;
var plotW = W - left - right;
var plotH = H - top - bottom;
var n = 12;
var maxV = 100;
function X(i) { return left + (i / (n - 1)) * plotW; }
function Y(v) { return top + (1 - v / maxV) * plotH; }
// gridlines + y labels
[0, 25, 50, 75, 100].forEach(function (g) {
var y = Y(g);
var ln = document.createElementNS(SVGNS, "line");
ln.setAttribute("class", "grid-line");
ln.setAttribute("x1", left); ln.setAttribute("x2", W - right);
ln.setAttribute("y1", y.toFixed(1)); ln.setAttribute("y2", y.toFixed(1));
seasonChart.appendChild(ln);
var lbl = document.createElementNS(SVGNS, "text");
lbl.setAttribute("class", "axis-label");
lbl.setAttribute("x", left - 8); lbl.setAttribute("y", (y + 4).toFixed(1));
lbl.setAttribute("text-anchor", "end");
lbl.textContent = g;
seasonChart.appendChild(lbl);
});
// x labels
for (var i = 0; i < n; i++) {
var t = document.createElementNS(SVGNS, "text");
t.setAttribute("class", "axis-label");
t.setAttribute("x", X(i).toFixed(1));
t.setAttribute("y", H - 12);
t.setAttribute("text-anchor", "middle");
t.textContent = MONTHS[i];
seasonChart.appendChild(t);
}
function path(series) {
return series.map(function (v, i) {
return (i ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1);
}).join(" ");
}
// interest area
var areaD = path(model.interest) + " L" + X(n - 1).toFixed(1) + " " + Y(0).toFixed(1) +
" L" + X(0).toFixed(1) + " " + Y(0).toFixed(1) + " Z";
var area = document.createElementNS(SVGNS, "path");
area.setAttribute("class", "interest-fill");
area.setAttribute("d", areaD);
seasonChart.appendChild(area);
var interestLine = document.createElementNS(SVGNS, "path");
interestLine.setAttribute("class", "interest-line");
interestLine.setAttribute("d", path(model.interest));
seasonChart.appendChild(interestLine);
var bookingLine = document.createElementNS(SVGNS, "path");
bookingLine.setAttribute("class", "booking-line");
bookingLine.setAttribute("d", path(model.bookings));
seasonChart.appendChild(bookingLine);
// hover line + dots
var hoverLine = document.createElementNS(SVGNS, "line");
hoverLine.setAttribute("class", "hover-line");
hoverLine.setAttribute("y1", top); hoverLine.setAttribute("y2", top + plotH);
seasonChart.appendChild(hoverLine);
var dotI = document.createElementNS(SVGNS, "circle");
dotI.setAttribute("class", "dot");
dotI.setAttribute("r", "0"); dotI.setAttribute("fill", TEAL);
seasonChart.appendChild(dotI);
var dotB = document.createElementNS(SVGNS, "circle");
dotB.setAttribute("class", "dot");
dotB.setAttribute("r", "0"); dotB.setAttribute("fill", CORAL);
seasonChart.appendChild(dotB);
// invisible hover columns
var hotGroup = document.createElementNS(SVGNS, "g");
for (var c = 0; c < n; c++) {
var rect = document.createElementNS(SVGNS, "rect");
rect.setAttribute("class", "hot-col");
var x0 = c === 0 ? left : (X(c) + X(c - 1)) / 2;
var x1 = c === n - 1 ? W - right : (X(c) + X(c + 1)) / 2;
rect.setAttribute("x", x0.toFixed(1));
rect.setAttribute("y", top);
rect.setAttribute("width", (x1 - x0).toFixed(1));
rect.setAttribute("height", plotH);
rect.setAttribute("data-index", c);
rect.setAttribute("tabindex", "0");
rect.setAttribute("role", "img");
rect.setAttribute("aria-label",
MONTHS[c] + ": interest " + model.interest[c] + ", bookings " + model.bookings[c]);
hotGroup.appendChild(rect);
}
seasonChart.appendChild(hotGroup);
seasonGeom = { X: X, Y: Y, hoverLine: hoverLine, dotI: dotI, dotB: dotB, model: model };
function showAt(idx) {
if (idx == null || idx < 0) {
hoverLine.style.opacity = "0";
dotI.setAttribute("r", "0"); dotB.setAttribute("r", "0");
seasonCaption.innerHTML = "Hover a month to inspect interest and bookings.";
return;
}
var x = X(idx);
hoverLine.setAttribute("x1", x.toFixed(1));
hoverLine.setAttribute("x2", x.toFixed(1));
hoverLine.style.opacity = "0.5";
dotI.setAttribute("cx", x.toFixed(1)); dotI.setAttribute("cy", Y(model.interest[idx]).toFixed(1));
dotI.setAttribute("r", "4.5");
dotB.setAttribute("cx", x.toFixed(1)); dotB.setAttribute("cy", Y(model.bookings[idx]).toFixed(1));
dotB.setAttribute("r", "4.5");
seasonCaption.innerHTML =
"<strong>" + MONTHS[idx] + "</strong> — interest <strong>" + model.interest[idx] +
"</strong> · bookings <strong>" + model.bookings[idx] + "</strong>";
}
var hotRects = hotGroup.querySelectorAll(".hot-col");
Array.prototype.forEach.call(hotRects, function (rect) {
var idx = parseInt(rect.getAttribute("data-index"), 10);
rect.addEventListener("mouseenter", function () { showAt(idx); });
rect.addEventListener("focus", function () { showAt(idx); });
});
hotGroup.addEventListener("mouseleave", function () { showAt(null); });
// animate lines in
[interestLine, bookingLine].forEach(function (p) {
try {
var len = p.getTotalLength();
p.style.strokeDasharray = len;
p.style.strokeDashoffset = len;
// force reflow then transition
p.getBoundingClientRect();
p.style.transition = "stroke-dashoffset 0.9s ease";
requestAnimationFrame(function () { p.style.strokeDashoffset = "0"; });
} catch (e) { /* getTotalLength unsupported — leave static */ }
});
}
/* ---------- Region bars ---------- */
var regionBars = document.getElementById("regionBars");
function renderRegions(model) {
regionBars.innerHTML = "";
var max = Math.max.apply(null, model.regions.map(function (r) { return r.pct; }));
model.regions.forEach(function (reg) {
var li = document.createElement("li");
li.className = "bar-row";
li.innerHTML =
'<div class="bar-top">' +
'<span class="bar-name"><span class="bar-flag" aria-hidden="true">' + reg.flag + '</span>' +
reg.name + '</span>' +
'<span class="bar-val">' + reg.pct.toFixed(1) + '% · ' + compact(reg.views) + '</span>' +
'</div>' +
'<div class="bar-track"><div class="bar-fill"></div></div>';
regionBars.appendChild(li);
var fill = li.querySelector(".bar-fill");
// animate from 0 to width on next frame
requestAnimationFrame(function () {
fill.style.width = ((reg.pct / max) * 100).toFixed(1) + "%";
});
});
}
/* ---------- Top guides table ---------- */
var guidesBody = document.getElementById("guidesBody");
var guidesTable = document.getElementById("guidesTable");
var currentGuides = [];
var sortKey = "views";
var sortDir = "descending";
function trendCell(t) {
var cls = t > 0.5 ? "up" : t < -0.5 ? "down" : "flat";
var arrow = t > 0.5 ? "▲" : t < -0.5 ? "▼" : "■";
return '<span class="trend ' + cls + '">' + arrow + " " + signedPct(t) + "</span>";
}
function renderGuides() {
var sorted = currentGuides.slice().sort(function (a, b) {
var av, bv;
if (sortKey === "title") { av = a.title.toLowerCase(); bv = b.title.toLowerCase(); }
else { av = a[sortKey]; bv = b[sortKey]; }
if (av < bv) return sortDir === "ascending" ? -1 : 1;
if (av > bv) return sortDir === "ascending" ? 1 : -1;
return 0;
});
guidesBody.innerHTML = "";
sorted.forEach(function (g) {
var tr = document.createElement("tr");
tr.innerHTML =
'<td><div class="guide-cell">' +
'<span class="guide-thumb" aria-hidden="true" style="background:linear-gradient(135deg,' +
g.grad[0] + ',' + g.grad[1] + ')"></span>' +
'<span><span class="guide-title">' + g.title + '</span><br>' +
'<span class="guide-tag">' + g.tag + '</span></span>' +
'</div></td>' +
'<td class="num">' + nf.format(g.views) + '</td>' +
'<td class="num">' + nf.format(g.saves) + '</td>' +
'<td class="num">' + mmss(g.read) + '</td>' +
'<td class="num">' + trendCell(g.trend) + '</td>';
guidesBody.appendChild(tr);
});
// reflect aria-sort on headers
Array.prototype.forEach.call(guidesTable.querySelectorAll(".sort"), function (btn) {
if (btn.getAttribute("data-sort") === sortKey) {
btn.setAttribute("aria-sort", sortDir);
} else {
btn.removeAttribute("aria-sort");
}
});
}
Array.prototype.forEach.call(guidesTable.querySelectorAll(".sort"), function (btn) {
btn.addEventListener("click", function () {
var key = btn.getAttribute("data-sort");
if (key === sortKey) {
sortDir = sortDir === "ascending" ? "descending" : "ascending";
} else {
sortKey = key;
sortDir = key === "title" ? "ascending" : "descending";
}
renderGuides();
});
});
/* ---------- Period switch ---------- */
var segBtns = Array.prototype.slice.call(document.querySelectorAll(".seg-btn"));
function applyPeriod(period) {
var model = buildModel(period);
var label = PERIODS[period].label;
currentGuides = model.guides;
renderKpis(model, label);
renderSeason(model);
renderRegions(model);
renderGuides();
}
segBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
var period = btn.getAttribute("data-period");
segBtns.forEach(function (b) {
var active = b === btn;
b.classList.toggle("is-active", active);
if (active) b.setAttribute("aria-pressed", "true");
else b.removeAttribute("aria-pressed");
});
applyPeriod(period);
toast("Showing " + PERIODS[period].label);
});
});
/* ---------- Export (mock) ---------- */
var exportBtn = document.getElementById("exportBtn");
if (exportBtn) {
exportBtn.addEventListener("click", function () {
var active = document.querySelector(".seg-btn.is-active");
var period = active ? active.getAttribute("data-period") : "28";
toast("Exported " + PERIODS[period].label + " report (CSV) — demo only");
});
}
/* ---------- Live count ticker ---------- */
var liveCount = document.getElementById("liveCount");
if (liveCount) {
setInterval(function () {
var cur = parseInt(liveCount.textContent, 10) || 300;
var next = Math.max(180, Math.min(640, cur + Math.round((Math.random() - 0.5) * 26)));
liveCount.textContent = next;
}, 3200);
}
/* ---------- Rail link active state on click ---------- */
Array.prototype.forEach.call(document.querySelectorAll(".rail-link"), function (link) {
link.addEventListener("click", function () {
document.querySelectorAll(".rail-link").forEach(function (l) {
l.classList.remove("is-active");
l.removeAttribute("aria-current");
});
link.classList.add("is-active");
link.setAttribute("aria-current", "page");
});
});
/* ---------- Init ---------- */
applyPeriod("28");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Waymark — Travel Site 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=Fraunces:opsz,[email protected],500;9..144,600;9..144,700&family=Work+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#main">Skip to dashboard</a>
<div class="shell">
<!-- Sidebar -->
<aside class="rail" aria-label="Primary navigation">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" role="img" aria-label="Waymark compass">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="1.6" />
<path d="M12 5 14 12 12 19 10 12Z" fill="currentColor" />
<circle cx="12" cy="12" r="1.6" fill="var(--bg)" />
</svg>
</span>
<span class="brand-name">Waymark</span>
</div>
<nav class="rail-nav">
<a href="#main" class="rail-link is-active" aria-current="page">
<span class="rail-ico" aria-hidden="true">◆</span> Overview
</a>
<a href="#guides" class="rail-link"><span class="rail-ico" aria-hidden="true">❦</span> Guides</a>
<a href="#regions" class="rail-link"><span class="rail-ico" aria-hidden="true">✦</span> Destinations</a>
<a href="#season" class="rail-link"><span class="rail-ico" aria-hidden="true">≈</span> Seasonality</a>
<a href="#main" class="rail-link"><span class="rail-ico" aria-hidden="true">⚲</span> Audience</a>
</nav>
<div class="rail-foot">
<div class="rail-card">
<p class="rail-card-k">Live now</p>
<p class="rail-card-v"><span id="liveDot" class="pulse" aria-hidden="true"></span> <strong id="liveCount">312</strong> on site</p>
</div>
</div>
</aside>
<!-- Main -->
<main class="main" id="main">
<header class="topbar">
<div class="topbar-lead">
<p class="eyebrow">Editorial Analytics</p>
<h1>Travel Site Dashboard</h1>
<p class="sub">How readers wander through your guides, maps and destinations.</p>
</div>
<div class="topbar-tools">
<div class="seg" role="group" aria-label="Reporting period">
<button class="seg-btn" data-period="7" type="button">7d</button>
<button class="seg-btn is-active" data-period="28" type="button" aria-pressed="true">28d</button>
<button class="seg-btn" data-period="90" type="button">90d</button>
<button class="seg-btn" data-period="365" type="button">1y</button>
</div>
<button class="ghost-btn" id="exportBtn" type="button">⇩ Export</button>
</div>
</header>
<!-- KPI cards -->
<section class="kpis" aria-label="Key performance indicators">
<article class="kpi" data-kpi="visitors">
<header class="kpi-head">
<span class="kpi-label">Visitors</span>
<span class="kpi-delta" data-delta>+0%</span>
</header>
<p class="kpi-value" data-value>—</p>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
<p class="kpi-foot" data-foot>vs previous period</p>
</article>
<article class="kpi" data-kpi="destinations">
<header class="kpi-head">
<span class="kpi-label">Top destination views</span>
<span class="kpi-delta" data-delta>+0%</span>
</header>
<p class="kpi-value" data-value>—</p>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
<p class="kpi-foot" data-foot>vs previous period</p>
</article>
<article class="kpi" data-kpi="guideViews">
<header class="kpi-head">
<span class="kpi-label">Guide views</span>
<span class="kpi-delta" data-delta>+0%</span>
</header>
<p class="kpi-value" data-value>—</p>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
<p class="kpi-foot" data-foot>vs previous period</p>
</article>
<article class="kpi" data-kpi="readTime">
<header class="kpi-head">
<span class="kpi-label">Avg. read time</span>
<span class="kpi-delta" data-delta>+0%</span>
</header>
<p class="kpi-value" data-value>—</p>
<svg class="spark" viewBox="0 0 120 32" preserveAspectRatio="none" aria-hidden="true" data-spark></svg>
<p class="kpi-foot" data-foot>vs previous period</p>
</article>
</section>
<div class="grid">
<!-- Seasonality chart -->
<section class="panel panel-wide" id="season" aria-labelledby="seasonTitle">
<header class="panel-head">
<div>
<h2 id="seasonTitle">Seasonality of interest</h2>
<p class="panel-sub">Bookings & guide interest by month, indexed to peak.</p>
</div>
<div class="chip-legend" aria-hidden="true">
<span class="chip chip-teal">Interest</span>
<span class="chip chip-coral">Bookings</span>
</div>
</header>
<figure class="chart-wrap">
<svg id="seasonChart" class="season-chart" viewBox="0 0 720 280" role="img"
aria-label="Line and area chart of monthly interest and bookings"></svg>
<figcaption id="seasonCaption" class="chart-caption" role="status" aria-live="polite">
Hover a month to inspect interest and bookings.
</figcaption>
</figure>
</section>
<!-- Regions bar -->
<section class="panel" id="regions" aria-labelledby="regionTitle">
<header class="panel-head">
<div>
<h2 id="regionTitle">Destinations by region</h2>
<p class="panel-sub">Share of guide views this period.</p>
</div>
</header>
<ul class="bars" id="regionBars" aria-label="Guide views by region"></ul>
</section>
<!-- Top guides table -->
<section class="panel panel-wide" id="guides" aria-labelledby="guidesTitle">
<header class="panel-head">
<div>
<h2 id="guidesTitle">Top guides</h2>
<p class="panel-sub">Sortable — click a column header.</p>
</div>
<span class="panel-tag">Updated just now</span>
</header>
<div class="table-scroll">
<table class="guides" id="guidesTable">
<thead>
<tr>
<th scope="col"><button class="sort" data-sort="title" type="button">Guide <span class="sort-ind" aria-hidden="true"></span></button></th>
<th scope="col" class="num"><button class="sort" data-sort="views" type="button">Views <span class="sort-ind" aria-hidden="true"></span></button></th>
<th scope="col" class="num"><button class="sort" data-sort="saves" type="button">Saves <span class="sort-ind" aria-hidden="true"></span></button></th>
<th scope="col" class="num"><button class="sort" data-sort="read" type="button">Read <span class="sort-ind" aria-hidden="true"></span></button></th>
<th scope="col" class="num"><button class="sort" data-sort="trend" type="button">Trend <span class="sort-ind" aria-hidden="true"></span></button></th>
</tr>
</thead>
<tbody id="guidesBody"></tbody>
</table>
</div>
</section>
</div>
<footer class="page-foot">
<p>Waymark Travel Co. · Illustrative analytics with fictional data.</p>
</footer>
</main>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Travel Site Dashboard
A warm, editorial admin view for Waymark, a fictional travel-guide publication. A sticky compass-branded rail leads into a topbar with a reporting-period switch (7d / 28d / 90d / 1y) and an export button. Four KPI cards summarise visitors, top-destination views, guide views and average read time, each with an up/down delta pill and a tiny inline-SVG sparkline of the last twelve buckets. Below, a wide panel charts the seasonality of interest — a teal area-and-line of reader interest layered over a coral booking line, indexed to peak, month by month.
Everything is driven by the period switch. Choosing a new range deterministically rebuilds the model: KPI
totals and deltas recompute, sparklines and the seasonality curves redraw, and the destinations-by-region bars
re-animate to their new shares. Hovering or keyboard-focusing a month on the chart drops a guide line, marks
both series and updates a live caption; the top-guides table sorts on any column header, toggling ascending and
descending with reflected aria-sort and trend arrows that flip teal or coral.
It is all vanilla JS with inline SVG only — no images, no chart libraries, no frameworks. A small live-visitor ticker, a toast on period change and export, visible focus rings, AA-contrast colours and a layout that folds the rail into a top bar and stacks to a single column make it usable down to ~360px.
Illustrative travel UI only — fictional destinations, prices, and maps.