Airline — Load Factor Report
A status-forward airline network revenue dashboard that reports load factor, RASK, yield and passenger revenue across long-haul routes. Headline KPIs carry trend pills and inline sparklines, a horizontal bar chart ranks routes by seat utilisation, and a revenue trend area chart redraws as you toggle between seven-day, thirty-day, quarter-to-date and year-to-date timeframes. A sortable route table, animated cabin-mix donut, route drill-down drawer and one-click CSV export round out the report.
MCP
Code
:root {
--sky: #0a66c2;
--sky-d: #084e95;
--sky-50: #e9f2fb;
--cloud: #f5f8fc;
--sunrise: #ff7a33;
--sunrise-50: #fff0e7;
--ink: #13233b;
--ink-2: #3a4d68;
--muted: #6b7c93;
--bg: #f5f8fc;
--surface: #ffffff;
--line: rgba(19, 35, 59, 0.1);
--line-2: rgba(19, 35, 59, 0.18);
--ok: #1f9d62;
--warn: #e0962a;
--danger: #d4493e;
--boarding: #1f9d62;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow: 0 1px 2px rgba(19, 35, 59, 0.05), 0 8px 24px rgba(19, 35, 59, 0.06);
--shadow-lg: 0 12px 40px rgba(19, 35, 59, 0.16);
}
* { box-sizing: border-box; }
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
background: var(--bg);
color: var(--ink);
}
.tnum { font-variant-numeric: tabular-nums; }
.app { min-height: 100vh; }
/* ---------- Topbar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px clamp(14px, 3vw, 28px);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: saturate(140%) blur(10px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 11px;
background: linear-gradient(135deg, var(--sky), var(--sky-d));
color: #fff;
box-shadow: 0 6px 16px rgba(10, 102, 194, 0.35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.25; }
.brand-text strong { font-size: 15px; font-weight: 800; letter-spacing: -0.01em; }
.brand-text span { font-size: 11.5px; color: var(--muted); font-weight: 500; }
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.seg {
display: inline-flex;
background: var(--sky-50);
border: 1px solid var(--line);
border-radius: 10px;
padding: 3px;
}
.seg-btn {
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
border: 0;
background: transparent;
padding: 6px 12px;
border-radius: 7px;
cursor: pointer;
transition: all 0.16s ease;
}
.seg-btn:hover { color: var(--sky-d); }
.seg-btn.is-active {
background: var(--surface);
color: var(--sky-d);
box-shadow: 0 1px 3px rgba(19, 35, 59, 0.14);
}
.seg-btn:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
.btn {
font: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 10px;
cursor: pointer;
border: 1px solid var(--line-2);
background: var(--surface);
color: var(--ink);
transition: all 0.16s ease;
}
.btn:hover { border-color: var(--sky); color: var(--sky-d); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
.btn-ghost { background: var(--surface); }
/* ---------- Layout ---------- */
.layout {
max-width: 1180px;
margin: 0 auto;
padding: clamp(16px, 3vw, 28px);
display: grid;
gap: 18px;
}
/* ---------- KPIs ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.kpi {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 12px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.kpi::before {
content: "";
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: linear-gradient(var(--sky), var(--sky-d));
}
.kpi-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.kpi-label { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.trend { font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; padding: 2px 7px; border-radius: 999px; }
.trend.up { color: var(--ok); background: rgba(31, 157, 98, 0.1); }
.trend.down { color: var(--danger); background: rgba(212, 73, 62, 0.1); }
.kpi-value {
margin-top: 10px;
font-size: 30px;
font-weight: 800;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
display: flex;
align-items: baseline;
gap: 3px;
color: var(--ink);
}
.kpi-value small { font-size: 15px; font-weight: 600; color: var(--muted); }
.kpi-value .cur { font-size: 18px; color: var(--ink-2); }
.kpi-foot { margin-top: 4px; font-size: 11.5px; color: var(--muted); }
.kpi-spark { margin-top: 10px; height: 32px; }
.kpi-spark svg { width: 100%; height: 32px; display: block; }
/* ---------- Cards ---------- */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--shadow);
}
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.card-head h2 { margin: 0; font-size: 15.5px; font-weight: 700; letter-spacing: -0.01em; }
.sub { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
.legend { font-size: 11px; color: var(--muted); display: inline-flex; align-items: center; gap: 2px; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; vertical-align: -1px; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.low { background: var(--danger); }
.pill { font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; font-variant-numeric: tabular-nums; }
.pill-ok { color: var(--ok); background: rgba(31, 157, 98, 0.12); }
.pill-warn { color: var(--warn); background: rgba(224, 150, 42, 0.14); }
.pill-low { color: var(--danger); background: rgba(212, 73, 62, 0.12); }
.grid-main { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.grid-lower { display: grid; grid-template-columns: 1.5fr 1fr; gap: 18px; }
/* ---------- Route bars ---------- */
.bars { display: grid; gap: 11px; }
.bar-row {
display: grid;
grid-template-columns: 92px 1fr 52px;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 4px 6px;
margin: 0 -6px;
border-radius: var(--r-sm);
transition: background 0.15s ease;
border: 0;
background: transparent;
font: inherit;
text-align: left;
width: calc(100% + 12px);
}
.bar-row:hover { background: var(--sky-50); }
.bar-row:focus-visible { outline: 2px solid var(--sky); outline-offset: 1px; }
.bar-route { font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.bar-track { height: 14px; background: var(--cloud); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 999px; width: 0; transition: width 0.7s cubic-bezier(.2,.7,.2,1); }
.bar-fill.ok { background: linear-gradient(90deg, #2bb877, var(--ok)); }
.bar-fill.warn { background: linear-gradient(90deg, #f0b24f, var(--warn)); }
.bar-fill.low { background: linear-gradient(90deg, #e8675c, var(--danger)); }
.bar-val { font-size: 12.5px; font-weight: 700; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
/* ---------- Line chart ---------- */
.line-wrap { position: relative; }
#lineChart { width: 100%; height: 200px; display: block; }
.grid-line { stroke: var(--line); stroke-width: 1; }
.rev-area { fill: url(#revGrad); }
.rev-line { fill: none; stroke: var(--sky); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.rev-dot { fill: var(--surface); stroke: var(--sky); stroke-width: 2; }
.line-axis { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ---------- Table ---------- */
.table-scroll { overflow-x: auto; }
.rtable { width: 100%; border-collapse: collapse; font-size: 13px; }
.rtable th {
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
padding: 0 10px 9px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.rtable td { padding: 11px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.rtable .num { text-align: right; font-variant-numeric: tabular-nums; }
.rtable tbody tr { cursor: pointer; transition: background 0.14s ease; }
.rtable tbody tr:hover { background: var(--sky-50); }
.rtable tbody tr:last-child td { border-bottom: 0; }
.r-code { font-weight: 700; font-variant-numeric: tabular-nums; }
.r-city { font-size: 11px; color: var(--muted); }
.r-flight { color: var(--ink-2); font-variant-numeric: tabular-nums; }
.r-load { font-weight: 700; }
.spill { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.spill .s-dot { width: 7px; height: 7px; border-radius: 50%; }
.spill.s-ok { color: var(--ok); background: rgba(31,157,98,0.1); }
.spill.s-ok .s-dot { background: var(--ok); }
.spill.s-warn { color: var(--warn); background: rgba(224,150,42,0.12); }
.spill.s-warn .s-dot { background: var(--warn); }
.spill.s-low { color: var(--danger); background: rgba(212,73,62,0.1); }
.spill.s-low .s-dot { background: var(--danger); }
/* ---------- Cabin mix donut ---------- */
.mix-card { display: flex; flex-direction: column; }
.donut-wrap { position: relative; width: 168px; height: 168px; margin: 4px auto 14px; }
#donut { width: 100%; height: 100%; }
.donut-seg { fill: none; stroke-width: 16; transition: stroke-width 0.18s ease; cursor: pointer; }
.donut-seg:hover { stroke-width: 20; }
.donut-center { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; pointer-events: none; }
.donut-center strong { font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.donut-center span { font-size: 11px; color: var(--muted); }
.mix-legend { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
.mix-legend li { display: flex; align-items: center; gap: 9px; font-size: 13px; cursor: pointer; padding: 3px 6px; margin: 0 -6px; border-radius: var(--r-sm); transition: background 0.14s ease; }
.mix-legend li:hover { background: var(--sky-50); }
.mix-legend .m-dot { width: 11px; height: 11px; border-radius: 4px; flex: 0 0 auto; }
.mix-legend .m-name { font-weight: 600; }
.mix-legend .m-val { margin-left: auto; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--ink-2); }
/* ---------- Drawer ---------- */
.drawer { position: fixed; inset: 0; z-index: 60; visibility: hidden; }
.drawer.open { visibility: visible; }
.drawer-scrim { position: absolute; inset: 0; background: rgba(19, 35, 59, 0.34); opacity: 0; transition: opacity 0.25s ease; }
.drawer.open .drawer-scrim { opacity: 1; }
.drawer-panel {
position: absolute;
top: 0; right: 0; bottom: 0;
width: min(420px, 92vw);
background: var(--surface);
box-shadow: var(--shadow-lg);
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(.2,.7,.2,1);
display: flex;
flex-direction: column;
}
.drawer.open .drawer-panel { transform: translateX(0); }
.drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px; border-bottom: 1px solid var(--line); }
.drawer-route { font-size: 12px; font-weight: 700; letter-spacing: 0.06em; color: var(--sky); text-transform: uppercase; }
.drawer-head h3 { margin: 4px 0 0; font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
.icon-btn { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); cursor: pointer; transition: all 0.15s ease; }
.icon-btn:hover { background: var(--sky-50); color: var(--sky-d); }
.icon-btn:focus-visible { outline: 2px solid var(--sky); outline-offset: 2px; }
.drawer-body { padding: 20px; overflow-y: auto; }
.dr-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 18px; }
.dr-stat { background: var(--cloud); border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px; }
.dr-stat .l { font-size: 11px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
.dr-stat .v { font-size: 21px; font-weight: 800; margin-top: 3px; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
.dr-h { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 0 0 10px; }
.dr-cabins { display: grid; gap: 9px; margin-bottom: 18px; }
.dr-cabin { display: grid; grid-template-columns: 80px 1fr 44px; align-items: center; gap: 10px; font-size: 12.5px; }
.dr-cabin .cab-name { font-weight: 600; }
.dr-cabin .cab-track { height: 9px; background: var(--cloud); border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
.dr-cabin .cab-fill { height: 100%; background: linear-gradient(90deg, var(--sky), var(--sky-d)); border-radius: 999px; }
.dr-cabin .cab-val { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--ink-2); }
.dr-flights { display: grid; gap: 8px; }
.dr-flight { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--r-md); font-size: 13px; }
.dr-flight .fno { font-weight: 700; font-variant-numeric: tabular-nums; }
.dr-flight .ftime { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.dr-flight .fload { font-weight: 700; font-variant-numeric: tabular-nums; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 11px 18px;
border-radius: 999px;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 80;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.grid-main, .grid-lower { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.topbar { padding: 12px 14px; }
.brand-text span { display: none; }
.topbar-actions { width: 100%; justify-content: space-between; }
.kpis { grid-template-columns: 1fr 1fr; gap: 12px; }
.kpi-value { font-size: 26px; }
.bar-row { grid-template-columns: 78px 1fr 44px; gap: 9px; }
.btn span { display: none; }
}(function () {
"use strict";
/* ---------- Fictional network data ---------- */
// Each route: code pair, cities, base flight no, per-timeframe load factor,
// revenue ($M), RASK (¢), plus cabin split for drill-down.
var ROUTES = [
{ o: "JFK", d: "LHR", co: "New York", cd: "London", fno: "SA118",
lf: { "7d": 91, "30d": 89, qtd: 88, ytd: 87 }, rev: 8.4, rask: 9.1,
cabins: [["Economy", 58], ["Premium", 19], ["Business", 18], ["First", 5]] },
{ o: "SFO", d: "NRT", co: "San Francisco", cd: "Tokyo", fno: "SA402",
lf: { "7d": 88, "30d": 86, qtd: 85, ytd: 84 }, rev: 7.1, rask: 8.6,
cabins: [["Economy", 61], ["Premium", 16], ["Business", 20], ["First", 3]] },
{ o: "MIA", d: "GRU", co: "Miami", cd: "São Paulo", fno: "SA770",
lf: { "7d": 86, "30d": 84, qtd: 83, ytd: 82 }, rev: 5.8, rask: 8.2,
cabins: [["Economy", 66], ["Premium", 14], ["Business", 20], ["First", 0]] },
{ o: "LAX", d: "SYD", co: "Los Angeles", cd: "Sydney", fno: "SA610",
lf: { "7d": 83, "30d": 85, qtd: 84, ytd: 83 }, rev: 6.9, rask: 8.0,
cabins: [["Economy", 60], ["Premium", 17], ["Business", 19], ["First", 4]] },
{ o: "ORD", d: "CDG", co: "Chicago", cd: "Paris", fno: "SA225",
lf: { "7d": 80, "30d": 78, qtd: 79, ytd: 80 }, rev: 4.6, rask: 7.4,
cabins: [["Economy", 64], ["Premium", 15], ["Business", 19], ["First", 2]] },
{ o: "BOS", d: "DUB", co: "Boston", cd: "Dublin", fno: "SA133",
lf: { "7d": 77, "30d": 79, qtd: 80, ytd: 81 }, rev: 3.4, rask: 7.0,
cabins: [["Economy", 70], ["Premium", 14], ["Business", 16], ["First", 0]] },
{ o: "SEA", d: "ICN", co: "Seattle", cd: "Seoul", fno: "SA518",
lf: { "7d": 72, "30d": 74, qtd: 75, ytd: 76 }, rev: 4.0, rask: 6.8,
cabins: [["Economy", 67], ["Premium", 15], ["Business", 18], ["First", 0]] },
{ o: "DEN", d: "MEX", co: "Denver", cd: "Mexico City", fno: "SA289",
lf: { "7d": 68, "30d": 70, qtd: 71, ytd: 73 }, rev: 2.2, rask: 6.1,
cabins: [["Economy", 78], ["Premium", 12], ["Business", 10], ["First", 0]] },
{ o: "ATL", d: "AMS", co: "Atlanta", cd: "Amsterdam", fno: "SA341",
lf: { "7d": 64, "30d": 67, qtd: 69, ytd: 71 }, rev: 2.6, rask: 5.9,
cabins: [["Economy", 72], ["Premium", 14], ["Business", 14], ["First", 0]] }
];
var CABIN_COLORS = { Economy: "#0a66c2", Premium: "#5aa0e0", Business: "#ff7a33", First: "#13233b" };
var KPI = {
"7d": { lf: 84.6, rask: 7.92, yield: 9.36, rev: 48.2 },
"30d": { lf: 83.1, rask: 7.74, yield: 9.41, rev: 196.4 },
qtd: { lf: 82.5, rask: 7.61, yield: 9.28, rev: 612.7 },
ytd: { lf: 81.8, rask: 7.55, yield: 9.19, rev: 2480.0 }
};
var KPI_PERIOD = { "7d": "last 7 days", "30d": "last 30 days", qtd: "quarter to date", ytd: "year to date" };
// Revenue trend series (normalized index per timeframe)
var TREND = {
"7d": { pts: [6.4, 6.1, 6.8, 7.2, 6.9, 7.5, 7.3], labels: ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], sub: "Daily passenger revenue ($M)", total: "+5.1%", cls: "ok" },
"30d": { pts: [44, 46, 43, 48, 50, 47, 52], labels: ["W1","W1","W2","W2","W3","W3","W4"], sub: "Weekly passenger revenue ($M)", total: "+3.6%", cls: "ok" },
qtd: { pts: [188, 196, 203, 199, 208, 212, 214], labels: ["Apr","Apr","May","May","Jun","Jun","Jun"], sub: "Monthly passenger revenue ($M)", total: "+2.2%", cls: "ok" },
ytd: { pts: [612, 598, 640, 655, 631, 668, 690], labels: ["Q1","Q1","Q2","Q2","Q3","Q3","Q4"], sub: "Quarterly passenger revenue ($M)", total: "-0.8%", cls: "low" }
};
var SPARK = {
lf: [80, 81, 82, 81, 83, 84, 84.6],
rask: [7.5, 7.6, 7.55, 7.7, 7.8, 7.85, 7.92],
yield: [9.5, 9.45, 9.4, 9.42, 9.38, 9.37, 9.36],
rev: [44, 45.5, 44.8, 46.2, 47, 47.6, 48.2]
};
var current = "7d";
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
/* ---------- Toast ---------- */
var toastEl = $("#toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2400);
}
function lfClass(v) { return v >= 85 ? "ok" : v >= 70 ? "warn" : "low"; }
function lfSpill(v) { return v >= 85 ? "s-ok" : v >= 70 ? "s-warn" : "s-low"; }
function lfStatus(v) { return v >= 85 ? "Strong" : v >= 70 ? "Healthy" : "Underloaded"; }
/* ---------- Sparklines ---------- */
function drawSpark(key) {
var svg = $('[data-spark="' + key + '"]');
if (!svg) return;
var d = SPARK[key];
var min = Math.min.apply(null, d), max = Math.max.apply(null, d);
var rng = max - min || 1;
var w = 120, h = 30, pad = 2;
var step = (w - pad * 2) / (d.length - 1);
var pts = d.map(function (v, i) {
var x = pad + i * step;
var y = pad + (1 - (v - min) / rng) * (h - pad * 2);
return [x, y];
});
var line = pts.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = "M" + pts[0][0].toFixed(1) + " " + h + " " +
pts.map(function (p) { return "L" + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ") +
" L" + pts[pts.length - 1][0].toFixed(1) + " " + h + " Z";
var up = d[d.length - 1] >= d[0];
var col = up ? "var(--ok)" : "var(--danger)";
svg.innerHTML =
'<path d="' + area + '" fill="' + (up ? "rgba(31,157,98,0.12)" : "rgba(212,73,62,0.12)") + '"/>' +
'<path d="' + line + '" fill="none" stroke="' + col + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
}
/* ---------- KPIs ---------- */
function renderKPIs() {
var k = KPI[current];
$('[data-kpi="lf"]').textContent = k.lf.toFixed(1);
$('[data-kpi="lf-period"]').textContent = KPI_PERIOD[current];
$('[data-kpi="rask"]').textContent = k.rask.toFixed(2);
$('[data-kpi="yield"]').textContent = k.yield.toFixed(2);
var rev = k.rev >= 1000 ? (k.rev / 1000).toFixed(2) : k.rev.toFixed(1);
$('[data-kpi="rev"]').textContent = rev;
var revUnit = k.rev >= 1000 ? "B" : "M";
$('[data-kpi="rev"]').nextElementSibling && ($('[data-kpi="rev"]').parentNode.querySelector("small:last-child").textContent = revUnit);
["lf", "rask", "yield", "rev"].forEach(drawSpark);
}
/* ---------- Route bars ---------- */
function renderBars() {
var host = $("#routeBars");
host.innerHTML = "";
var sorted = ROUTES.slice().sort(function (a, b) { return b.lf[current] - a.lf[current]; });
sorted.forEach(function (r) {
var v = r.lf[current];
var btn = document.createElement("button");
btn.className = "bar-row";
btn.type = "button";
btn.setAttribute("role", "listitem");
btn.setAttribute("aria-label", r.o + " to " + r.d + ", load factor " + v + " percent");
btn.innerHTML =
'<span class="bar-route">' + r.o + '<span style="color:var(--muted)"> → </span>' + r.d + '</span>' +
'<span class="bar-track"><span class="bar-fill ' + lfClass(v) + '"></span></span>' +
'<span class="bar-val">' + v + '%</span>';
btn.addEventListener("click", function () { openDrawer(r); });
host.appendChild(btn);
// animate fill
requestAnimationFrame(function () {
requestAnimationFrame(function () { $(".bar-fill", btn).style.width = v + "%"; });
});
});
}
/* ---------- Line chart ---------- */
function renderLine() {
var t = TREND[current];
$("#trendSub").textContent = t.sub;
var pill = $("#trendTotal");
pill.textContent = t.total;
pill.className = "pill pill-" + t.cls;
var svg = $("#lineChart");
var W = 480, H = 200, padL = 8, padR = 8, padT = 16, padB = 14;
var d = t.pts;
var min = Math.min.apply(null, d), max = Math.max.apply(null, d);
var rng = (max - min) || 1;
min -= rng * 0.15; max += rng * 0.15; rng = max - min;
var step = (W - padL - padR) / (d.length - 1);
var pts = d.map(function (v, i) {
return [padL + i * step, padT + (1 - (v - min) / rng) * (H - padT - padB)];
});
var grid = "";
for (var g = 0; g <= 3; g++) {
var y = padT + (g / 3) * (H - padT - padB);
grid += '<line class="grid-line" x1="0" y1="' + y.toFixed(1) + '" x2="' + W + '" y2="' + y.toFixed(1) + '"/>';
}
var line = pts.map(function (p, i) { return (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join(" ");
var area = "M" + pts[0][0].toFixed(1) + " " + (H - padB) +
pts.map(function (p) { return " L" + p[0].toFixed(1) + " " + p[1].toFixed(1); }).join("") +
" L" + pts[pts.length - 1][0].toFixed(1) + " " + (H - padB) + " Z";
var dots = pts.map(function (p) { return '<circle class="rev-dot" cx="' + p[0].toFixed(1) + '" cy="' + p[1].toFixed(1) + '" r="3.5"/>'; }).join("");
svg.innerHTML =
'<defs><linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="rgba(10,102,194,0.22)"/><stop offset="1" stop-color="rgba(10,102,194,0)"/>' +
'</linearGradient></defs>' + grid +
'<path class="rev-area" d="' + area + '"/>' +
'<path class="rev-line" d="' + line + '"/>' + dots;
var axis = $("#lineAxis");
axis.innerHTML = t.labels.map(function (l) { return "<span>" + l + "</span>"; }).join("");
}
/* ---------- Table ---------- */
function renderTable() {
var body = $("#routeTable");
body.innerHTML = "";
var sorted = ROUTES.slice().sort(function (a, b) { return b.lf[current] - a.lf[current]; });
sorted.forEach(function (r) {
var v = r.lf[current];
var tr = document.createElement("tr");
tr.tabIndex = 0;
tr.innerHTML =
'<td><span class="r-code">' + r.o + ' → ' + r.d + '</span><div class="r-city">' + r.co + ' – ' + r.cd + '</div></td>' +
'<td class="r-flight">' + r.fno + '</td>' +
'<td class="num r-load" style="color:var(--' + lfClass(v) + (lfClass(v) === "low" ? "" : "") + ')">' + v + '%</td>' +
'<td class="num">$' + r.rev.toFixed(1) + 'M</td>' +
'<td class="num">' + r.rask.toFixed(1) + '¢</td>' +
'<td><span class="spill ' + lfSpill(v) + '"><span class="s-dot"></span>' + lfStatus(v) + '</span></td>';
tr.addEventListener("click", function () { openDrawer(r); });
tr.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openDrawer(r); } });
body.appendChild(tr);
});
// recolor load cells properly
$$("#routeTable .r-load").forEach(function (td) {
var num = parseFloat(td.textContent);
var c = lfClass(num);
td.style.color = c === "ok" ? "var(--ok)" : c === "warn" ? "var(--warn)" : "var(--danger)";
});
}
/* ---------- Cabin mix donut (network aggregate) ---------- */
function networkMix() {
var totals = { Economy: 0, Premium: 0, Business: 0, First: 0 };
ROUTES.forEach(function (r) {
var w = r.rev;
r.cabins.forEach(function (c) { totals[c[0]] += (c[1] / 100) * w; });
});
var sum = totals.Economy + totals.Premium + totals.Business + totals.First;
return [
["Economy", totals.Economy / sum * 100],
["Premium", totals.Premium / sum * 100],
["Business", totals.Business / sum * 100],
["First", totals.First / sum * 100]
];
}
function renderDonut() {
var mix = networkMix();
var svg = $("#donut");
var cx = 60, cy = 60, r = 46, C = 2 * Math.PI * r;
var offset = 0;
var segs = "";
mix.forEach(function (m) {
var frac = m[1] / 100;
var len = frac * C;
segs += '<circle class="donut-seg" data-cabin="' + m[0] + '" cx="' + cx + '" cy="' + cy + '" r="' + r +
'" stroke="' + CABIN_COLORS[m[0]] + '" stroke-dasharray="' + len.toFixed(2) + ' ' + (C - len).toFixed(2) +
'" stroke-dashoffset="' + (-offset).toFixed(2) + '" transform="rotate(-90 ' + cx + ' ' + cy + ')"/>';
offset += len;
});
svg.innerHTML = segs;
var top = mix.slice().sort(function (a, b) { return b[1] - a[1]; })[0];
$("#donutPct").textContent = Math.round(top[1]) + "%";
var legend = $("#mixLegend");
legend.innerHTML = "";
mix.forEach(function (m) {
var li = document.createElement("li");
li.innerHTML = '<span class="m-dot" style="background:' + CABIN_COLORS[m[0]] + '"></span>' +
'<span class="m-name">' + m[0] + '</span><span class="m-val">' + m[1].toFixed(1) + '%</span>';
li.addEventListener("mouseenter", function () { highlightSeg(m[0]); });
li.addEventListener("mouseleave", function () { highlightSeg(null); });
li.addEventListener("click", function () {
$("#donutPct").textContent = Math.round(m[1]) + "%";
$(".donut-center span").textContent = m[0];
toast(m[0] + " carries " + m[1].toFixed(1) + "% of network revenue");
});
legend.appendChild(li);
});
$$(".donut-seg", svg).forEach(function (s) {
s.addEventListener("mouseenter", function () {
var name = s.getAttribute("data-cabin");
var val = mix.filter(function (m) { return m[0] === name; })[0][1];
$("#donutPct").textContent = Math.round(val) + "%";
$(".donut-center span").textContent = name;
});
});
}
function highlightSeg(name) {
$$(".donut-seg").forEach(function (s) {
s.style.opacity = (!name || s.getAttribute("data-cabin") === name) ? "1" : "0.3";
});
}
/* ---------- Drawer ---------- */
var drawer = $("#drawer");
var lastFocus = null;
function openDrawer(r) {
lastFocus = document.activeElement;
var v = r.lf[current];
$("#drRoute").textContent = r.o + " → " + r.d;
$("#drTitle").textContent = r.co + " to " + r.cd;
var loadColor = lfClass(v) === "ok" ? "var(--ok)" : lfClass(v) === "warn" ? "var(--warn)" : "var(--danger)";
var cabins = r.cabins.map(function (c) {
return '<div class="dr-cabin"><span class="cab-name">' + c[0] + '</span>' +
'<span class="cab-track"><span class="cab-fill" style="width:' + c[1] + '%"></span></span>' +
'<span class="cab-val">' + c[1] + '%</span></div>';
}).join("");
var fn = parseInt(r.fno.replace(/\D/g, ""), 10);
var flights = [
{ no: r.fno, time: "06:40 → 18:55", load: Math.min(99, v + 4) },
{ no: "SA" + (fn + 1), time: "11:15 → 23:30", load: v },
{ no: "SA" + (fn + 2), time: "19:50 → 08:05⁺¹", load: Math.max(40, v - 6) }
].map(function (f) {
var fc = lfClass(f.load) === "ok" ? "var(--ok)" : lfClass(f.load) === "warn" ? "var(--warn)" : "var(--danger)";
return '<div class="dr-flight"><div><div class="fno">' + f.no + '</div><div class="ftime">' + f.time + '</div></div>' +
'<div class="fload" style="color:' + fc + '">' + f.load + '%</div></div>';
}).join("");
$("#drBody").innerHTML =
'<div class="dr-stats">' +
'<div class="dr-stat"><div class="l">Load Factor</div><div class="v" style="color:' + loadColor + '">' + v + '%</div></div>' +
'<div class="dr-stat"><div class="l">Revenue</div><div class="v">$' + r.rev.toFixed(1) + 'M</div></div>' +
'<div class="dr-stat"><div class="l">RASK</div><div class="v">' + r.rask.toFixed(1) + '¢</div></div>' +
'<div class="dr-stat"><div class="l">Status</div><div class="v" style="font-size:16px;color:' + loadColor + '">' + lfStatus(v) + '</div></div>' +
'</div>' +
'<p class="dr-h">Cabin revenue split</p><div class="dr-cabins">' + cabins + '</div>' +
'<p class="dr-h">Scheduled rotations</p><div class="dr-flights">' + flights + '</div>';
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
setTimeout(function () { $(".icon-btn", drawer).focus(); }, 60);
}
function closeDrawer() {
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
if (lastFocus && lastFocus.focus) lastFocus.focus();
}
$$("[data-close]", drawer).forEach(function (el) { el.addEventListener("click", closeDrawer); });
document.addEventListener("keydown", function (e) { if (e.key === "Escape" && drawer.classList.contains("open")) closeDrawer(); });
/* ---------- Timeframe toggle ---------- */
$$(".seg-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.dataset.tf === current) return;
$$(".seg-btn").forEach(function (b) { b.classList.remove("is-active"); b.setAttribute("aria-selected", "false"); });
btn.classList.add("is-active");
btn.setAttribute("aria-selected", "true");
current = btn.dataset.tf;
renderAll();
toast("Timeframe: " + btn.textContent + " · charts updated");
});
});
/* ---------- Export ---------- */
$("#exportBtn").addEventListener("click", function () {
var rows = [["Route", "Flight", "LoadFactor%", "Revenue$M", "RASK_cents", "Status"]];
ROUTES.slice().sort(function (a, b) { return b.lf[current] - a.lf[current]; }).forEach(function (r) {
var v = r.lf[current];
rows.push([r.o + "-" + r.d, r.fno, v, r.rev.toFixed(1), r.rask.toFixed(1), lfStatus(v)]);
});
var csv = rows.map(function (r) { return r.join(","); }).join("\n");
try {
var blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "load-factor-report-" + current + ".csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
toast("Exported " + (rows.length - 1) + " routes (" + current.toUpperCase() + ")");
} catch (err) {
toast("Export blocked in this preview");
}
});
/* ---------- Render ---------- */
function renderAll() {
renderKPIs();
renderBars();
renderLine();
renderTable();
}
renderAll();
renderDonut();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Skyward Atlantic — Load Factor & Revenue Report</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">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16l20-5-9-3-2-6-2 7-5 1 3 3-1 4 4-2z"/></svg>
</span>
<div class="brand-text">
<strong>Skyward Atlantic</strong>
<span>Network Revenue · Load Factor Report</span>
</div>
</div>
<div class="topbar-actions">
<div class="seg" role="tablist" aria-label="Timeframe">
<button class="seg-btn is-active" role="tab" aria-selected="true" data-tf="7d">7D</button>
<button class="seg-btn" role="tab" aria-selected="false" data-tf="30d">30D</button>
<button class="seg-btn" role="tab" aria-selected="false" data-tf="qtd">QTD</button>
<button class="seg-btn" role="tab" aria-selected="false" data-tf="ytd">YTD</button>
</div>
<button class="btn btn-ghost" id="exportBtn" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export CSV
</button>
</div>
</header>
<main class="layout">
<section class="kpis" aria-label="Key performance indicators">
<article class="kpi">
<div class="kpi-top"><span class="kpi-label">Load Factor</span><span class="trend up" data-trend="lf">▲ 1.8 pts</span></div>
<div class="kpi-value"><span data-kpi="lf">84.6</span><small>%</small></div>
<div class="kpi-foot">Seats sold vs available · <span data-kpi="lf-period">last 7 days</span></div>
<div class="kpi-spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" data-spark="lf"></svg></div>
</article>
<article class="kpi">
<div class="kpi-top"><span class="kpi-label">RASK</span><span class="trend up" data-trend="rask">▲ 2.4%</span></div>
<div class="kpi-value"><span data-kpi="rask">7.92</span><small>¢</small></div>
<div class="kpi-foot">Revenue per available seat-km</div>
<div class="kpi-spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" data-spark="rask"></svg></div>
</article>
<article class="kpi">
<div class="kpi-top"><span class="kpi-label">Yield</span><span class="trend down" data-trend="yield">▼ 0.6%</span></div>
<div class="kpi-value"><span data-kpi="yield">9.36</span><small>¢/RPK</small></div>
<div class="kpi-foot">Passenger revenue per km flown</div>
<div class="kpi-spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" data-spark="yield"></svg></div>
</article>
<article class="kpi">
<div class="kpi-top"><span class="kpi-label">Passenger Revenue</span><span class="trend up" data-trend="rev">▲ 5.1%</span></div>
<div class="kpi-value"><small class="cur">$</small><span data-kpi="rev">48.2</span><small>M</small></div>
<div class="kpi-foot">Net of taxes & ancillary</div>
<div class="kpi-spark"><svg viewBox="0 0 120 32" preserveAspectRatio="none" data-spark="rev"></svg></div>
</article>
</section>
<section class="grid-main">
<article class="card chart-card">
<div class="card-head">
<div>
<h2>Load Factor by Route</h2>
<p class="sub">Tap a bar to drill into route detail</p>
</div>
<span class="legend"><i class="dot ok"></i> ≥85% <i class="dot warn"></i> 70–85% <i class="dot low"></i> <70%</span>
</div>
<div class="bars" id="routeBars" role="list"></div>
</article>
<article class="card chart-card">
<div class="card-head">
<div>
<h2>Revenue Trend</h2>
<p class="sub" id="trendSub">Daily passenger revenue ($M)</p>
</div>
<span class="pill pill-ok" id="trendTotal">+5.1%</span>
</div>
<div class="line-wrap">
<svg viewBox="0 0 480 200" preserveAspectRatio="none" id="lineChart" role="img" aria-label="Revenue trend line chart"></svg>
<div class="line-axis" id="lineAxis"></div>
</div>
</article>
</section>
<section class="grid-lower">
<article class="card table-card">
<div class="card-head">
<div><h2>Route Performance</h2><p class="sub">Top & bottom by load factor</p></div>
</div>
<div class="table-scroll">
<table class="rtable">
<thead>
<tr>
<th>Route</th><th>Flight</th><th class="num">Load</th><th class="num">Rev</th><th class="num">RASK</th><th>Status</th>
</tr>
</thead>
<tbody id="routeTable"></tbody>
</table>
</div>
</article>
<article class="card mix-card">
<div class="card-head"><div><h2>Cabin Mix</h2><p class="sub">Revenue share by cabin</p></div></div>
<div class="donut-wrap">
<svg viewBox="0 0 120 120" id="donut" role="img" aria-label="Cabin revenue mix donut chart"></svg>
<div class="donut-center"><strong id="donutPct">—</strong><span>of revenue</span></div>
</div>
<ul class="mix-legend" id="mixLegend"></ul>
</article>
</section>
</main>
<!-- Route 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="drTitle">
<header class="drawer-head">
<div>
<span class="drawer-route" id="drRoute">—</span>
<h3 id="drTitle">Route detail</h3>
</div>
<button class="icon-btn" data-close aria-label="Close">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</header>
<div class="drawer-body" id="drBody"></div>
</aside>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</div>
<script src="script.js"></script>
</body>
</html>Load Factor Report
A revenue-management report for the fictional carrier Skyward Atlantic, built in the clean aviation-blue design system. Four headline KPIs — load factor, RASK, yield and passenger revenue — sit across the top with up/down trend pills and inline SVG sparklines. Below them a horizontal bar chart ranks every long-haul route by seat utilisation (colour-coded green ≥85%, amber 70–85%, red below 70%), beside a filled revenue trend chart.
The timeframe segmented control (7D / 30D / QTD / YTD) is the spine of the report: switching it recomputes all KPIs, redraws the bars, repaints the revenue trend line and resorts the route table, with a toast confirming the change. Every route is interactive — click a bar or a table row to slide open a drill-down drawer showing load factor, revenue, RASK, status, the cabin revenue split and scheduled rotations for that city pair.
A cabin-mix donut aggregates revenue share by Economy, Premium, Business and First, with hover highlighting and a live centre readout, and the Export CSV button downloads the current timeframe’s ranked route table. Everything is vanilla JS with no dependencies, tabular figures throughout, keyboard-usable rows and drawer, and a layout that collapses cleanly down to ~360px.
Illustrative UI only — fictional airline, not a real booking or flight system.