Gym — Gym Floor Map
A top-down interactive gym floor map laid out on a CSS grid, with six labeled zones — Free Weights, Machines, Cardio, Functional Rig, Studio A and Locker Rooms — each color-coded by live occupancy and showing its station count. Clicking a zone opens a detail panel listing equipment with free, in-use and service status, while a circular capacity gauge and a one-tap busy-hour simulation bring the whole floor to life on a dark, athletic theme.
MCP
الكود
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--sh-3: 0 18px 48px rgba(0, 0, 0, 0.55);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(198, 255, 58, 0.07), transparent 60%),
radial-gradient(900px 500px at -5% 110%, rgba(255, 106, 43, 0.06), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 22px 22px 48px;
}
.eyebrow {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-2);
}
.brand { display: flex; align-items: center; gap: 14px; }
.logo {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--neon);
color: #0a0c0e;
font-weight: 900;
font-size: 18px;
letter-spacing: -0.02em;
box-shadow: 0 0 0 1px var(--neon-d), 0 8px 20px rgba(198, 255, 58, 0.25);
}
.brand h1 {
margin: 2px 0 0;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
}
.topbar-actions { display: flex; align-items: center; gap: 12px; }
.clock {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 15px;
color: var(--ink-2);
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--surface-2);
}
.btn {
font-family: inherit;
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
cursor: pointer;
border-radius: var(--r-md);
padding: 11px 16px;
border: 1px solid var(--line-2);
transition: transform 0.08s ease, background 0.18s ease, border-color 0.18s ease;
}
.btn:focus-visible {
outline: 3px solid var(--neon);
outline-offset: 2px;
}
.btn:active { transform: translateY(1px); }
.btn-ghost {
background: var(--surface-2);
color: var(--ink);
display: inline-flex;
align-items: center;
gap: 9px;
}
.btn-ghost:hover { border-color: var(--neon); color: var(--ink); }
.btn-ghost[aria-pressed="true"] {
background: var(--orange-soft);
border-color: var(--orange);
color: #ffd7c4;
}
.dot-live {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 0 rgba(255, 106, 43, 0.5);
}
.btn-ghost[aria-pressed="true"] .dot-live {
background: var(--orange);
animation: pulse 1.4s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 106, 43, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(255, 106, 43, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 106, 43, 0); }
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 18px;
margin-top: 18px;
}
.map-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-2);
}
.map-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14px;
}
.map-head h2 { margin: 0; font-size: 17px; font-weight: 800; letter-spacing: -0.01em; }
.hint { font-size: 12px; color: var(--muted); }
/* ---------- Floor grid ---------- */
.floor {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, minmax(72px, 1fr));
grid-template-areas:
"fw fw mc mc"
"fw fw mc mc"
"rg cd cd st"
"lk en cd st";
}
.zone, .zone-static {
position: relative;
border-radius: var(--r-md);
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
padding: 12px 14px;
text-align: left;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
overflow: hidden;
min-height: 0;
}
.zone {
font-family: inherit;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.zone::before {
content: "";
position: absolute;
inset: 0;
opacity: 0.16;
background: var(--occ, var(--muted));
transition: opacity 0.25s ease;
}
.zone > * { position: relative; z-index: 1; }
.zone:hover { transform: translateY(-2px); box-shadow: var(--sh-2); }
.zone:hover::before { opacity: 0.26; }
.zone:focus-visible {
outline: 3px solid var(--neon);
outline-offset: 2px;
}
.zone[aria-pressed="true"] {
border-color: var(--neon);
box-shadow: 0 0 0 1px var(--neon), var(--sh-2);
}
.zone[data-occ="quiet"] { --occ: var(--ok); }
.zone[data-occ="moderate"] { --occ: var(--warn); }
.zone[data-occ="busy"] { --occ: var(--danger); }
.zone-tag {
font-weight: 800;
font-size: 13px;
letter-spacing: -0.01em;
}
.zone-meta {
font-size: 11px;
font-weight: 600;
color: var(--ink-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.zone-pulse {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--occ, var(--muted));
box-shadow: 0 0 10px var(--occ, var(--muted));
z-index: 2;
}
.zone-static.entrance {
background: repeating-linear-gradient(
45deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03) 8px,
transparent 8px,
transparent 16px
);
border-style: dashed;
align-items: center;
justify-content: center;
}
.entrance .zone-tag {
color: var(--muted);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* ---------- Legend ---------- */
.legend {
display: flex;
gap: 18px;
flex-wrap: wrap;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.leg {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.leg i { width: 12px; height: 12px; border-radius: 4px; display: inline-block; }
.leg-quiet i { background: var(--ok); }
.leg-moderate i { background: var(--warn); }
.leg-busy i { background: var(--danger); }
/* ---------- Side ---------- */
.side { display: flex; flex-direction: column; gap: 18px; }
.gauge-card, .panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-2);
}
.gauge {
position: relative;
width: 168px;
height: 168px;
margin: 12px auto 6px;
}
.gauge-svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.g-track {
fill: none;
stroke: var(--elevated);
stroke-width: 11;
}
.g-fill {
fill: none;
stroke: var(--neon);
stroke-width: 11;
stroke-linecap: round;
stroke-dasharray: 326.7;
stroke-dashoffset: 326.7;
transition: stroke-dashoffset 0.7s cubic-bezier(0.4, 0, 0.2, 1), stroke 0.4s ease;
}
.gauge-center {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
}
.gauge-center strong {
display: block;
font-size: 34px;
font-weight: 900;
letter-spacing: -0.03em;
}
.gauge-center span {
font-size: 12px;
font-weight: 600;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.gauge-label {
margin: 4px 0 0;
text-align: center;
font-size: 13px;
font-weight: 700;
color: var(--ink-2);
}
/* ---------- Panel ---------- */
.panel { flex: 1; min-height: 280px; }
.panel-empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
color: var(--muted);
padding: 24px 8px;
}
.ghost {
font-size: 38px;
color: var(--elevated);
}
.panel-empty p { margin: 0; font-size: 13px; max-width: 220px; }
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-head h3 { margin: 3px 0 0; font-size: 19px; font-weight: 800; letter-spacing: -0.02em; }
.badge {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 11px;
border-radius: 999px;
border: 1px solid var(--line-2);
white-space: nowrap;
}
.badge[data-occ="quiet"] { background: rgba(52, 211, 153, 0.14); color: #6ee7b7; border-color: rgba(52, 211, 153, 0.4); }
.badge[data-occ="moderate"] { background: rgba(251, 191, 36, 0.14); color: #fcd34d; border-color: rgba(251, 191, 36, 0.4); }
.badge[data-occ="busy"] { background: rgba(248, 113, 113, 0.14); color: #fca5a5; border-color: rgba(248, 113, 113, 0.4); }
.panel-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 16px 0;
}
.panel-stats > div {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 10px 8px;
text-align: center;
}
.panel-stats strong {
display: block;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.02em;
}
.panel-stats span {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--muted);
}
.equip {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.equip li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 13px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-sm);
animation: rise 0.32s ease both;
}
.equip .e-name { font-weight: 600; font-size: 14px; }
.equip .e-sub { display: block; font-size: 11px; color: var(--muted); font-weight: 500; }
.pill {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 5px 10px;
border-radius: 999px;
white-space: nowrap;
}
.pill.free { background: rgba(52, 211, 153, 0.14); color: #6ee7b7; }
.pill.in-use { background: rgba(248, 113, 113, 0.14); color: #fca5a5; }
.pill.maint { background: var(--surface); color: var(--muted); border: 1px solid var(--line-2); }
@keyframes rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--elevated);
color: var(--ink);
border: 1px solid var(--line-2);
padding: 12px 18px;
border-radius: var(--r-md);
font-size: 13px;
font-weight: 600;
box-shadow: var(--sh-3);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.app { padding: 14px 14px 40px; }
.topbar { padding: 12px 14px; }
.brand h1 { font-size: 18px; }
.logo { width: 40px; height: 40px; font-size: 16px; }
.floor {
grid-template-rows: repeat(7, minmax(64px, 1fr));
grid-template-areas:
"fw fw mc mc"
"fw fw mc mc"
"cd cd cd cd"
"rg rg st st"
"rg rg st st"
"lk lk en en";
}
.topbar-actions { width: 100%; justify-content: space-between; }
.gauge { width: 150px; height: 150px; }
.gauge-center strong { font-size: 30px; }
}(function () {
"use strict";
// ---- Data: zones, equipment, capacity ----
var ZONES = {
"free-weights": {
title: "Free Weights",
eyebrow: "Strength Floor",
cap: 48,
equipment: [
{ name: "Power Rack #1", sub: "Eleiko platform", base: "in-use" },
{ name: "Power Rack #2", sub: "Competition bar", base: "free" },
{ name: "Dumbbell Range", sub: "2.5–60 kg", base: "in-use" },
{ name: "Flat Bench Cluster", sub: "4 stations", base: "free" },
{ name: "Incline Bench", sub: "Adjustable", base: "free" },
{ name: "Deadlift Platform", sub: "Bumper plates", base: "in-use" }
]
},
"machines": {
title: "Machines",
eyebrow: "Pin-Loaded",
cap: 52,
equipment: [
{ name: "Leg Press", sub: "45° sled", base: "in-use" },
{ name: "Lat Pulldown", sub: "Dual handle", base: "free" },
{ name: "Cable Crossover", sub: "Twin stack", base: "in-use" },
{ name: "Chest Press", sub: "Converging", base: "free" },
{ name: "Hack Squat", sub: "Linear bearing", base: "maint" },
{ name: "Seated Row", sub: "Plate-loaded", base: "free" }
]
},
"cardio": {
title: "Cardio",
eyebrow: "Conditioning",
cap: 44,
equipment: [
{ name: "Treadmills", sub: "8 units", base: "in-use" },
{ name: "Assault Bikes", sub: "6 units", base: "free" },
{ name: "Rowing Ergs", sub: "Concept2 x5", base: "in-use" },
{ name: "Stair Climbers", sub: "4 units", base: "free" },
{ name: "Ski Ergs", sub: "3 units", base: "free" }
]
},
"rig": {
title: "Functional / Rig",
eyebrow: "Open Training",
cap: 30,
equipment: [
{ name: "Pull-Up Rig", sub: "12 stations", base: "in-use" },
{ name: "Kettlebell Wall", sub: "8–48 kg", base: "free" },
{ name: "Sled Track", sub: "20 m turf", base: "free" },
{ name: "Battle Ropes", sub: "2 anchors", base: "in-use" },
{ name: "Plyo Boxes", sub: "Stackable", base: "free" }
]
},
"studio-a": {
title: "Studio A",
eyebrow: "Group Class",
cap: 26,
equipment: [
{ name: "Spin Bikes", sub: "Coach Mara — 18:00", base: "in-use" },
{ name: "Yoga Mats", sub: "30 available", base: "free" },
{ name: "Sound System", sub: "Class mode", base: "in-use" },
{ name: "Resistance Bands", sub: "Full set", base: "free" }
]
},
"lockers": {
title: "Locker Rooms",
eyebrow: "Amenities",
cap: 0,
equipment: [
{ name: "Lockers (North)", sub: "120 units", base: "free" },
{ name: "Showers", sub: "10 stalls", base: "in-use" },
{ name: "Sauna", sub: "Open · 80°C", base: "free" },
{ name: "Towel Station", sub: "Restocked 17:30", base: "free" }
]
}
};
var TOTAL_CAP = Object.keys(ZONES).reduce(function (s, k) { return s + ZONES[k].cap; }, 0);
var simBusy = false;
// ---- Helpers ----
var $ = function (sel, root) { return (root || document).querySelector(sel); };
var $$ = function (sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); };
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
var toastEl = $("#toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2200);
}
// Deterministic occupancy fraction per zone, nudged by busy simulation.
var SEED = { "free-weights": 0.62, "machines": 0.4, "cardio": 0.78, "rig": 0.33, "studio-a": 0.88, "lockers": 0.25 };
function occFraction(key) {
var base = SEED[key];
if (simBusy) base = clamp(base + 0.28, 0, 0.99);
return base;
}
function occLevel(frac) {
if (frac >= 0.7) return "busy";
if (frac >= 0.4) return "moderate";
return "quiet";
}
var LEVEL_LABEL = { busy: "Busy", moderate: "Moderate", quiet: "Quiet" };
function zoneInUse(key) {
var z = ZONES[key];
if (z.cap === 0) return 0;
return Math.round(z.equipment.filter(function (e) {
var s = effectiveStatus(e);
return s === "in-use";
}).length / z.equipment.length * z.cap * occFraction(key) / Math.max(occFraction(key), 0.5));
}
// Equipment status flips toward "in-use" under busy simulation.
function effectiveStatus(e) {
if (e.base === "maint") return "maint";
if (simBusy && e.base === "free" && (e.name.charCodeAt(0) % 2 === 0)) return "in-use";
return e.base;
}
// ---- Render zones on the map ----
function paintZones() {
$$(".zone").forEach(function (btn) {
var key = btn.getAttribute("data-zone");
var z = ZONES[key];
var frac = occFraction(key);
var lvl = occLevel(frac);
btn.setAttribute("data-occ", lvl);
btn.setAttribute("aria-label", z.title + " — " + LEVEL_LABEL[lvl] + " occupancy");
var countEl = $(".z-count", btn);
if (countEl) countEl.textContent = z.equipment.length;
});
}
// ---- Gauge ----
var CIRC = 2 * Math.PI * 52; // r=52
var gaugeFill = $("#gaugeFill");
var gaugePct = $("#gaugePct");
var gaugeHead = $("#gaugeHead");
var gaugeStatus = $("#gaugeStatus");
function currentHeadcount() {
// Weighted estimate across zones with capacity.
var total = 0;
Object.keys(ZONES).forEach(function (key) {
var z = ZONES[key];
total += z.cap * occFraction(key);
});
return Math.round(total);
}
function updateGauge() {
var head = currentHeadcount();
var pct = clamp(Math.round(head / TOTAL_CAP * 100), 0, 100);
gaugeFill.style.strokeDashoffset = String(CIRC * (1 - pct / 100));
gaugePct.textContent = pct + "%";
gaugeHead.textContent = head + " / " + TOTAL_CAP;
var lvl = occLevel(pct / 100);
var color = lvl === "busy" ? "var(--danger)" : lvl === "moderate" ? "var(--warn)" : "var(--neon)";
gaugeFill.style.stroke = color;
gaugeStatus.textContent =
lvl === "busy" ? "Busy — peak hours" :
lvl === "moderate" ? "Moderate — steady flow" :
"Quiet — plenty of space";
}
// ---- Panel ----
var activeKey = null;
var panelEmpty = $("#panelEmpty");
var panelBody = $("#panelBody");
function openPanel(key) {
var z = ZONES[key];
activeKey = key;
$$(".zone").forEach(function (b) {
b.setAttribute("aria-pressed", b.getAttribute("data-zone") === key ? "true" : "false");
});
var frac = occFraction(key);
var lvl = occLevel(frac);
$("#panelEyebrow").textContent = z.eyebrow;
$("#panelTitle").textContent = z.title;
var badge = $("#panelBadge");
badge.textContent = LEVEL_LABEL[lvl];
badge.setAttribute("data-occ", lvl);
var inUse = z.equipment.filter(function (e) { return effectiveStatus(e) === "in-use"; }).length;
var maint = z.equipment.filter(function (e) { return effectiveStatus(e) === "maint"; }).length;
var free = z.equipment.length - inUse - maint;
$("#statOcc").textContent = inUse;
$("#statTotal").textContent = z.equipment.length;
$("#statFree").textContent = free;
var list = $("#equipList");
list.innerHTML = "";
z.equipment.forEach(function (e, i) {
var s = effectiveStatus(e);
var li = document.createElement("li");
li.style.animationDelay = (i * 35) + "ms";
var label = s === "free" ? "Free" : s === "in-use" ? "In use" : "Service";
var cls = s === "free" ? "free" : s === "in-use" ? "in-use" : "maint";
li.innerHTML =
'<span><span class="e-name">' + e.name + '</span>' +
'<span class="e-sub">' + e.sub + '</span></span>' +
'<span class="pill ' + cls + '">' + label + '</span>';
list.appendChild(li);
});
panelEmpty.hidden = true;
panelBody.hidden = false;
}
// ---- Wire up zones ----
$$(".zone").forEach(function (btn) {
btn.addEventListener("click", function () {
openPanel(btn.getAttribute("data-zone"));
});
});
// ---- Simulation toggle ----
var simBtn = $("#simBtn");
simBtn.addEventListener("click", function () {
simBusy = !simBusy;
simBtn.setAttribute("aria-pressed", String(simBusy));
simBtn.lastChild.textContent = simBusy ? " Live conditions" : " Simulate busy hour";
paintZones();
updateGauge();
if (activeKey) openPanel(activeKey);
toast(simBusy ? "Busy-hour simulation on — occupancy climbing" : "Back to live conditions");
});
// ---- Clock ----
function tick() {
var d = new Date();
var h = String(d.getHours()).padStart(2, "0");
var m = String(d.getMinutes()).padStart(2, "0");
$("#clock").textContent = h + ":" + m;
}
// ---- Init ----
paintZones();
updateGauge();
tick();
setInterval(tick, 30000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ironworks Athletic — Floor Map</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;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">IW</div>
<div>
<p class="eyebrow">Ironworks Athletic — Downtown</p>
<h1>Live Floor Map</h1>
</div>
</div>
<div class="topbar-actions">
<div class="clock" id="clock" aria-live="polite">--:--</div>
<button class="btn btn-ghost" id="simBtn" type="button" aria-pressed="false">
<span class="dot-live" aria-hidden="true"></span>
Simulate busy hour
</button>
</div>
</header>
<main class="layout">
<section class="map-wrap" aria-label="Gym floor map">
<div class="map-head">
<h2>Tap a zone</h2>
<span class="hint">Color shows live occupancy</span>
</div>
<div class="floor" role="group" aria-label="Interactive zones">
<button class="zone" data-zone="free-weights" style="grid-area:fw" type="button">
<span class="zone-tag">Free Weights</span>
<span class="zone-meta"><span class="z-count">0</span> stations</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<button class="zone" data-zone="machines" style="grid-area:mc" type="button">
<span class="zone-tag">Machines</span>
<span class="zone-meta"><span class="z-count">0</span> stations</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<button class="zone" data-zone="cardio" style="grid-area:cd" type="button">
<span class="zone-tag">Cardio</span>
<span class="zone-meta"><span class="z-count">0</span> stations</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<button class="zone" data-zone="rig" style="grid-area:rg" type="button">
<span class="zone-tag">Functional / Rig</span>
<span class="zone-meta"><span class="z-count">0</span> stations</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<button class="zone" data-zone="studio-a" style="grid-area:st" type="button">
<span class="zone-tag">Studio A</span>
<span class="zone-meta"><span class="z-count">0</span> stations</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<button class="zone" data-zone="lockers" style="grid-area:lk" type="button">
<span class="zone-tag">Locker Rooms</span>
<span class="zone-meta">Members only</span>
<span class="zone-pulse" aria-hidden="true"></span>
</button>
<div class="zone-static entrance" style="grid-area:en" aria-hidden="true">
<span class="zone-tag">Entrance</span>
</div>
</div>
<div class="legend" aria-label="Occupancy legend">
<span class="leg leg-quiet"><i></i> Quiet</span>
<span class="leg leg-moderate"><i></i> Moderate</span>
<span class="leg leg-busy"><i></i> Busy</span>
</div>
</section>
<aside class="side">
<section class="gauge-card" aria-label="Current capacity">
<p class="eyebrow">Current capacity</p>
<div class="gauge">
<svg viewBox="0 0 120 120" class="gauge-svg" aria-hidden="true">
<circle class="g-track" cx="60" cy="60" r="52"></circle>
<circle class="g-fill" id="gaugeFill" cx="60" cy="60" r="52"></circle>
</svg>
<div class="gauge-center">
<strong id="gaugePct">0%</strong>
<span id="gaugeHead">0 / 240</span>
</div>
</div>
<p class="gauge-label" id="gaugeStatus">Quiet — plenty of space</p>
</section>
<section class="panel" id="panel" aria-live="polite">
<div class="panel-empty" id="panelEmpty">
<div class="ghost" aria-hidden="true">▣</div>
<p>Select a zone on the map to see its equipment and live status.</p>
</div>
<div class="panel-body" id="panelBody" hidden>
<div class="panel-head">
<div>
<p class="eyebrow" id="panelEyebrow">Zone</p>
<h3 id="panelTitle">—</h3>
</div>
<span class="badge" id="panelBadge">—</span>
</div>
<div class="panel-stats">
<div><strong id="statOcc">0</strong><span>in use</span></div>
<div><strong id="statTotal">0</strong><span>stations</span></div>
<div><strong id="statFree">0</strong><span>free now</span></div>
</div>
<ul class="equip" id="equipList"></ul>
</div>
</section>
</aside>
</main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Gym Floor Map
A dark, high-energy facility map for Ironworks Athletic, rendered as a top-down CSS grid of clickable zones. Each zone tile carries a label and station count, and its background tint plus a corner status dot encode live occupancy — green for quiet, amber for moderate, red for busy. A dashed entrance block and a legend anchor the layout, and hover lifts each tile with a deepening color wash so the floor reads at a glance.
Selecting any zone slides equipment detail into the side panel: an occupancy badge, three quick stats (in use, total stations, free now) and a status list where individual machines show as Free, In use or Service. A circular SVG capacity gauge tracks the whole building against its 200-plus headcount, shifting its ring color and caption as the floor fills.
A single Simulate busy hour control pushes every zone toward peak load — recoloring tiles, flipping idle machines to in-use, redrawing the gauge and refreshing the open panel — then toggles back to live conditions. Everything is vanilla JS with a small toast helper, keyboard-focusable zones with visible focus rings, and a responsive grid that reflows down to ~360px.
Illustrative UI only — occupancy figures are simulated, not a real facility feed.