Airline — Ops Control Center
A status-forward airline operations control center built with vanilla HTML, CSS and JavaScript. It pairs a live, filterable flight board with operational KPIs, a network route-status teaser, an acknowledgeable disruption alert feed, and a gantt-style aircraft rotation timeline with a live now-marker. Click any flight row or rotation segment to drill into a boarding-pass-styled rotation detail drawer, toggle auto-refresh to simulate evolving operations, and watch toasts narrate departures, delays and boardings in real time.
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-sm: 0 1px 2px rgba(19, 35, 59, 0.06), 0 1px 3px rgba(19, 35, 59, 0.05);
--shadow-md: 0 6px 20px rgba(19, 35, 59, 0.08);
--tab: "tnum" 1, "lnum" 1;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.num, .clock-time, .kpi-value, .board-table td.num, .flightno, .timecol {
font-variant-numeric: tabular-nums;
font-feature-settings: var(--tab);
}
.app {
max-width: 1280px;
margin: 0 auto;
padding: 18px clamp(12px, 3vw, 26px) 40px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding: 14px 18px;
background: linear-gradient(180deg, #ffffff, #fbfdff);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid; place-items: center;
width: 40px; height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--sky), var(--sky-d));
color: #fff;
box-shadow: 0 6px 14px rgba(10, 102, 194, 0.3);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.15; }
.brand-text strong { font-size: 16px; font-weight: 800; letter-spacing: -0.01em; }
.brand-text span { font-size: 12px; color: var(--muted); font-weight: 500; }
.hub-clock {
display: flex; align-items: center; gap: 10px;
margin-left: auto;
padding: 6px 14px;
background: var(--ink);
border-radius: 12px;
color: #cfe0f3;
}
.clock-block { display: flex; flex-direction: column; line-height: 1.1; }
.clock-label { font-size: 9px; letter-spacing: 0.14em; color: #7e96b6; font-weight: 700; }
.clock-time { font-size: 18px; font-weight: 700; color: #eaf3ff; }
.net-dot {
width: 9px; height: 9px; border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(31, 157, 98, 0.55);
animation: pulse 2.2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(31, 157, 98, 0.5); }
70% { box-shadow: 0 0 0 7px rgba(31, 157, 98, 0); }
100% { box-shadow: 0 0 0 0 rgba(31, 157, 98, 0); }
}
.top-actions { display: flex; align-items: center; gap: 12px; }
.refresh-toggle {
display: flex; align-items: center; gap: 7px;
font-size: 13px; font-weight: 500; color: var(--ink-2);
cursor: pointer; user-select: none;
}
.refresh-toggle input { accent-color: var(--sky); width: 15px; height: 15px; cursor: pointer; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: inherit; font-size: 13px; font-weight: 600;
border-radius: var(--r-sm);
padding: 9px 14px;
border: 1px solid transparent;
cursor: pointer;
transition: transform .12s ease, background .15s ease, box-shadow .15s ease;
}
.btn:active { transform: translateY(1px); }
.btn-ghost {
background: var(--sky-50);
color: var(--sky-d);
border-color: rgba(10, 102, 194, 0.18);
}
.btn-ghost:hover { background: #dceafa; }
/* ---------- KPIs ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin: 16px 0;
}
.kpi {
position: relative;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 14px;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.kpi::before {
content: "";
position: absolute; left: 0; top: 0; bottom: 0;
width: 4px; border-radius: 4px;
}
.kpi[data-tone="ok"]::before { background: var(--ok); }
.kpi[data-tone="warn"]::before { background: var(--warn); }
.kpi[data-tone="danger"]::before { background: var(--danger); }
.kpi[data-tone="sky"]::before { background: var(--sky); }
.kpi-label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); }
.kpi-value { display: block; font-size: 30px; font-weight: 800; letter-spacing: -0.02em; margin: 4px 0 2px; }
.kpi-trend { font-size: 12px; color: var(--ink-2); }
.kpi-trend strong { color: var(--ink); }
/* ---------- Grid layout ---------- */
.grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
gap: 16px;
}
.board { grid-column: 1; grid-row: 1; }
.side { grid-column: 2; grid-row: 1 / span 2; display: flex; flex-direction: column; gap: 16px; }
.rotation { grid-column: 1; grid-row: 2; }
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.panel-head {
display: flex; align-items: center; gap: 12px;
padding: 15px 18px;
border-bottom: 1px solid var(--line);
}
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.muted-tag { font-size: 12px; color: var(--muted); margin-left: auto; }
.badge {
margin-left: auto;
min-width: 22px; height: 22px;
display: inline-grid; place-items: center;
padding: 0 7px;
background: var(--sunrise);
color: #fff; font-size: 12px; font-weight: 700;
border-radius: 999px;
}
/* ---------- Filters ---------- */
.filters { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
.chip {
font: inherit; font-size: 12px; font-weight: 600;
padding: 6px 11px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: #fff; color: var(--ink-2);
cursor: pointer;
transition: all .13s ease;
}
.chip:hover { border-color: var(--sky); color: var(--sky-d); }
.chip.is-active { background: var(--ink); color: #fff; border-color: var(--ink); }
/* ---------- Board table ---------- */
.board-scroll { max-height: 430px; overflow: auto; }
.board-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.board-table thead th {
position: sticky; top: 0; z-index: 2;
background: #f3f7fc;
text-align: left;
font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
color: var(--muted);
padding: 10px 14px;
border-bottom: 1px solid var(--line);
}
.board-table th.num, .board-table td.num { text-align: right; }
.board-table tbody td { padding: 11px 14px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.board-table tbody tr { transition: background .12s ease; cursor: default; }
.board-table tbody tr:hover { background: var(--sky-50); }
.flightno { font-weight: 700; color: var(--ink); }
.route { display: flex; align-items: center; gap: 6px; font-weight: 600; }
.route .code { letter-spacing: 0.02em; }
.route .plane { color: var(--sky); display: inline-flex; }
.timecol.late { color: var(--warn); font-weight: 700; }
.gate { font-weight: 600; }
.aircraft { color: var(--ink-2); font-size: 12.5px; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11.5px; font-weight: 700;
padding: 4px 10px; border-radius: 999px;
white-space: nowrap;
}
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.pill.ontime { background: rgba(31,157,98,.12); color: #0f7a48; }
.pill.boarding { background: rgba(31,157,98,.14); color: #0f7a48; }
.pill.boarding::before { animation: pulse 1.6s infinite; }
.pill.delayed { background: rgba(224,150,42,.14); color: #9a640f; }
.pill.departed { background: var(--sky-50); color: var(--sky-d); }
.pill.cancelled { background: rgba(212,73,62,.12); color: #a32b22; }
.empty { text-align: center; color: var(--muted); padding: 30px; font-size: 13px; }
/* ---------- Routes ---------- */
.route-map { padding: 12px 16px 4px; }
.route-map svg { width: 100%; height: 150px; display: block; }
.route-arc { fill: none; stroke: var(--sky); stroke-width: 1.6; opacity: .55; }
.route-arc.warn { stroke: var(--warn); }
.route-arc.danger { stroke: var(--danger); stroke-dasharray: 4 4; }
.route-node { fill: var(--surface); stroke: var(--ink-2); stroke-width: 1.5; }
.route-node-label { font-size: 8px; font-weight: 700; fill: var(--ink-2); font-family: "Inter", sans-serif; }
.route-list { list-style: none; margin: 0; padding: 6px 12px 14px; display: flex; flex-direction: column; gap: 4px; }
.route-row {
display: flex; align-items: center; gap: 10px;
padding: 8px 8px; border-radius: var(--r-sm);
font-size: 13px;
}
.route-row:hover { background: #f6f9fd; }
.route-row .pair { font-weight: 700; min-width: 92px; }
.route-row .pair .code { font-variant-numeric: tabular-nums; }
.route-row .meta { color: var(--muted); font-size: 12px; margin-left: auto; }
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.status-dot.ok { background: var(--ok); }
.status-dot.warn { background: var(--warn); }
.status-dot.danger { background: var(--danger); }
/* ---------- Alerts ---------- */
.alert-list { list-style: none; margin: 0; padding: 8px; display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow: auto; }
.alert {
display: grid; grid-template-columns: auto 1fr auto; gap: 10px;
align-items: start;
padding: 11px 12px;
border: 1px solid var(--line);
border-left-width: 4px;
border-radius: var(--r-sm);
background: #fff;
transition: opacity .2s ease, transform .2s ease, background .15s ease;
}
.alert.sev-high { border-left-color: var(--danger); }
.alert.sev-med { border-left-color: var(--warn); }
.alert.sev-low { border-left-color: var(--sky); }
.alert.ack { opacity: .5; background: #fafcfe; }
.alert .sev-ico { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; color: #fff; font-size: 13px; font-weight: 800; }
.alert.sev-high .sev-ico { background: var(--danger); }
.alert.sev-med .sev-ico { background: var(--warn); }
.alert.sev-low .sev-ico { background: var(--sky); }
.alert .a-body strong { display: block; font-size: 13px; font-weight: 700; }
.alert .a-body span { display: block; font-size: 12px; color: var(--muted); margin-top: 1px; }
.alert .a-time { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.ack-btn {
font: inherit; font-size: 11px; font-weight: 700;
border: 1px solid var(--line-2); background: #fff; color: var(--ink-2);
padding: 5px 9px; border-radius: 6px; cursor: pointer; white-space: nowrap;
transition: all .12s ease;
}
.ack-btn:hover { border-color: var(--ok); color: var(--ok); }
.alert.ack .ack-btn { border-color: var(--ok); color: var(--ok); background: rgba(31,157,98,.08); }
/* ---------- Rotation gantt ---------- */
.rot-hours { display: grid; padding: 10px 18px 0; gap: 0; position: relative; }
.rot-hours, .rot-grid { --lead: 96px; }
.rot-hours {
display: flex;
padding-left: calc(18px + 96px);
}
.rot-hours span {
flex: 1; font-size: 10px; font-weight: 700; color: var(--muted);
font-variant-numeric: tabular-nums; text-align: left;
}
.rot-grid { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 8px; }
.rot-row { display: flex; align-items: center; gap: 0; }
.rot-tail {
width: 96px; flex: none;
font-size: 12px; font-weight: 700; color: var(--ink);
display: flex; flex-direction: column; line-height: 1.1;
}
.rot-tail small { font-size: 10px; font-weight: 600; color: var(--muted); }
.rot-track {
position: relative; flex: 1;
height: 34px;
background: linear-gradient(90deg, rgba(19,35,59,.04) 1px, transparent 1px) 0 0 / calc(100%/14) 100%;
border-radius: 7px;
border: 1px solid var(--line);
}
.rot-seg {
position: absolute; top: 4px; bottom: 4px;
border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 10.5px; font-weight: 700; color: #fff;
overflow: hidden; white-space: nowrap;
cursor: pointer;
transition: transform .12s ease, box-shadow .12s ease;
}
.rot-seg:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); z-index: 3; }
.rot-seg.flight { background: linear-gradient(180deg, var(--sky), var(--sky-d)); }
.rot-seg.ground { background: #d7e2ef; color: var(--ink-2); border: 1px dashed var(--line-2); }
.rot-seg.flight.delayed { background: linear-gradient(180deg, #eaa53e, var(--warn)); }
.rot-seg.flight.cancelled { background: repeating-linear-gradient(45deg, var(--danger), var(--danger) 6px, #b83b31 6px, #b83b31 12px); }
.rot-now {
position: absolute; top: -3px; bottom: -3px; width: 2px;
background: var(--sunrise);
z-index: 4;
}
.rot-now::before { content: ""; position: absolute; top: -4px; left: -3px; width: 8px; height: 8px; border-radius: 50%; background: var(--sunrise); }
.rot-legend { display: flex; gap: 12px; margin-left: auto; font-size: 11px; color: var(--muted); font-weight: 600; }
.rot-legend span { display: inline-flex; align-items: center; gap: 5px; }
.sw { width: 12px; height: 10px; border-radius: 3px; display: inline-block; }
.sw-flight { background: var(--sky); }
.sw-ground { background: #d7e2ef; border: 1px dashed var(--line-2); }
.sw-now { background: var(--sunrise); width: 3px; height: 12px; border-radius: 2px; }
/* ---------- Drawer ---------- */
.drawer-overlay { position: fixed; inset: 0; background: rgba(19,35,59,.4); z-index: 40; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(420px, 92vw);
background: var(--surface);
box-shadow: -12px 0 40px rgba(19,35,59,.18);
z-index: 50;
transform: translateX(100%);
transition: transform .26s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
}
.drawer.open { transform: translateX(0); }
.drawer-head {
display: flex; align-items: flex-start; gap: 12px;
padding: 20px 22px 16px;
border-bottom: 1px solid var(--line);
}
.drawer-eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; color: var(--sky-d); text-transform: uppercase; }
.drawer-head h3 { margin: 2px 0 0; font-size: 19px; font-weight: 800; }
.icon-btn {
margin-left: auto; flex: none;
width: 34px; height: 34px; border-radius: 9px;
border: 1px solid var(--line); background: #fff; color: var(--ink-2);
font-size: 15px; cursor: pointer;
transition: all .12s ease;
}
.icon-btn:hover { background: #f3f6fa; }
.drawer-body { padding: 18px 22px 28px; overflow: auto; }
.dr-perf {
position: relative;
border: 1px solid var(--line); border-radius: var(--r-md);
padding: 16px; margin-bottom: 16px;
background: linear-gradient(180deg, #fff, var(--sky-50));
}
.dr-perf::before, .dr-perf::after {
content: ""; position: absolute; top: 50%; width: 16px; height: 16px;
background: var(--surface); border: 1px solid var(--line); border-radius: 50%;
transform: translateY(-50%);
}
.dr-perf::before { left: -9px; }
.dr-perf::after { right: -9px; }
.dr-leg { display: flex; flex-direction: column; gap: 12px; }
.dr-seg-card {
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center;
padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-sm);
background: #fff;
}
.dr-seg-card .leg-route { font-weight: 800; font-size: 15px; font-variant-numeric: tabular-nums; }
.dr-seg-card .leg-sub { font-size: 12px; color: var(--muted); }
.dr-seg-card .leg-time { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; font-size: 13px; }
.dr-seg-card .leg-time small { display: block; font-size: 11px; color: var(--muted); font-weight: 500; }
.dr-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
.dr-meta div { background: #f6f9fd; border-radius: var(--r-sm); padding: 10px 12px; }
.dr-meta dt { font-size: 11px; color: var(--muted); font-weight: 600; margin: 0; }
.dr-meta dd { margin: 2px 0 0; font-size: 14px; font-weight: 700; }
/* ---------- Toast ---------- */
.toast-wrap { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 8px; align-items: center; }
.toast {
background: var(--ink); color: #eaf3ff;
padding: 11px 16px; border-radius: 10px;
font-size: 13px; font-weight: 600;
box-shadow: 0 10px 30px rgba(19,35,59,.3);
display: flex; align-items: center; gap: 9px;
animation: toastIn .25s ease;
}
.toast .t-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
.toast.warn .t-dot { background: var(--warn); }
.toast.danger .t-dot { background: var(--danger); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.toast.out { opacity: 0; transform: translateY(8px); transition: all .25s ease; }
.flash { animation: flashRow 1s ease; }
@keyframes flashRow { 0% { background: var(--sunrise-50); } 100% { background: transparent; } }
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
.kpis { grid-template-columns: repeat(2, 1fr); }
.grid { grid-template-columns: 1fr; }
.board, .rotation { grid-column: 1; }
.side { grid-column: 1; grid-row: auto; }
.board { grid-row: 1; }
.side { grid-row: 2; }
.rotation { grid-row: 3; }
}
@media (max-width: 520px) {
.app { padding: 12px 10px 30px; }
.topbar { gap: 12px; padding: 12px; }
.hub-clock { margin-left: 0; order: 3; }
.top-actions { width: 100%; justify-content: space-between; order: 4; }
.kpis { grid-template-columns: 1fr 1fr; gap: 10px; }
.kpi-value { font-size: 24px; }
.panel-head { flex-wrap: wrap; }
.filters { width: 100%; }
.board-table { font-size: 12.5px; }
.board-table td.aircraft, .board-table th:nth-child(6) { display: none; }
.rot-tail { width: 70px; }
.rot-hours { padding-left: calc(18px + 70px); }
.rot-hours span:nth-child(odd) { display: none; }
}"use strict";
/* ---------- Fictional ops data ---------- */
const PLANE_SVG =
'<svg class="plane" viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M2 13l8-2 3-7 1.4.4-1.8 6.6 6-1.6.6 1.4-5.6 2.5L11 19l-1.2-.3-.2-4.6L4 16.2 2 13z" fill="currentColor"/></svg>';
const FLIGHTS = [
{ fn: "CR412", from: "JFK", to: "LHR", sched: "08:10", est: "08:10", gate: "B22", ac: "A350-900", tail: "CR-NXA", status: "departed", paxLoad: 96 },
{ fn: "CR118", from: "LHR", to: "DXB", sched: "09:25", est: "09:55", gate: "A14", ac: "B787-9", tail: "CR-PWB", status: "delayed", paxLoad: 88 },
{ fn: "CR205", from: "CDG", to: "JFK", sched: "10:05", est: "10:05", gate: "C07", ac: "A330-300", tail: "CR-MTC", status: "boarding", paxLoad: 92 },
{ fn: "CR731", from: "DXB", to: "SIN", sched: "10:40", est: "10:40", gate: "D31", ac: "B777-300", tail: "CR-LQD", status: "ontime", paxLoad: 74 },
{ fn: "CR509", from: "LHR", to: "FRA", sched: "11:15", est: "—", gate: "—", ac: "A320neo", tail: "CR-VKE", status: "cancelled", paxLoad: 0 },
{ fn: "CR860", from: "SIN", to: "HND", sched: "11:30", est: "11:30", gate: "E12", ac: "A350-900", tail: "CR-NXA", status: "ontime", paxLoad: 81 },
{ fn: "CR322", from: "JFK", to: "CDG", sched: "12:00", est: "12:25", gate: "B09", ac: "B787-9", tail: "CR-PWB", status: "delayed", paxLoad: 90 },
{ fn: "CR144", from: "FRA", to: "DXB", sched: "12:45", est: "12:45", gate: "A03", ac: "A330-300", tail: "CR-MTC", status: "boarding", paxLoad: 68 },
{ fn: "CR677", from: "HND", to: "SIN", sched: "13:10", est: "13:10", gate: "E20", ac: "B777-300", tail: "CR-LQD", status: "ontime", paxLoad: 79 },
{ fn: "CR238", from: "DXB", to: "LHR", sched: "13:35", est: "13:35", gate: "D08", ac: "A320neo", tail: "CR-VKE", status: "ontime", paxLoad: 85 },
{ fn: "CR901", from: "CDG", to: "FRA", sched: "14:05", est: "14:50", gate: "C18", ac: "A350-900", tail: "CR-NXA", status: "delayed", paxLoad: 71 },
{ fn: "CR455", from: "SIN", to: "DXB", sched: "14:30", est: "14:30", gate: "E05", ac: "B787-9", tail: "CR-PWB", status: "ontime", paxLoad: 93 },
];
const ROUTES = [
{ from: "JFK", to: "LHR", status: "ok", note: "Nominal", x1: 30, y1: 60, x2: 150, y2: 40 },
{ from: "LHR", to: "DXB", status: "warn", note: "ATC flow control", x1: 150, y1: 40, x2: 250, y2: 80 },
{ from: "CDG", to: "JFK", status: "ok", note: "Nominal", x1: 140, y1: 70, x2: 30, y2: 60 },
{ from: "DXB", to: "SIN", status: "ok", note: "Nominal", x1: 250, y1: 80, x2: 295, y2: 120 },
{ from: "LHR", to: "FRA", status: "danger", note: "Wx — thunderstorms", x1: 150, y1: 40, x2: 175, y2: 95 },
{ from: "SIN", to: "HND", status: "ok", note: "Nominal", x1: 295, y1: 120, x2: 300, y2: 50 },
];
let ALERTS = [
{ id: "a1", sev: "high", title: "CR509 LHR→FRA cancelled", msg: "Crew duty limit exceeded — rebooking 168 pax", min: 4, ack: false },
{ id: "a2", sev: "med", title: "CR118 delayed +30", msg: "Late inbound aircraft CR-PWB from JFK", min: 12, ack: false },
{ id: "a3", sev: "med", title: "Gate conflict B22 / B23", msg: "CR412 pushback holding for tug", min: 18, ack: false },
{ id: "a4", sev: "low", title: "De-icing queue forming", msg: "Stand 31–34, +6 min taxi added", min: 27, ack: false },
{ id: "a5", sev: "high", title: "CR901 CDG→FRA delayed +45", msg: "Slot restriction, knock-on to CR455", min: 33, ack: false },
];
/* ---------- DOM refs ---------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const statusLabels = {
ontime: "On time", boarding: "Boarding", delayed: "Delayed",
departed: "Departed", cancelled: "Cancelled",
};
let activeFilter = "all";
let refreshTimer = null;
/* ---------- Toast ---------- */
function toast(msg, tone = "ok") {
const wrap = $("#toastWrap");
const el = document.createElement("div");
el.className = "toast " + (tone === "ok" ? "" : tone);
el.innerHTML = '<span class="t-dot"></span><span>' + msg + "</span>";
wrap.appendChild(el);
setTimeout(() => {
el.classList.add("out");
setTimeout(() => el.remove(), 250);
}, 2600);
}
/* ---------- Flight board ---------- */
function renderBoard() {
const body = $("#boardBody");
const rows = FLIGHTS.filter((f) => activeFilter === "all" || f.status === activeFilter);
body.innerHTML = rows
.map((f) => {
const late = f.status === "delayed";
return (
'<tr data-tail="' + f.tail + '">' +
'<td class="flightno">' + f.fn + "</td>" +
'<td><span class="route"><span class="code">' + f.from + "</span>" +
'<span class="plane">' + PLANE_SVG + "</span>" +
'<span class="code">' + f.to + "</span></span></td>" +
'<td class="num timecol">' + f.sched + "</td>" +
'<td class="num timecol ' + (late ? "late" : "") + '">' + f.est + "</td>" +
'<td class="gate">' + f.gate + "</td>" +
'<td class="aircraft">' + f.ac + "</td>" +
'<td><span class="pill ' + f.status + '">' + statusLabels[f.status] + "</span></td>" +
"</tr>"
);
})
.join("");
$("#boardEmpty").hidden = rows.length !== 0;
$$("#boardBody tr").forEach((tr) => {
tr.addEventListener("click", () => openDrawer(tr.dataset.tail));
});
}
/* ---------- KPIs ---------- */
function renderKpis() {
const total = FLIGHTS.length;
const cancelled = FLIGHTS.filter((f) => f.status === "cancelled").length;
const delayed = FLIGHTS.filter((f) => f.status === "delayed").length;
const departed = FLIGHTS.filter((f) => f.status === "departed").length;
const onTimeShare = Math.round(((total - cancelled - delayed) / total) * 100);
const avgDelay = 28 + Math.floor(Math.random() * 10);
const util = 78 + Math.floor(Math.random() * 8);
$("#kpiOntime").textContent = onTimeShare + "%";
$("#kpiOntimeTrend").textContent = (onTimeShare >= 82 ? "▲ on target" : "▼ below 82% target");
$("#kpiDelays").textContent = delayed;
$("#kpiAvgDelay").textContent = avgDelay;
$("#kpiCancel").textContent = cancelled;
$("#kpiTotal").textContent = total;
$("#kpiUtil").textContent = util + "%";
$("#kpiAirborne").textContent = departed + Math.floor(Math.random() * 3);
}
/* ---------- Route map ---------- */
function renderRoutes() {
const arcs = $("#routeArcs");
const nodes = $("#routeNodes");
const list = $("#routeList");
const seen = {};
arcs.innerHTML = ROUTES.map((r) => {
const cls = r.status === "ok" ? "" : r.status;
const mx = (r.x1 + r.x2) / 2;
const my = Math.min(r.y1, r.y2) - 26;
return '<path class="route-arc ' + cls + '" d="M' + r.x1 + " " + r.y1 + " Q" + mx + " " + my + " " + r.x2 + " " + r.y2 + '"/>';
}).join("");
let nodeHtml = "";
ROUTES.forEach((r) => {
[[r.from, r.x1, r.y1], [r.to, r.x2, r.y2]].forEach(([code, x, y]) => {
if (seen[code]) return;
seen[code] = true;
nodeHtml +=
'<circle class="route-node" cx="' + x + '" cy="' + y + '" r="3.5"/>' +
'<text class="route-node-label" x="' + (x + 6) + '" y="' + (y + 3) + '">' + code + "</text>";
});
});
nodes.innerHTML = nodeHtml;
list.innerHTML = ROUTES.map((r) =>
'<li class="route-row">' +
'<span class="status-dot ' + r.status + '"></span>' +
'<span class="pair"><span class="code">' + r.from + "</span> → <span class=\"code\">" + r.to + "</span></span>" +
'<span class="meta">' + r.note + "</span></li>"
).join("");
}
/* ---------- Alerts ---------- */
function renderAlerts() {
const list = $("#alertList");
const open = ALERTS.filter((a) => !a.ack).length;
$("#alertCount").textContent = open;
list.innerHTML = ALERTS.map((a) => {
const ico = a.sev === "high" ? "!" : a.sev === "med" ? "▲" : "i";
return (
'<li class="alert sev-' + a.sev + (a.ack ? " ack" : "") + '" data-id="' + a.id + '">' +
'<span class="sev-ico">' + ico + "</span>" +
'<span class="a-body"><strong>' + a.title + "</strong><span>" + a.msg + "</span></span>" +
'<span class="a-time">' + a.min + "m<br>" +
'<button class="ack-btn" data-ack="' + a.id + '">' + (a.ack ? "Acked" : "Ack") + "</button></span>" +
"</li>"
);
}).join("");
$$("[data-ack]").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const a = ALERTS.find((x) => x.id === btn.dataset.ack);
if (!a || a.ack) return;
a.ack = true;
renderAlerts();
toast("Alert acknowledged — " + a.title, "ok");
});
});
}
/* ---------- Rotation gantt ---------- */
const TAILS = [
{ tail: "CR-NXA", type: "A350-900", segs: [
{ type: "ground", s: 6, e: 7.2, label: "Turn" },
{ type: "flight", s: 7.2, e: 8.1, label: "CR412", st: "departed" },
{ type: "ground", s: 8.1, e: 9.6, label: "" },
{ type: "flight", s: 9.6, e: 11.5, label: "CR860", st: "ontime" },
{ type: "ground", s: 11.5, e: 13.8, label: "" },
{ type: "flight", s: 13.8, e: 16.2, label: "CR901", st: "delayed" },
]},
{ tail: "CR-PWB", type: "B787-9", segs: [
{ type: "flight", s: 6, e: 8.4, label: "CR118", st: "delayed" },
{ type: "ground", s: 8.4, e: 10.2, label: "" },
{ type: "flight", s: 10.2, e: 12.4, label: "CR322", st: "delayed" },
{ type: "ground", s: 12.4, e: 14, label: "Turn" },
{ type: "flight", s: 14, e: 16.6, label: "CR455", st: "ontime" },
]},
{ tail: "CR-MTC", type: "A330-300", segs: [
{ type: "flight", s: 6.5, e: 8.6, label: "CR205", st: "boarding" },
{ type: "ground", s: 8.6, e: 10.4, label: "" },
{ type: "flight", s: 10.4, e: 12.5, label: "CR144", st: "boarding" },
{ type: "ground", s: 12.5, e: 15, label: "" },
]},
{ tail: "CR-LQD", type: "B777-300", segs: [
{ type: "ground", s: 6, e: 8.4, label: "Turn" },
{ type: "flight", s: 8.4, e: 10.4, label: "CR731", st: "ontime" },
{ type: "ground", s: 10.4, e: 12.6, label: "" },
{ type: "flight", s: 12.6, e: 15, label: "CR677", st: "ontime" },
]},
{ tail: "CR-VKE", type: "A320neo", segs: [
{ type: "ground", s: 6, e: 9, label: "AOG check" },
{ type: "flight", s: 9, e: 11, label: "CR509", st: "cancelled" },
{ type: "ground", s: 11, e: 13.2, label: "" },
{ type: "flight", s: 13.2, e: 15, label: "CR238", st: "ontime" },
]},
];
const ROT_START = 6;
const ROT_END = 20; // 14h window
function renderRotation() {
const hours = $("#rotHours");
const grid = $("#rotGrid");
let hh = "";
for (let h = ROT_START; h < ROT_END; h++) {
hh += "<span>" + String(h).padStart(2, "0") + ":00</span>";
}
hours.innerHTML = hh;
const span = ROT_END - ROT_START;
const pct = (v) => ((v - ROT_START) / span) * 100;
const now = nowDecimal();
grid.innerHTML = TAILS.map((t) => {
const segs = t.segs.map((sg) => {
const left = pct(sg.s);
const width = pct(sg.e) - pct(sg.s);
const cls = sg.type === "flight" ? "flight " + (sg.st || "") : "ground";
const title = (sg.label || (sg.type === "ground" ? "Ground" : "")) +
" " + decToTime(sg.s) + "–" + decToTime(sg.e);
return '<div class="rot-seg ' + cls + '" style="left:' + left + "%;width:" + width +
'%" title="' + title + '" data-tail="' + t.tail + '" tabindex="0">' +
(sg.label || "") + "</div>";
}).join("");
const nowMarker = (now >= ROT_START && now <= ROT_END)
? '<div class="rot-now" style="left:' + pct(now) + '%"></div>' : "";
return (
'<div class="rot-row">' +
'<div class="rot-tail">' + t.tail + "<small>" + t.type + "</small></div>" +
'<div class="rot-track">' + segs + nowMarker + "</div>" +
"</div>"
);
}).join("");
$$(".rot-seg").forEach((s) => {
s.addEventListener("click", () => openDrawer(s.dataset.tail));
s.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openDrawer(s.dataset.tail); }
});
});
}
/* ---------- Drawer (rotation drill) ---------- */
function openDrawer(tail) {
const t = TAILS.find((x) => x.tail === tail);
const drawer = $("#drawer");
if (!t) {
// tail not in rotation set — still show a minimal card from FLIGHTS
const f = FLIGHTS.find((x) => x.tail === tail);
if (!f) return;
}
const rec = t || { tail, type: "—", segs: [] };
const flights = rec.segs.filter((s) => s.type === "flight");
const lastDelay = flights.some((s) => s.st === "delayed");
$("#drawerTail").textContent = "Tail " + rec.tail;
$("#drawerTitle").textContent = rec.type + " rotation";
const legs = flights.map((s) => {
const f = FLIGHTS.find((x) => x.fn === s.label) ||
{ from: "—", to: "—", gate: "—", paxLoad: 0 };
const stTxt = statusLabels[s.st] || s.st;
return (
'<div class="dr-seg-card">' +
"<div><div class=\"leg-route\">" + f.from + " → " + f.to + "</div>" +
'<div class="leg-sub">' + s.label + " · " + stTxt + " · " + f.paxLoad + "% load</div></div>" +
"<div></div>" +
'<div class="leg-time">' + decToTime(s.s) + '<small>→ ' + decToTime(s.e) + "</small></div>" +
"</div>"
);
}).join("");
const blockHrs = flights.reduce((a, s) => a + (s.e - s.s), 0).toFixed(1);
const groundHrs = rec.segs.filter((s) => s.type === "ground")
.reduce((a, s) => a + (s.e - s.s), 0).toFixed(1);
$("#drawerBody").innerHTML =
'<div class="dr-perf"><div class="dr-leg">' + (legs || "<p class=\"empty\">No flight legs.</p>") + "</div></div>" +
'<dl class="dr-meta">' +
"<div><dt>Legs today</dt><dd>" + flights.length + "</dd></div>" +
"<div><dt>Block hours</dt><dd>" + blockHrs + " h</dd></div>" +
"<div><dt>Ground time</dt><dd>" + groundHrs + " h</dd></div>" +
"<div><dt>Rotation health</dt><dd style=\"color:" + (lastDelay ? "var(--warn)" : "var(--ok)") + '">' +
(lastDelay ? "At risk" : "Healthy") + "</dd></div>" +
"</dl>";
drawer.classList.add("open");
drawer.setAttribute("aria-hidden", "false");
$("#overlay").hidden = false;
}
function closeDrawer() {
$("#drawer").classList.remove("open");
$("#drawer").setAttribute("aria-hidden", "true");
$("#overlay").hidden = true;
}
/* ---------- Time helpers ---------- */
function nowDecimal() {
// map real clock minutes into the ops window for a live "now" line
const d = new Date();
const dec = d.getHours() + d.getMinutes() / 60;
if (dec >= ROT_START && dec <= ROT_END) return dec;
// demo fallback: place "now" at a lively point in the window
return 11.4 + (d.getSeconds() / 60) * 0.5;
}
function decToTime(dec) {
const h = Math.floor(dec);
const m = Math.round((dec - h) * 60);
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
}
/* ---------- Clock ---------- */
function tickClock() {
const d = new Date();
$("#clock").textContent =
String(d.getHours()).padStart(2, "0") + ":" +
String(d.getMinutes()).padStart(2, "0") + ":" +
String(d.getSeconds()).padStart(2, "0");
}
/* ---------- Auto refresh simulation ---------- */
const SIM_EVENTS = [
() => {
const f = FLIGHTS.find((x) => x.status === "boarding");
if (f) { f.status = "departed"; f.est = f.sched; toast(f.fn + " " + f.from + "→" + f.to + " departed gate " + f.gate, "ok"); return true; }
return false;
},
() => {
const f = FLIGHTS.find((x) => x.status === "ontime");
if (f) {
f.status = "delayed";
const [h, m] = f.sched.split(":").map(Number);
const nm = m + 20; const nh = h + Math.floor(nm / 60);
f.est = String(nh).padStart(2, "0") + ":" + String(nm % 60).padStart(2, "0");
ALERTS.unshift({ id: "a" + Date.now(), sev: "med", title: f.fn + " delayed +20", msg: "New restriction on " + f.from + "→" + f.to, min: 0, ack: false });
toast(f.fn + " now delayed", "warn"); return true;
}
return false;
},
() => {
const f = FLIGHTS.find((x) => x.status === "ontime");
if (f) { f.status = "boarding"; f.gate = f.gate === "—" ? "B17" : f.gate; toast(f.fn + " boarding at gate " + f.gate, "ok"); return true; }
return false;
},
];
let simIdx = 0;
function runRefresh(manual) {
$("#netDot").style.background = "var(--sunrise)";
setTimeout(() => ($("#netDot").style.background = "var(--ok)"), 600);
// bump alert ages
ALERTS.forEach((a) => (a.min += 1));
if (manual || Math.random() > 0.35) {
let acted = false;
for (let i = 0; i < SIM_EVENTS.length && !acted; i++) {
acted = SIM_EVENTS[simIdx % SIM_EVENTS.length]();
simIdx++;
}
if (!acted && manual) toast("Feed refreshed — no new changes", "ok");
}
renderKpis();
renderBoard();
renderAlerts();
renderRotation();
// flash a changed row
const tr = $("#boardBody tr");
if (tr) tr.classList.add("flash");
}
function startAuto() {
stopAuto();
refreshTimer = setInterval(() => runRefresh(false), 7000);
}
function stopAuto() {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
/* ---------- Wire up ---------- */
function init() {
renderKpis();
renderBoard();
renderRoutes();
renderAlerts();
renderRotation();
tickClock();
setInterval(tickClock, 1000);
setInterval(() => renderRotation(), 15000); // keep "now" line moving
$$(".chip").forEach((c) => {
c.addEventListener("click", () => {
$$(".chip").forEach((x) => { x.classList.remove("is-active"); x.setAttribute("aria-selected", "false"); });
c.classList.add("is-active");
c.setAttribute("aria-selected", "true");
activeFilter = c.dataset.filter;
renderBoard();
});
});
$("#refreshBtn").addEventListener("click", () => runRefresh(true));
$("#autoRefresh").addEventListener("change", (e) => {
if (e.target.checked) { startAuto(); toast("Auto-refresh on (7s)", "ok"); }
else { stopAuto(); toast("Auto-refresh paused", "warn"); }
});
$("#drawerClose").addEventListener("click", closeDrawer);
$("#overlay").addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDrawer(); });
startAuto();
}
document.addEventListener("DOMContentLoaded", init);<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cirrus Air — Ops Control Center</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">
<!-- Top bar -->
<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"><path d="M2 16l9-3 3-9 1.5.5-2 7.5 6-2 .8 1.6-6.2 2.7L13 21l-1.4-.3-.2-5.2L4.3 17.5 2 16z" fill="currentColor"/></svg>
</span>
<div class="brand-text">
<strong>Cirrus Air</strong>
<span>Ops Control Center</span>
</div>
</div>
<div class="hub-clock">
<div class="clock-block">
<span class="clock-label">HUB · CIR-OCC</span>
<span class="clock-time" id="clock">--:--:--</span>
</div>
<span class="net-dot" id="netDot" title="Live feed"></span>
</div>
<div class="top-actions">
<label class="refresh-toggle">
<input type="checkbox" id="autoRefresh" checked />
<span>Auto-refresh</span>
</label>
<button class="btn btn-ghost" id="refreshBtn" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none"><path d="M4 12a8 8 0 0 1 13.7-5.6M20 12A8 8 0 0 1 6.3 17.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M17 3v4h-4M7 21v-4h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Refresh now
</button>
</div>
</header>
<!-- KPI strip -->
<section class="kpis" aria-label="Operational KPIs">
<article class="kpi" data-tone="ok">
<span class="kpi-label">On-time performance</span>
<span class="kpi-value" id="kpiOntime">—</span>
<span class="kpi-trend" id="kpiOntimeTrend">vs target 82%</span>
</article>
<article class="kpi" data-tone="warn">
<span class="kpi-label">Active delays</span>
<span class="kpi-value" id="kpiDelays">—</span>
<span class="kpi-trend">avg <strong id="kpiAvgDelay">—</strong> min</span>
</article>
<article class="kpi" data-tone="danger">
<span class="kpi-label">Cancellations today</span>
<span class="kpi-value" id="kpiCancel">—</span>
<span class="kpi-trend">of <span id="kpiTotal">—</span> flights</span>
</article>
<article class="kpi" data-tone="sky">
<span class="kpi-label">Aircraft utilization</span>
<span class="kpi-value" id="kpiUtil">—</span>
<span class="kpi-trend"><span id="kpiAirborne">—</span> airborne now</span>
</article>
</section>
<main class="grid">
<!-- Flight board -->
<section class="panel board" aria-label="Live flight board">
<div class="panel-head">
<h2>Live Flight Board</h2>
<div class="filters" role="tablist" aria-label="Filter by status">
<button class="chip is-active" data-filter="all" role="tab" aria-selected="true">All</button>
<button class="chip" data-filter="ontime" role="tab" aria-selected="false">On time</button>
<button class="chip" data-filter="boarding" role="tab" aria-selected="false">Boarding</button>
<button class="chip" data-filter="delayed" role="tab" aria-selected="false">Delayed</button>
<button class="chip" data-filter="departed" role="tab" aria-selected="false">Departed</button>
<button class="chip" data-filter="cancelled" role="tab" aria-selected="false">Cancelled</button>
</div>
</div>
<div class="board-scroll">
<table class="board-table">
<thead>
<tr>
<th>Flight</th>
<th>Route</th>
<th class="num">Sched</th>
<th class="num">Est</th>
<th>Gate</th>
<th>Aircraft</th>
<th>Status</th>
</tr>
</thead>
<tbody id="boardBody"><!-- rows injected --></tbody>
</table>
<p class="empty" id="boardEmpty" hidden>No flights match this filter.</p>
</div>
</section>
<!-- Right column -->
<div class="side">
<!-- Route status teaser -->
<section class="panel routes" aria-label="Network route status">
<div class="panel-head">
<h2>Network Status</h2>
<span class="muted-tag">6 corridors</span>
</div>
<div class="route-map" aria-hidden="true">
<svg viewBox="0 0 320 150" preserveAspectRatio="none">
<defs>
<pattern id="dots" width="14" height="14" patternUnits="userSpaceOnUse">
<circle cx="2" cy="2" r="1" fill="rgba(19,35,59,0.08)"/>
</pattern>
</defs>
<rect width="320" height="150" fill="url(#dots)"/>
<g id="routeArcs"></g>
<g id="routeNodes"></g>
</svg>
</div>
<ul class="route-list" id="routeList"></ul>
</section>
<!-- Disruption alert feed -->
<section class="panel alerts" aria-label="Disruption alert feed">
<div class="panel-head">
<h2>Disruption Feed</h2>
<span class="badge" id="alertCount">0</span>
</div>
<ul class="alert-list" id="alertList"></ul>
</section>
</div>
<!-- Aircraft rotation gantt -->
<section class="panel rotation" aria-label="Aircraft rotation timeline">
<div class="panel-head">
<h2>Aircraft Rotation</h2>
<div class="rot-legend">
<span><i class="sw sw-flight"></i> Flight</span>
<span><i class="sw sw-ground"></i> Ground</span>
<span><i class="sw sw-now"></i> Now</span>
</div>
</div>
<div class="rot-hours" id="rotHours"></div>
<div class="rot-grid" id="rotGrid"></div>
</section>
</main>
</div>
<!-- Rotation drill drawer -->
<div class="drawer-overlay" id="overlay" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" aria-label="Rotation detail">
<div class="drawer-head">
<div>
<span class="drawer-eyebrow" id="drawerTail">—</span>
<h3 id="drawerTitle">Rotation detail</h3>
</div>
<button class="icon-btn" id="drawerClose" aria-label="Close detail">✕</button>
</div>
<div class="drawer-body" id="drawerBody"></div>
</aside>
<div class="toast-wrap" id="toastWrap" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Ops Control Center
A single-screen operations control center for the fictional Cirrus Air hub. The top strip carries a live hub clock and four KPI cards — on-time performance against an 82% target, active delays with an average delay figure, cancellations as a share of the day’s flights, and aircraft utilization with a live airborne count. Below, a sticky-header flight board lists routes with airport codes, 24-hour scheduled and estimated times, gate, aircraft type, and large status pills (On time, Boarding, Delayed, Departed, Cancelled). Status chips above the board filter rows instantly.
The right rail teases network health with a dotted route map and a corridor list flagged green, amber or red, then streams a disruption alert feed where each item can be acknowledged with one click. The aircraft rotation panel renders a gantt-style timeline per tail with flight and ground segments, a sunrise-colored now-marker, and hover lift. Clicking any flight row or rotation segment opens a boarding-pass-styled drawer detailing the tail’s legs, block hours, ground time and rotation health.
Toggle auto-refresh (or hit Refresh now) to simulate evolving operations: flights push back, on-time legs slip into delay, new alerts surface, and toasts narrate each change. Everything is vanilla JS — no frameworks, no build step — with tabular figures for times and flight numbers, keyboard-usable controls, and a layout that collapses cleanly to a mobile-first passenger-screen width.
Illustrative UI only — fictional airline, not a real booking or flight system.