Pages Hard
Restaurant Floor Plan
Responsive SVG restaurant floor plan — tables in real coordinates, status badges, side inspection, and an edit mode for adding, moving, resizing, reshaping, duplicating, or removing tables.
Open in Lab
MCP
html css vanilla-js svg
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--shadow-1: 0 1px 2px rgba(44, 26, 14, 0.08), 0 2px 6px rgba(44, 26, 14, 0.06);
--shadow-2: 0 8px 24px rgba(44, 26, 14, 0.12);
/* State colours */
--state-free: #c9d6c4;
--state-free-d: #6a8770;
--state-reserved: #e7d099;
--state-reserved-d: #8a7325;
--state-seated: #c1714a;
--state-seated-d: #7a3a1f;
--state-check: #6e5dba;
--state-check-d: #463696;
--state-dirty: #b9a190;
--state-dirty-d: #5e4a36;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
[hidden] {
display: none !important;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
}
/* ── Topbar ── */
.topbar {
padding: clamp(14px, 1.5vw, 18px) clamp(16px, 2.2vw, 24px);
display: flex;
align-items: center;
gap: 14px 22px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
background: var(--bone);
flex-wrap: wrap;
}
.topbar > div:first-child {
min-width: min(100%, 190px);
}
.kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--terracotta);
font-weight: 600;
}
.topbar h1 {
font-family: var(--font-display);
font-size: clamp(1.32rem, 1rem + 0.8vw, 1.55rem);
font-weight: 700;
}
.kpis {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
}
.kpi {
font-size: 0.84rem;
color: var(--ink-2);
white-space: nowrap;
}
.kpi b {
font-family: var(--font-mono);
color: var(--ink);
font-weight: 700;
font-size: 1rem;
margin-right: 2px;
}
.legend {
margin-left: auto;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.lg {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.74rem;
font-weight: 600;
color: var(--ink-2);
padding: 4px 10px;
border-radius: 999px;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.1);
}
.lg::before {
content: "";
width: 10px;
height: 10px;
border-radius: 999px;
}
.lg[data-state="free"]::before {
background: var(--state-free);
}
.lg[data-state="reserved"]::before {
background: var(--state-reserved);
}
.lg[data-state="seated"]::before {
background: var(--state-seated);
}
.lg[data-state="check"]::before {
background: var(--state-check);
}
.lg[data-state="dirty"]::before {
background: var(--state-dirty);
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.tool-btn {
min-height: 32px;
border: 1px solid rgba(44, 26, 14, 0.14);
border-radius: 999px;
background: var(--cream);
color: var(--ink-2);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 800;
padding: 7px 12px;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
}
.tool-btn:hover:not(:disabled) {
background: var(--cream-2);
border-color: rgba(44, 26, 14, 0.22);
color: var(--ink);
transform: translateY(-1px);
}
.tool-btn[aria-pressed="true"] {
background: var(--ink);
border-color: var(--ink);
color: var(--bone);
}
.tool-btn-primary {
background: var(--forest);
border-color: var(--forest);
color: var(--bone);
}
.tool-btn-primary:hover:not(:disabled) {
background: var(--forest-d);
border-color: var(--forest-d);
color: var(--bone);
}
.tool-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
/* ── Layout ── */
.layout {
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) clamp(290px, 21vw, 340px);
gap: 0;
min-height: 0;
overflow: hidden;
}
.plan-wrap {
padding: clamp(14px, 2vw, 28px);
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
overflow: auto;
}
.plan {
display: block;
width: min(100%, 1040px);
max-height: min(76dvh, 720px);
aspect-ratio: 640 / 420;
height: auto;
background: var(--cream-2);
border-radius: var(--r-lg);
box-shadow: var(--shadow-1);
flex: 0 1 auto;
}
/* ── SVG styles ── */
.floor {
fill: var(--cream-2);
}
.edit-grid {
fill: url(#editGrid);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.grid-line {
fill: none;
stroke: rgba(44, 26, 14, 0.16);
stroke-width: 1;
}
.is-editing .edit-grid {
opacity: 0.28;
}
.zone {
fill: var(--bone);
stroke: rgba(44, 26, 14, 0.08);
stroke-width: 1;
}
.zone-window {
fill: #f1e9d9;
}
.zone-center {
fill: #f7f1e3;
}
.zone-patio {
fill: #ece4d4;
}
.zone-bar {
fill: #e6dcc5;
}
.zone-label {
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
fill: var(--warm-gray);
letter-spacing: 0.1em;
text-transform: uppercase;
}
.feature {
fill: var(--warm-gray);
opacity: 0.7;
}
.feature-rail {
fill: none;
stroke: rgba(44, 26, 14, 0.14);
stroke-dasharray: 6 7;
stroke-width: 2;
}
.feature-bar {
fill: var(--forest);
opacity: 0.85;
}
.feature-host {
fill: var(--gold);
}
.feature-entry {
fill: var(--warm-gray);
opacity: 0.55;
}
.feature-label {
font-family: var(--font-mono);
font-size: 9px;
fill: var(--bone);
font-weight: 700;
letter-spacing: 0.08em;
}
.table-group {
cursor: pointer;
transition: transform 0.12s;
transform-box: fill-box;
transform-origin: center;
}
.is-editing .table-group {
cursor: grab;
}
.is-dragging .table-group {
cursor: grabbing;
}
.table-group:hover .table-shape {
filter: brightness(1.08);
}
.table-group.is-selected .table-shape {
stroke-width: 3;
}
.is-editing .table-group.is-selected .table-shape {
filter: drop-shadow(0 0 7px rgba(45, 74, 62, 0.35));
}
.table-shape {
stroke: rgba(44, 26, 14, 0.18);
stroke-width: 1.5;
transition: filter 0.15s, stroke-width 0.15s;
}
.table-group[data-status="free"] .table-shape {
fill: var(--state-free);
stroke: var(--state-free-d);
}
.table-group[data-status="reserved"] .table-shape {
fill: var(--state-reserved);
stroke: var(--state-reserved-d);
}
.table-group[data-status="seated"] .table-shape {
fill: var(--state-seated);
stroke: var(--state-seated-d);
}
.table-group[data-status="check"] .table-shape {
fill: var(--state-check);
stroke: var(--state-check-d);
}
.table-group[data-status="dirty"] .table-shape {
fill: var(--state-dirty);
stroke: var(--state-dirty-d);
}
.table-label {
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
fill: var(--ink);
text-anchor: middle;
pointer-events: none;
}
.table-group[data-status="seated"] .table-label,
.table-group[data-status="check"] .table-label {
fill: var(--bone);
}
.table-sub {
font-family: var(--font-body);
font-size: 8px;
font-weight: 600;
fill: var(--ink-2);
text-anchor: middle;
letter-spacing: 0.04em;
pointer-events: none;
}
.table-group[data-status="seated"] .table-sub,
.table-group[data-status="check"] .table-sub {
fill: rgba(250, 247, 241, 0.85);
}
/* ── Side panel ── */
.side {
background: var(--bone);
border-left: 1px solid rgba(44, 26, 14, 0.1);
padding: 22px 22px 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
.side-kicker {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--terracotta);
font-weight: 600;
margin-bottom: 4px;
}
.side-head h2 {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.side-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 18px;
padding: 14px 16px;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: var(--r-md);
}
.side-meta div {
display: flex;
flex-direction: column;
gap: 2px;
}
.side-meta dt {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--warm-gray);
font-weight: 600;
}
.side-meta dd {
font-size: 0.92rem;
font-weight: 700;
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.side-meta dd[data-tone="seated"] {
color: var(--state-seated-d);
}
.side-meta dd[data-tone="reserved"] {
color: var(--state-reserved-d);
}
.side-meta dd[data-tone="free"] {
color: var(--success);
}
.side-meta dd[data-tone="check"] {
color: var(--state-check-d);
}
.side-meta dd[data-tone="dirty"] {
color: var(--state-dirty-d);
}
.side-hint {
font-size: 0.85rem;
color: var(--warm-gray);
font-style: italic;
background: var(--cream);
padding: 12px 14px;
border-radius: var(--r-md);
border: 1px dashed rgba(44, 26, 14, 0.18);
}
.edit-panel {
display: grid;
gap: 12px;
padding: 14px;
background: rgba(236, 228, 212, 0.58);
border: 1px solid rgba(44, 26, 14, 0.1);
border-radius: var(--r-md);
}
.edit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.edit-label,
.edit-field span {
color: var(--warm-gray);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.edit-field {
display: grid;
gap: 7px;
width: 100%;
}
.edit-field select {
width: 100%;
min-height: 36px;
border: 1px solid rgba(44, 26, 14, 0.14);
border-radius: var(--r-sm);
background: var(--bone);
color: var(--ink);
font-family: inherit;
font-size: 0.86rem;
font-weight: 700;
padding: 0 10px;
}
.size-tools {
display: grid;
grid-template-columns: 36px minmax(64px, 1fr) 36px;
align-items: center;
gap: 7px;
min-width: 150px;
}
.edit-icon {
aspect-ratio: 1;
border-radius: 999px;
font-size: 1rem;
font-weight: 900;
padding: 0;
}
.size-tools output {
color: var(--ink);
font-family: var(--font-mono);
font-size: 0.82rem;
font-weight: 800;
text-align: center;
}
.edit-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.side-foot {
margin-top: auto;
display: flex;
gap: 8px;
}
.side-foot button,
.edit-actions button {
flex: 1;
font-family: inherit;
font-size: 0.84rem;
font-weight: 700;
padding: 11px 14px;
border-radius: 999px;
cursor: pointer;
transition: background 0.15s;
}
.ghost {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
}
.ghost:hover {
background: var(--cream-2);
color: var(--ink);
}
.ghost:disabled,
.danger:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.danger {
background: rgba(179, 67, 42, 0.1);
border: 1px solid rgba(179, 67, 42, 0.24);
color: var(--danger);
}
.danger:hover:not(:disabled) {
background: rgba(179, 67, 42, 0.16);
}
.primary {
background: var(--forest);
color: var(--bone);
border: none;
}
.primary:hover {
background: var(--forest-d);
}
@media (max-width: 880px) {
.layout {
grid-template-columns: 1fr;
overflow: visible;
}
.plan-wrap {
align-items: flex-start;
}
.plan {
max-height: none;
}
.side {
border-left: none;
border-top: 1px solid rgba(44, 26, 14, 0.1);
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
.legend {
margin-left: 0;
}
.top-actions {
width: 100%;
}
.tool-btn {
flex: 1;
}
}
@media (max-width: 560px) {
.topbar {
padding-inline: 12px;
}
.kpis,
.legend {
width: 100%;
}
.kpis {
justify-content: space-between;
}
.legend {
gap: 5px;
}
.lg {
padding-inline: 8px;
}
.plan-wrap {
padding: 10px;
}
.side {
padding: 18px 14px;
}
.side-meta,
.edit-actions {
grid-template-columns: 1fr;
}
.side-foot {
flex-direction: column;
}
}// Floor plan in 640×420 coordinate space
// shape: "round" | "square" | "long" (booth)
const TABLES = [
// Window — Sofía
{
id: "T1",
x: 50,
y: 78,
shape: "round",
seats: 2,
status: "seated",
party: "Reyes ×2",
server: "Sofía",
seated: "19:42",
course: "Mains",
check: "$112.40",
},
{ id: "T2", x: 132, y: 78, shape: "round", seats: 2, status: "free", seats_meta: "" },
{
id: "T3",
x: 214,
y: 78,
shape: "round",
seats: 2,
status: "reserved",
party: "Marquez 20:00",
server: "Sofía",
seated: "—",
course: "—",
check: "—",
},
{
id: "T4",
x: 42,
y: 142,
shape: "square",
seats: 4,
status: "seated",
party: "Costa ×3",
server: "Sofía",
seated: "19:10",
course: "Dessert",
check: "$208.00",
},
{
id: "T5",
x: 125,
y: 142,
shape: "square",
seats: 4,
status: "check",
party: "Vega ×4",
server: "Sofía",
seated: "18:55",
course: "Pre-bill",
check: "$284.20",
},
{ id: "T6", x: 208, y: 142, shape: "square", seats: 4, status: "dirty" },
// Centre — Marco
{
id: "T7",
x: 314,
y: 78,
shape: "round",
seats: 2,
status: "seated",
party: "García ×2",
server: "Marco",
seated: "20:05",
course: "Apps",
check: "$48.00",
},
{ id: "T8", x: 396, y: 78, shape: "round", seats: 2, status: "free" },
{
id: "T9",
x: 478,
y: 78,
shape: "round",
seats: 2,
status: "reserved",
party: "Khoury 20:15",
server: "Marco",
},
{ id: "T10", x: 560, y: 78, shape: "round", seats: 2, status: "free" },
{
id: "T11",
x: 308,
y: 142,
shape: "square",
seats: 4,
status: "seated",
party: "Loredo ×4",
server: "Marco",
seated: "19:20",
course: "Mains",
check: "$162.80",
},
{
id: "T12",
x: 410,
y: 142,
shape: "square",
seats: 4,
status: "seated",
party: "Yamamoto ×3",
server: "Marco",
seated: "19:50",
course: "Apps",
check: "$96.00",
},
{
id: "T13",
x: 520,
y: 146,
shape: "long",
seats: 6,
status: "reserved",
party: "Mendoza 20:30 ×6",
server: "Marco",
},
// Patio — Aitana
{
id: "P1",
x: 58,
y: 258,
shape: "round",
seats: 2,
status: "seated",
party: "Park ×2",
server: "Aitana",
seated: "20:00",
course: "Apps",
check: "$54.00",
},
{ id: "P2", x: 148, y: 258, shape: "round", seats: 2, status: "free" },
{ id: "P3", x: 238, y: 258, shape: "round", seats: 2, status: "dirty" },
{
id: "P4",
x: 328,
y: 258,
shape: "round",
seats: 2,
status: "seated",
party: "Singh ×2",
server: "Aitana",
seated: "19:35",
course: "Mains",
check: "$118.50",
},
{
id: "P5",
x: 54,
y: 344,
shape: "square",
seats: 4,
status: "check",
party: "Davis ×4",
server: "Aitana",
seated: "18:30",
course: "Pre-bill",
check: "$312.10",
},
{
id: "P6",
x: 170,
y: 348,
shape: "long",
seats: 6,
status: "seated",
party: "Mota ×5",
server: "Aitana",
seated: "19:00",
course: "Mains",
check: "$248.00",
},
{
id: "P7",
x: 326,
y: 344,
shape: "square",
seats: 4,
status: "reserved",
party: "Tanaka 20:45",
server: "Aitana",
},
// Bar — Theo
{
id: "B1",
x: 448,
y: 284,
shape: "round",
seats: 2,
status: "seated",
party: "Walk-in ×1",
server: "Theo",
seated: "20:10",
course: "Drinks",
check: "$28.00",
},
{
id: "B2",
x: 508,
y: 284,
shape: "round",
seats: 2,
status: "seated",
party: "Walk-in ×2",
server: "Theo",
seated: "20:14",
course: "Drinks",
check: "$36.00",
},
{ id: "B3", x: 568, y: 284, shape: "round", seats: 2, status: "free" },
{ id: "B4", x: 478, y: 348, shape: "round", seats: 2, status: "dirty" },
{ id: "B5", x: 542, y: 348, shape: "round", seats: 2, status: "free" },
];
const CYCLE = ["free", "reserved", "seated", "check", "dirty", "free"];
const STATUS_LABEL = {
free: "Free",
reserved: "Reserved",
seated: "Seated",
check: "Pre-bill",
dirty: "Needs bussing",
};
const SEATS_COUNT = {
round: 2,
square: 4,
long: 6,
};
const BASE_SIZES = {
round: { w: 56, h: 44, r: 22 },
square: { w: 60, h: 44, rx: 6 },
long: { w: 92, h: 36, rx: 4 },
};
const PLAN_BOUNDS = {
w: 640,
h: 420,
pad: 8,
};
let selectedId = null;
let tables = TABLES.map((t) => ({ ...t }));
let editMode = false;
let dragState = null;
let suppressClick = false;
const SVG_NS = "http://www.w3.org/2000/svg";
const plan = document.querySelector(".plan");
const tablesG = document.getElementById("tables");
const editToggle = document.getElementById("editToggle");
const addTableBtn = document.getElementById("addTable");
const sideKicker = document.getElementById("sideKicker");
const sideTitle = document.getElementById("sideTitle");
const sideHint = document.getElementById("sideHint");
const sideMeta = document.getElementById("sideMeta");
const sideFoot = document.getElementById("sideFoot");
const editPanel = document.getElementById("editPanel");
const shapeSelect = document.getElementById("shapeSelect");
const sizeDownBtn = document.getElementById("sizeDown");
const sizeUpBtn = document.getElementById("sizeUp");
const sizeValue = document.getElementById("sizeValue");
const duplicateBtn = document.getElementById("duplicateTable");
const deleteBtn = document.getElementById("deleteTable");
const cycleBtn = document.getElementById("sideCycle");
const assignBtn = document.getElementById("sideAssign");
const dStatus = document.getElementById("sideStatus");
const dParty = document.getElementById("sideParty");
const dServer = document.getElementById("sideServer");
const dSeated = document.getElementById("sideSeated");
const dCourse = document.getElementById("sideCourse");
const dCheck = document.getElementById("sideCheck");
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function getTableSize(t) {
const base = BASE_SIZES[t.shape] || BASE_SIZES.square;
const scale = t.scale || 1;
return {
w: base.w * scale,
h: base.h * scale,
r: (base.r || 0) * scale,
rx: (base.rx || 0) * scale,
};
}
function keepTableInBounds(t) {
const size = getTableSize(t);
t.x = clamp(t.x, PLAN_BOUNDS.pad, PLAN_BOUNDS.w - size.w - PLAN_BOUNDS.pad);
t.y = clamp(t.y, PLAN_BOUNDS.pad, PLAN_BOUNDS.h - size.h - PLAN_BOUNDS.pad);
}
function getSvgPoint(e) {
const ctm = plan.getScreenCTM();
if (!ctm) return { x: 0, y: 0 };
const point = plan.createSVGPoint();
point.x = e.clientX;
point.y = e.clientY;
return point.matrixTransform(ctm.inverse());
}
function createTable(t) {
const g = document.createElementNS(SVG_NS, "g");
g.classList.add("table-group");
g.dataset.id = t.id;
g.dataset.status = t.status;
const size = getTableSize(t);
let shape;
if (t.shape === "round") {
shape = document.createElementNS(SVG_NS, "circle");
shape.setAttribute("cx", t.x + size.w / 2);
shape.setAttribute("cy", t.y + size.h / 2);
shape.setAttribute("r", size.r);
} else if (t.shape === "square") {
shape = document.createElementNS(SVG_NS, "rect");
shape.setAttribute("x", t.x);
shape.setAttribute("y", t.y);
shape.setAttribute("width", size.w);
shape.setAttribute("height", size.h);
shape.setAttribute("rx", size.rx);
} else {
shape = document.createElementNS(SVG_NS, "rect");
shape.setAttribute("x", t.x);
shape.setAttribute("y", t.y);
shape.setAttribute("width", size.w);
shape.setAttribute("height", size.h);
shape.setAttribute("rx", size.rx);
}
shape.classList.add("table-shape");
g.appendChild(shape);
const label = document.createElementNS(SVG_NS, "text");
label.classList.add("table-label");
label.setAttribute("x", t.x + size.w / 2);
label.setAttribute("y", t.y + size.h / 2 + 1);
label.textContent = t.id;
g.appendChild(label);
const sub = document.createElementNS(SVG_NS, "text");
sub.classList.add("table-sub");
sub.setAttribute("x", t.x + size.w / 2);
sub.setAttribute("y", t.y + size.h / 2 + 12);
sub.textContent = `${t.seats || SEATS_COUNT[t.shape]} seats`;
g.appendChild(sub);
return g;
}
function renderTables() {
tablesG.innerHTML = "";
tables.forEach((t) => {
keepTableInBounds(t);
const node = createTable(t);
if (t.id === selectedId) node.classList.add("is-selected");
tablesG.appendChild(node);
});
document.getElementById("kpiTotal").textContent = tables.length;
document.getElementById("kpiOccupied").textContent = tables.filter(
(t) => t.status === "seated" || t.status === "check"
).length;
document.getElementById("kpiReserved").textContent = tables.filter(
(t) => t.status === "reserved"
).length;
document.getElementById("kpiCovers").textContent = tables
.filter((t) => t.status === "seated" || t.status === "check")
.reduce((sum, t) => {
const m = (t.party || "").match(/×(\d+)/);
return sum + (m ? Number(m[1]) : 0);
}, 0);
}
function renderSide() {
const t = tables.find((x) => x.id === selectedId);
if (!t) {
sideKicker.textContent = editMode ? "Edit layout" : "Select a table";
sideTitle.textContent = editMode ? "No item selected" : "No table selected";
sideHint.textContent = editMode
? "Add a table or select one on the plan."
: "Tap any table on the plan to see its status, party and current course.";
sideHint.hidden = false;
sideMeta.hidden = true;
sideFoot.hidden = true;
updateEditControls();
return;
}
sideKicker.textContent = STATUS_LABEL[t.status];
sideTitle.textContent = `${t.id} · ${t.seats || SEATS_COUNT[t.shape]} seats`;
sideHint.hidden = true;
sideMeta.hidden = false;
sideFoot.hidden = editMode;
dStatus.textContent = STATUS_LABEL[t.status];
dStatus.dataset.tone = t.status;
dParty.textContent = t.party || "—";
dServer.textContent = t.server || "—";
dSeated.textContent = t.seated || "—";
dCourse.textContent = t.course || "—";
dCheck.textContent = t.check || "—";
updateEditControls();
}
function updateEditControls() {
const selected = tables.find((x) => x.id === selectedId);
document.body.classList.toggle("is-editing", editMode);
editToggle.textContent = editMode ? "Done" : "Edit layout";
editToggle.setAttribute("aria-pressed", String(editMode));
addTableBtn.disabled = !editMode;
editPanel.hidden = !editMode;
const hasSelection = Boolean(selected);
shapeSelect.disabled = !editMode || !hasSelection;
sizeDownBtn.disabled = !editMode || !hasSelection;
sizeUpBtn.disabled = !editMode || !hasSelection;
duplicateBtn.disabled = !editMode || !hasSelection;
deleteBtn.disabled = !editMode || !hasSelection;
if (selected) {
shapeSelect.value = selected.shape;
sizeValue.textContent = `${Math.round((selected.scale || 1) * 100)}%`;
} else {
sizeValue.textContent = "100%";
}
}
function nextTableId() {
let i = 1;
while (tables.some((t) => t.id === `N${i}`)) i += 1;
return `N${i}`;
}
function addTable(seed) {
const source = seed || tables.find((t) => t.id === selectedId);
const t = {
id: nextTableId(),
x: source ? source.x + 24 : 292,
y: source ? source.y + 24 : 214,
shape: source ? source.shape : "square",
seats: source ? source.seats || SEATS_COUNT[source.shape] : 4,
status: "free",
scale: source ? source.scale || 1 : 1,
};
keepTableInBounds(t);
tables.push(t);
selectedId = t.id;
renderTables();
renderSide();
}
function resizeSelected(delta) {
const t = tables.find((x) => x.id === selectedId);
if (!t) return;
t.scale = clamp((t.scale || 1) + delta, 0.75, 1.5);
keepTableInBounds(t);
renderTables();
renderSide();
}
tablesG.addEventListener("click", (e) => {
if (suppressClick) return;
const g = e.target.closest("[data-id]");
if (!g) return;
selectedId = g.dataset.id;
renderTables();
renderSide();
});
tablesG.addEventListener("pointerdown", (e) => {
const g = e.target.closest("[data-id]");
if (!g || !editMode) return;
const t = tables.find((x) => x.id === g.dataset.id);
if (!t) return;
selectedId = t.id;
const point = getSvgPoint(e);
dragState = {
id: t.id,
dx: point.x - t.x,
dy: point.y - t.y,
moved: false,
};
document.body.classList.add("is-dragging");
renderTables();
renderSide();
e.preventDefault();
});
window.addEventListener("pointermove", (e) => {
if (!dragState) return;
const t = tables.find((x) => x.id === dragState.id);
if (!t) return;
const point = getSvgPoint(e);
t.x = point.x - dragState.dx;
t.y = point.y - dragState.dy;
keepTableInBounds(t);
dragState.moved = true;
renderTables();
renderSide();
e.preventDefault();
});
window.addEventListener("pointerup", () => {
if (!dragState) return;
suppressClick = dragState.moved;
dragState = null;
document.body.classList.remove("is-dragging");
if (suppressClick) {
window.setTimeout(() => {
suppressClick = false;
}, 0);
}
});
editToggle.addEventListener("click", () => {
editMode = !editMode;
renderTables();
renderSide();
});
addTableBtn.addEventListener("click", () => {
if (!editMode) return;
addTable();
});
duplicateBtn.addEventListener("click", () => {
if (!editMode) return;
const selected = tables.find((t) => t.id === selectedId);
if (!selected) return;
addTable(selected);
});
deleteBtn.addEventListener("click", () => {
if (!editMode || !selectedId) return;
tables = tables.filter((t) => t.id !== selectedId);
selectedId = null;
renderTables();
renderSide();
});
shapeSelect.addEventListener("change", () => {
const t = tables.find((x) => x.id === selectedId);
if (!t) return;
t.shape = shapeSelect.value;
t.seats = SEATS_COUNT[t.shape];
keepTableInBounds(t);
renderTables();
renderSide();
});
sizeDownBtn.addEventListener("click", () => resizeSelected(-0.1));
sizeUpBtn.addEventListener("click", () => resizeSelected(0.1));
cycleBtn.addEventListener("click", () => {
const t = tables.find((x) => x.id === selectedId);
if (!t) return;
const i = CYCLE.indexOf(t.status);
t.status = CYCLE[(i + 1) % CYCLE.length];
if (t.status === "free") {
t.party = undefined;
t.seated = undefined;
t.course = undefined;
t.check = undefined;
t.server = undefined;
}
if (t.status === "seated" && !t.party) {
t.party = "Walk-in";
t.seated = `${new Date().getHours()}:${String(new Date().getMinutes()).padStart(2, "0")}`;
t.course = "Apps";
t.check = "$0.00";
t.server = "—";
}
renderTables();
renderSide();
});
assignBtn.addEventListener("click", () => {
const t = tables.find((x) => x.id === selectedId);
if (!t) return;
const name = prompt("Party name:", t.party || "");
if (!name) return;
t.party = name;
if (t.status === "free") t.status = "reserved";
renderTables();
renderSide();
});
renderTables();
renderSide();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Floor Plan</title>
</head>
<body>
<header class="topbar">
<div>
<p class="kicker">Front of house</p>
<h1>Floor Plan · Dinner</h1>
</div>
<div class="kpis">
<span class="kpi"><b id="kpiOccupied">0</b>/<span id="kpiTotal">0</span> seated</span>
<span class="kpi"><b id="kpiCovers">0</b> covers</span>
<span class="kpi"><b id="kpiReserved">0</b> reserved</span>
</div>
<div class="legend">
<span class="lg" data-state="free">Free</span>
<span class="lg" data-state="reserved">Reserved</span>
<span class="lg" data-state="seated">Seated</span>
<span class="lg" data-state="check">Check</span>
<span class="lg" data-state="dirty">Dirty</span>
</div>
<div class="top-actions">
<button class="tool-btn" id="editToggle" type="button" aria-pressed="false">
Edit layout
</button>
<button class="tool-btn tool-btn-primary" id="addTable" type="button" disabled>
Add table
</button>
</div>
</header>
<main class="layout">
<section class="plan-wrap">
<svg
class="plan"
viewBox="0 0 640 420"
preserveAspectRatio="xMidYMid meet"
role="img"
aria-label="Restaurant floor plan"
>
<defs>
<pattern id="editGrid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" class="grid-line" />
</pattern>
</defs>
<!-- Floor backdrop -->
<rect x="0" y="0" width="640" height="420" rx="16" class="floor" />
<rect x="0" y="0" width="640" height="420" rx="16" class="edit-grid" />
<!-- Zones -->
<rect x="16" y="16" width="260" height="172" rx="12" class="zone zone-window" />
<text x="28" y="38" class="zone-label">Window · Sofía</text>
<rect x="286" y="16" width="338" height="172" rx="12" class="zone zone-center" />
<text x="298" y="38" class="zone-label">Centre · Marco</text>
<rect x="16" y="198" width="404" height="206" rx="12" class="zone zone-patio" />
<text x="28" y="220" class="zone-label">Patio · Aitana</text>
<rect x="430" y="198" width="194" height="206" rx="12" class="zone zone-bar" />
<text x="442" y="220" class="zone-label">Bar · Theo</text>
<!-- Static features -->
<path
d="M 420 198 L 420 404"
class="feature feature-rail"
vector-effect="non-scaling-stroke"
/>
<rect x="448" y="238" width="158" height="24" rx="7" class="feature feature-bar" />
<text x="527" y="254" class="feature-label" text-anchor="middle">— Bar —</text>
<rect x="570" y="20" width="44" height="22" rx="7" class="feature feature-host" />
<text x="592" y="35" class="feature-label" text-anchor="middle">Host</text>
<rect x="28" y="404" width="86" height="12" rx="6" class="feature feature-entry" />
<text x="71" y="414" class="feature-label" text-anchor="middle">Entry</text>
<!-- Tables rendered by script -->
<g id="tables"></g>
</svg>
</section>
<aside class="side" id="side">
<header class="side-head">
<p class="side-kicker" id="sideKicker">Select a table</p>
<h2 id="sideTitle">No table selected</h2>
</header>
<dl class="side-meta" id="sideMeta" hidden>
<div><dt>Status</dt><dd id="sideStatus">—</dd></div>
<div><dt>Party</dt><dd id="sideParty">—</dd></div>
<div><dt>Server</dt><dd id="sideServer">—</dd></div>
<div><dt>Seated</dt><dd id="sideSeated">—</dd></div>
<div><dt>Course</dt><dd id="sideCourse">—</dd></div>
<div><dt>Check</dt><dd id="sideCheck">—</dd></div>
</dl>
<p class="side-hint" id="sideHint">
Tap any table on the plan to see its status, party and current course.
</p>
<section class="edit-panel" id="editPanel" hidden>
<div class="edit-row">
<label class="edit-field" for="shapeSelect">
<span>Shape</span>
<select id="shapeSelect" disabled>
<option value="round">Round</option>
<option value="square">Square</option>
<option value="long">Long</option>
</select>
</label>
</div>
<div class="edit-row">
<span class="edit-label">Size</span>
<div class="size-tools">
<button class="ghost edit-icon" id="sizeDown" type="button" disabled>-</button>
<output id="sizeValue">100%</output>
<button class="ghost edit-icon" id="sizeUp" type="button" disabled>+</button>
</div>
</div>
<div class="edit-actions">
<button class="ghost" id="duplicateTable" type="button" disabled>Duplicate</button>
<button class="danger" id="deleteTable" type="button" disabled>Remove</button>
</div>
</section>
<footer class="side-foot" id="sideFoot" hidden>
<button class="ghost" id="sideAssign">Assign party</button>
<button class="primary" id="sideCycle">Cycle status →</button>
</footer>
</aside>
</main>
<script src="script.js"></script>
</body>
</html>Restaurant Floor Plan
A hostess-stand view of the dining room — tables drawn as SVG shapes in real coordinates, colour-coded by status (free, reserved, seated, check, dirty). Clicking a table opens the side panel with the party name, server, course state and duration; the cycle action advances the table through the service lifecycle.
Also features a responsive floor canvas, legend, occupancy KPI, server-zone shading, bar/host/entry markers, and an edit mode for layout tweaks: add a table, drag it into place, resize it, change its shape, duplicate it, or remove it.