Museum — Floor Map / Wayfinding
An interactive museum wayfinding map built from a hand-drawn SVG floor plan with labelled galleries, restrooms, a café, the shop and entrances. Floor tabs swap between Level 1, Level 2 and a lower level; clicking or keying into a room highlights it and opens a side panel showing its current exhibition with title, dates, medium, scope, attribution and curator. A type-ahead find field searches galleries and amenities, then pans to and pulses the matching room across floors.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--shadow-sm: 0 1px 2px rgba(28, 27, 25, 0.06);
--shadow-md: 0 8px 28px rgba(28, 27, 25, 0.1);
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: 28px 20px 48px;
}
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.frame {
max-width: 1120px;
margin: 0 auto;
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 22px 28px;
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.crest {
font-size: 26px;
color: var(--gold);
line-height: 1;
}
.kicker {
margin: 0;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.topbar h1 {
margin: 2px 0 0;
font-family: var(--serif);
font-weight: 600;
font-size: 28px;
letter-spacing: 0.01em;
color: var(--charcoal);
}
/* ---------- Find / search ---------- */
.find {
position: relative;
display: flex;
align-items: center;
gap: 8px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 6px 6px 6px 12px;
min-width: 280px;
flex: 1 1 280px;
max-width: 420px;
}
.find:focus-within {
border-color: var(--gold);
box-shadow: 0 0 0 3px var(--gold-50);
}
.find-ic {
color: var(--muted);
font-size: 18px;
}
.find input {
border: 0;
background: transparent;
font: inherit;
color: var(--ink);
flex: 1;
padding: 6px 2px;
min-width: 0;
}
.find input:focus { outline: none; }
.find input::placeholder { color: var(--muted); }
.find-go {
border: 1px solid var(--gold-d);
background: var(--gold);
color: #fff;
font: inherit;
font-weight: 600;
font-size: 13px;
padding: 8px 16px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s ease, transform .1s ease;
}
.find-go:hover { background: var(--gold-d); }
.find-go:active { transform: translateY(1px); }
.find-results {
position: absolute;
top: calc(100% + 8px);
left: 0; right: 0;
list-style: none;
margin: 0;
padding: 6px;
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 260px;
overflow: auto;
}
.find-results li {
padding: 9px 12px;
border-radius: var(--r-sm);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.find-results li:hover,
.find-results li.is-cursor {
background: var(--gold-50);
}
.find-results .res-floor {
margin-left: auto;
font-size: 11px;
color: var(--muted);
letter-spacing: .04em;
}
.find-results .res-empty {
color: var(--muted);
cursor: default;
}
.find-results .res-empty:hover { background: transparent; }
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 0;
}
/* ---------- Map pane ---------- */
.map-pane {
padding: 22px 24px 24px;
border-right: 1px solid var(--line);
}
.map-tools {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tabs {
display: inline-flex;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 4px;
gap: 4px;
}
.tab {
border: 0;
background: transparent;
font: inherit;
font-weight: 500;
font-size: 13px;
color: var(--ink-2);
padding: 7px 14px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s ease, color .15s ease;
}
.tab:hover { color: var(--charcoal); }
.tab.is-active {
background: var(--wall);
color: var(--charcoal);
box-shadow: var(--shadow-sm);
}
.tab:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.floor-meta {
margin: 0;
font-size: 13px;
color: var(--muted);
font-style: italic;
font-family: var(--serif);
font-size: 15px;
}
.stage {
position: relative;
background:
radial-gradient(120% 120% at 50% 0%, #fbfaf6 0%, var(--paper) 100%);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
}
#floorSvg {
display: block;
width: 100%;
height: auto;
}
.shell {
fill: none;
stroke: var(--line-2);
stroke-width: 2;
}
/* rooms */
.room {
cursor: pointer;
transition: filter .15s ease;
}
.room .room-rect {
fill: #fcfbf8;
stroke: var(--line-2);
stroke-width: 1.4;
transition: fill .18s ease, stroke .18s ease;
}
.room[data-kind="amenity"] .room-rect { fill: #f4f1ea; }
.room[data-kind="access"] .room-rect { fill: var(--gold-50); }
.room:hover .room-rect {
fill: #f7f1e3;
stroke: var(--gold);
}
.room.is-selected .room-rect {
fill: var(--gold-50);
stroke: var(--gold-d);
stroke-width: 2.4;
}
.room:focus-visible { outline: none; }
.room:focus-visible .room-rect {
stroke: var(--gold);
stroke-width: 2.6;
}
.room.is-found .room-rect {
animation: pulse 1.1s ease 2;
}
@keyframes pulse {
0%, 100% { stroke: var(--gold-d); }
50% { stroke: var(--danger); }
}
.room .room-ic {
font-size: 15px;
fill: var(--ink-2);
}
.labels text {
font-family: var(--sans);
font-size: 11px;
font-weight: 500;
fill: var(--ink-2);
pointer-events: none;
text-anchor: middle;
}
.labels .room-no {
font-size: 9px;
fill: var(--muted);
letter-spacing: .05em;
}
.compass {
position: absolute;
top: 22px;
right: 22px;
width: 38px;
height: 38px;
border: 1px solid var(--line);
border-radius: 50%;
background: var(--wall);
display: grid;
place-items: center;
box-shadow: var(--shadow-sm);
color: var(--muted);
font-size: 11px;
line-height: 1;
}
.compass-n {
position: absolute;
top: 4px;
font-weight: 600;
font-size: 9px;
color: var(--gold-d);
}
.compass-arrow { font-size: 16px; color: var(--gold); margin-top: 4px; }
.legend {
display: flex;
flex-wrap: wrap;
gap: 18px;
list-style: none;
margin: 16px 0 0;
padding: 0;
font-size: 12px;
color: var(--ink-2);
}
.legend li { display: flex; align-items: center; gap: 7px; }
.dot {
width: 11px; height: 11px;
border-radius: 3px;
border: 1px solid var(--line-2);
display: inline-block;
}
.dot-gallery { background: #fcfbf8; }
.dot-amenity { background: #f4f1ea; }
.dot-access { background: var(--gold-50); }
.dot-active { background: var(--gold); border-color: var(--gold-d); }
/* ---------- Side panel ---------- */
.panel {
padding: 22px 24px 26px;
min-height: 480px;
}
.panel-empty {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
color: var(--muted);
padding: 40px 12px;
}
.panel-empty .pe-mark {
font-size: 34px;
color: var(--gold);
margin-bottom: 10px;
}
.panel-empty h3 {
font-family: var(--serif);
font-size: 22px;
color: var(--charcoal);
margin: 0 0 6px;
}
.panel-empty p { margin: 0; font-size: 14px; max-width: 30ch; align-self: center; }
.gx-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.gx-no {
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.gx-name {
font-family: var(--serif);
font-weight: 600;
font-size: 27px;
line-height: 1.15;
margin: 4px 0 0;
color: var(--charcoal);
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: .03em;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--line);
white-space: nowrap;
}
.badge::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: currentColor;
}
.badge-open { color: var(--ok); background: rgba(63,125,86,.08); border-color: rgba(63,125,86,.25); }
.badge-soon { color: var(--warn); background: rgba(184,132,44,.08); border-color: rgba(184,132,44,.25); }
.badge-closed { color: var(--danger); background: rgba(180,73,58,.08); border-color: rgba(180,73,58,.25); }
.badge-amenity { color: var(--ink-2); background: var(--paper); }
.gx-art {
margin: 18px 0;
border-radius: var(--r-sm);
border: 1px solid var(--line);
height: 140px;
position: relative;
overflow: hidden;
}
.gx-art .mat {
position: absolute;
inset: 10px;
border: 1px solid rgba(255,255,255,.55);
border-radius: 3px;
}
.gx-sub {
font-family: var(--serif);
font-style: italic;
font-size: 17px;
color: var(--ink-2);
margin: 0 0 4px;
}
.gx-dates { font-size: 12.5px; color: var(--muted); margin: 0 0 16px; }
.gx-desc {
font-size: 14px;
color: var(--ink-2);
margin: 0 0 18px;
}
.gx-meta {
list-style: none;
margin: 0 0 18px;
padding: 14px 0 0;
border-top: 1px solid var(--line);
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.gx-meta dt {
font-size: 10.5px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.gx-meta dd {
margin: 2px 0 0;
font-size: 13.5px;
color: var(--ink);
}
.gx-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn {
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 9px 16px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s ease, transform .1s ease, border-color .15s ease;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--gold); outline-offset: 2px; }
.btn-primary {
background: var(--charcoal);
color: var(--paper);
border: 1px solid var(--charcoal);
}
.btn-primary:hover { background: #000; }
.btn-ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--line-2);
}
.btn-ghost:hover { border-color: var(--gold); color: var(--gold-d); }
.amenity-note {
font-size: 14px;
color: var(--ink-2);
margin: 18px 0 0;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(20px);
background: var(--charcoal);
color: var(--paper);
font-size: 13.5px;
font-weight: 500;
padding: 11px 20px;
border-radius: 999px;
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------- Responsive ---------- */
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.map-pane { border-right: 0; border-bottom: 1px solid var(--line); }
.panel { min-height: auto; }
}
@media (max-width: 520px) {
body { padding: 16px 12px 36px; }
.topbar { padding: 18px 18px; gap: 16px; }
.topbar h1 { font-size: 23px; }
.find { min-width: 0; max-width: none; }
.map-pane, .panel { padding: 18px 16px; }
.map-tools { gap: 8px; }
.tab { padding: 6px 11px; }
.gx-name { font-size: 23px; }
.gx-meta { grid-template-columns: 1fr; }
.gx-actions { flex-direction: column; }
.btn { width: 100%; }
}(function () {
"use strict";
/* ---------------- Data ---------------- */
// Each room: id, name, room no, kind (gallery|amenity|access), floor,
// svg rect geometry, optional icon, exhibition details.
var FLOORS = {
L1: { label: "Main level · Entrance & Grand Galleries" },
L2: { label: "Upper level · Modern & Works on Paper" },
B: { label: "Lower level · Antiquities, Café & Shop" }
};
var ROOMS = [
// ---- Level 1 ----
{ id: "g101", floor: "L1", kind: "gallery", no: "Gallery 101", name: "Origins",
x: 40, y: 50, w: 200, h: 150, accent: ["#8a9a8e", "#5f6f63"],
ex: { title: "Before the Frame", status: "open",
sub: "Landscapes of the Northern School, 1610–1740",
dates: "On view through Nov 9, 2026",
desc: "Sixty-one oils and ground studies tracing the slow invention of open-air painting.",
artist: "Various masters", medium: "Oil on panel & canvas",
works: "61 works", curator: "H. Albrecht" } },
{ id: "g102", floor: "L1", kind: "gallery", no: "Gallery 102", name: "Light & Atmosphere",
x: 260, y: 50, w: 200, h: 150, accent: ["#d9c27a", "#b89a45"],
ex: { title: "The Hour of Gold", status: "open",
sub: "Luminist studies by Cora Vane, 1871–1889",
dates: "On view through Jan 18, 2027",
desc: "Vane's quiet shorelines, lent from the Hartwell bequest, gathered for the first time.",
artist: "Cora Vane (1844–1902)", medium: "Oil & gouache",
works: "34 works", curator: "M. Okonkwo" } },
{ id: "g103", floor: "L1", kind: "gallery", no: "Gallery 103", name: "Portraiture",
x: 480, y: 50, w: 200, h: 150, accent: ["#a76f6f", "#7d4b4b"],
ex: { title: "Faces of the Republic", status: "soon",
sub: "Civic portraits, 1790–1860",
dates: "Opens Jul 2, 2026",
desc: "Installation in progress — a survey of the painted official portrait.",
artist: "Various", medium: "Oil on canvas",
works: "29 works", curator: "L. Strand" } },
{ id: "atrium", floor: "L1", kind: "access", no: "Hall", name: "Grand Atrium",
x: 260, y: 220, w: 200, h: 110, icon: "✦",
ex: { title: "Grand Atrium", status: "amenity",
amenity: "The double-height entrance hall. Information desk, coat check, and step-free access to all levels via the central lift." } },
{ id: "entrance", floor: "L1", kind: "access", no: "Entrance", name: "Main Entrance",
x: 300, y: 350, w: 120, h: 50, icon: "⌂",
ex: { title: "Main Entrance", status: "amenity",
amenity: "Street-level entry on Meridian Square. Ticketing, security check, and accessible ramp to the right of the steps." } },
{ id: "wc1", floor: "L1", kind: "amenity", no: "Amenity", name: "Restrooms",
x: 40, y: 220, w: 90, h: 110, icon: "♿",
ex: { title: "Restrooms", status: "amenity",
amenity: "Accessible restrooms and baby-change facilities, just past Gallery 101." } },
{ id: "info", floor: "L1", kind: "amenity", no: "Amenity", name: "Information",
x: 150, y: 220, w: 90, h: 110, icon: "ⓘ",
ex: { title: "Information Desk", status: "amenity",
amenity: "Tickets, maps, audio-guide hire, and daily tour sign-up. Staffed 10:00–17:30." } },
{ id: "shop1", floor: "L1", kind: "amenity", no: "Amenity", name: "Museum Shop",
x: 480, y: 220, w: 200, h: 110, icon: "🛍",
ex: { title: "Museum Shop", status: "amenity",
amenity: "Catalogues, prints, and design objects. Members receive 10% off." } },
// ---- Level 2 ----
{ id: "g201", floor: "L2", kind: "gallery", no: "Gallery 201", name: "Abstraction",
x: 40, y: 50, w: 260, h: 150, accent: ["#6f7fa7", "#49587d"],
ex: { title: "Fields & Edges", status: "open",
sub: "Postwar abstraction, 1948–1969",
dates: "On view through Sep 28, 2026",
desc: "Color-field canvases and hard-edge works drawn entirely from the permanent collection.",
artist: "Various", medium: "Acrylic & oil on canvas",
works: "22 works", curator: "D. Reyes" } },
{ id: "g202", floor: "L2", kind: "gallery", no: "Gallery 202", name: "Works on Paper",
x: 320, y: 50, w: 200, h: 150, accent: ["#b3aa97", "#8c8470"],
ex: { title: "A Line Around a Thought", status: "open",
sub: "Drawings & prints, 16th–20th c.",
dates: "Rotating display",
desc: "A light-sensitive rotation of drawings, etchings and lithographs shown at low lux.",
artist: "Various", medium: "Ink, chalk & intaglio",
works: "40 works", curator: "P. Nadeau" } },
{ id: "g203", floor: "L2", kind: "gallery", no: "Gallery 203", name: "Contemporary",
x: 540, y: 50, w: 140, h: 150, accent: ["#7aa78c", "#4f7d63"],
ex: { title: "Now / Adjacent", status: "soon",
sub: "New acquisitions, 2019–present",
dates: "Opens Aug 15, 2026",
desc: "A first look at recent gifts spanning sculpture, video and textile.",
artist: "Various living artists", medium: "Mixed media",
works: "18 works", curator: "S. Imari" } },
{ id: "g204", floor: "L2", kind: "gallery", no: "Gallery 204", name: "Photography",
x: 40, y: 220, w: 280, h: 110, accent: ["#8c8c8c", "#5c5c5c"],
ex: { title: "Silver & Salt", status: "open",
sub: "A century of the photographic print",
dates: "On view through Dec 14, 2026",
desc: "From wet-plate to gelatin silver, traced across 47 vintage prints.",
artist: "Various", medium: "Photographic prints",
works: "47 works", curator: "R. Voss" } },
{ id: "wc2", floor: "L2", kind: "amenity", no: "Amenity", name: "Restrooms",
x: 340, y: 220, w: 100, h: 110, icon: "♿",
ex: { title: "Restrooms", status: "amenity",
amenity: "Accessible restrooms beside the upper lift landing." } },
{ id: "lounge", floor: "L2", kind: "amenity", no: "Amenity", name: "Members' Lounge",
x: 460, y: 220, w: 220, h: 110, icon: "☕",
ex: { title: "Members' Lounge", status: "amenity",
amenity: "Quiet seating, complimentary tea, and the day's papers. Member card required." } },
// ---- Lower B ----
{ id: "b01", floor: "B", kind: "gallery", no: "Gallery B1", name: "Antiquities",
x: 40, y: 50, w: 300, h: 150, accent: ["#b59b6e", "#8c7242"],
ex: { title: "Stone, Bronze & Clay", status: "open",
sub: "The ancient Mediterranean, 900 BCE–400 CE",
dates: "Permanent collection",
desc: "Marble fragments, votive bronzes and painted ware from the founding Calloway gift.",
artist: "Various cultures", medium: "Stone, bronze, terracotta",
works: "73 objects", curator: "E. Marsh" } },
{ id: "b02", floor: "B", kind: "gallery", no: "Gallery B2", name: "Textiles & Costume",
x: 360, y: 50, w: 320, h: 150, accent: ["#a78c9e", "#7d647a"],
ex: { title: "Woven Histories", status: "closed",
sub: "Dress & textile, 1680–1920",
dates: "Temporarily closed for rehang",
desc: "Gallery closed while garments are rotated for conservation. Reopens late July.",
artist: "Various", medium: "Silk, wool & cotton",
works: "31 objects", curator: "T. Bellweather" } },
{ id: "cafe", floor: "B", kind: "amenity", no: "Amenity", name: "Garden Café",
x: 40, y: 220, w: 240, h: 130, icon: "☕",
ex: { title: "Garden Café", status: "amenity",
amenity: "Seasonal plates and pastries overlooking the sculpture court. Open 10:00–16:30." } },
{ id: "cloak", floor: "B", kind: "amenity", no: "Amenity", name: "Cloakroom",
x: 300, y: 220, w: 130, h: 130, icon: "🧥",
ex: { title: "Cloakroom", status: "amenity",
amenity: "Free coat and bag check. Large luggage cannot be accommodated." } },
{ id: "lift", floor: "B", kind: "access", no: "Access", name: "Central Lift",
x: 450, y: 220, w: 110, h: 130, icon: "⇕",
ex: { title: "Central Lift", status: "amenity",
amenity: "Step-free access to every level. Stairs adjacent for those who prefer them." } },
{ id: "wcb", floor: "B", kind: "amenity", no: "Amenity", name: "Restrooms",
x: 580, y: 220, w: 100, h: 130, icon: "♿",
ex: { title: "Restrooms", status: "amenity",
amenity: "Accessible restrooms by the lower lift landing." } }
];
var STATUS_LABEL = {
open: "On View",
soon: "Opening Soon",
closed: "Closed",
amenity: "Amenity"
};
var STATUS_CLASS = {
open: "badge-open",
soon: "badge-soon",
closed: "badge-closed",
amenity: "badge-amenity"
};
/* ---------------- Refs ---------------- */
var SVGNS = "http://www.w3.org/2000/svg";
var roomsGroup = document.getElementById("roomsGroup");
var labelsGroup = document.getElementById("labelsGroup");
var tabs = Array.prototype.slice.call(document.querySelectorAll(".tab"));
var floorMeta = document.getElementById("floorMeta");
var panelBody = document.getElementById("panelBody");
var findForm = document.querySelector(".find");
var findInput = document.getElementById("findInput");
var findResults = document.getElementById("findResults");
var toastEl = document.getElementById("toast");
var currentFloor = "L1";
var selectedId = null;
var resultCursor = -1;
/* ---------------- Toast ---------------- */
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2200);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
/* ---------------- Render floor ---------------- */
function renderFloor(floor) {
currentFloor = floor;
roomsGroup.innerHTML = "";
labelsGroup.innerHTML = "";
floorMeta.textContent = FLOORS[floor].label;
ROOMS.filter(function (r) { return r.floor === floor; }).forEach(function (r) {
var g = document.createElementNS(SVGNS, "g");
g.setAttribute("class", "room");
g.setAttribute("data-id", r.id);
g.setAttribute("data-kind", r.kind);
g.setAttribute("tabindex", "0");
g.setAttribute("role", "button");
g.setAttribute("aria-label", r.name + " — " + (r.ex.sub ? r.ex.title : STATUS_LABEL[r.ex.status]));
var rect = document.createElementNS(SVGNS, "rect");
rect.setAttribute("class", "room-rect");
rect.setAttribute("x", r.x);
rect.setAttribute("y", r.y);
rect.setAttribute("width", r.w);
rect.setAttribute("height", r.h);
rect.setAttribute("rx", "6");
g.appendChild(rect);
// accent swatch for galleries
if (r.kind === "gallery" && r.accent) {
var swatch = document.createElementNS(SVGNS, "rect");
swatch.setAttribute("x", r.x + 10);
swatch.setAttribute("y", r.y + 10);
swatch.setAttribute("width", 26);
swatch.setAttribute("height", 26);
swatch.setAttribute("rx", "3");
swatch.setAttribute("fill", r.accent[0]);
swatch.setAttribute("stroke", r.accent[1]);
swatch.setAttribute("stroke-width", "1");
swatch.setAttribute("pointer-events", "none");
g.appendChild(swatch);
}
roomsGroup.appendChild(g);
// labels
var cx = r.x + r.w / 2;
var cy = r.y + r.h / 2;
if (r.icon) {
var ico = document.createElementNS(SVGNS, "text");
ico.setAttribute("x", cx);
ico.setAttribute("y", cy - 4);
ico.setAttribute("font-size", "18");
ico.setAttribute("text-anchor", "middle");
ico.textContent = r.icon;
labelsGroup.appendChild(ico);
}
var name = document.createElementNS(SVGNS, "text");
name.setAttribute("x", cx);
name.setAttribute("y", r.icon ? cy + 16 : cy);
name.textContent = r.name;
labelsGroup.appendChild(name);
if (r.kind === "gallery") {
var no = document.createElementNS(SVGNS, "text");
no.setAttribute("class", "room-no");
no.setAttribute("x", cx);
no.setAttribute("y", cy + 16);
no.textContent = r.no.toUpperCase();
labelsGroup.appendChild(no);
}
});
// re-apply selection highlight if the selected room is on this floor
if (selectedId) {
var stillHere = ROOMS.some(function (r) { return r.id === selectedId && r.floor === floor; });
if (stillHere) markSelected(selectedId);
}
}
/* ---------------- Selection ---------------- */
function getRoom(id) {
return ROOMS.filter(function (r) { return r.id === id; })[0];
}
function markSelected(id) {
Array.prototype.forEach.call(roomsGroup.querySelectorAll(".room"), function (g) {
g.classList.toggle("is-selected", g.getAttribute("data-id") === id);
});
}
function selectRoom(id, opts) {
opts = opts || {};
var r = getRoom(id);
if (!r) return;
selectedId = id;
if (r.floor !== currentFloor) {
setFloor(r.floor, true);
}
markSelected(id);
renderPanel(r);
if (opts.found) {
var g = roomsGroup.querySelector('.room[data-id="' + id + '"]');
if (g) {
var rect = g.querySelector(".room-rect");
rect.classList.remove("is-found");
// reflow to restart animation
void rect.getBBox();
rect.classList.add("is-found");
}
}
if (opts.toast) toast(opts.toast);
}
function renderPanel(r) {
var ex = r.ex;
var statusCls = STATUS_CLASS[ex.status];
var statusLbl = STATUS_LABEL[ex.status];
if (ex.status === "amenity") {
panelBody.innerHTML =
'<div class="gx-head">' +
'<div><span class="gx-no">' + escapeHtml(r.no) + ' · ' + currentFloor + '</span>' +
'<h2 class="gx-name">' + escapeHtml(ex.title) + '</h2></div>' +
'<span class="badge ' + statusCls + '">' + statusLbl + '</span>' +
'</div>' +
'<p class="amenity-note">' + escapeHtml(ex.amenity) + '</p>' +
'<div class="gx-actions" style="margin-top:18px">' +
'<button class="btn btn-ghost" data-route>Route from entrance</button>' +
'</div>';
} else {
var grad = r.accent
? "linear-gradient(135deg," + r.accent[0] + " 0%," + r.accent[1] + " 100%)"
: "var(--gold-50)";
panelBody.innerHTML =
'<div class="gx-head">' +
'<div><span class="gx-no">' + escapeHtml(r.no) + ' · ' + escapeHtml(r.name) + ' · ' + currentFloor + '</span>' +
'<h2 class="gx-name">' + escapeHtml(ex.title) + '</h2></div>' +
'<span class="badge ' + statusCls + '">' + statusLbl + '</span>' +
'</div>' +
'<div class="gx-art" style="background:' + grad + '"><span class="mat"></span></div>' +
'<p class="gx-sub">' + escapeHtml(ex.sub) + '</p>' +
'<p class="gx-dates">' + escapeHtml(ex.dates) + '</p>' +
'<p class="gx-desc">' + escapeHtml(ex.desc) + '</p>' +
'<dl class="gx-meta">' +
'<div><dt>Medium</dt><dd>' + escapeHtml(ex.medium) + '</dd></div>' +
'<div><dt>Scope</dt><dd>' + escapeHtml(ex.works) + '</dd></div>' +
'<div><dt>Attribution</dt><dd>' + escapeHtml(ex.artist) + '</dd></div>' +
'<div><dt>Curator</dt><dd>' + escapeHtml(ex.curator) + '</dd></div>' +
'</dl>' +
'<div class="gx-actions">' +
'<button class="btn btn-primary" data-guide>Add to my visit</button>' +
'<button class="btn btn-ghost" data-route>Route from entrance</button>' +
'</div>';
}
var guideBtn = panelBody.querySelector("[data-guide]");
if (guideBtn) guideBtn.addEventListener("click", function () {
toast("Added " + ex.title + " to your visit");
});
var routeBtn = panelBody.querySelector("[data-route]");
if (routeBtn) routeBtn.addEventListener("click", function () {
toast("Routing to " + r.name + " on " + currentFloor);
});
}
function renderEmpty() {
panelBody.innerHTML =
'<div class="panel-empty">' +
'<span class="pe-mark" aria-hidden="true">◈</span>' +
'<h3>Select a space</h3>' +
'<p>Tap any gallery or amenity on the plan to see its current exhibition and details.</p>' +
'</div>';
}
/* ---------------- Floors / tabs ---------------- */
function setFloor(floor, silent) {
if (floor === currentFloor) { renderFloor(floor); return; }
tabs.forEach(function (t) {
var on = t.getAttribute("data-floor") === floor;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", on ? "true" : "false");
});
renderFloor(floor);
if (!silent) toast("Now showing " + floorName(floor));
}
function floorName(f) {
return f === "L1" ? "Level 1" : f === "L2" ? "Level 2" : "Lower B";
}
tabs.forEach(function (t) {
t.addEventListener("click", function () {
setFloor(t.getAttribute("data-floor"));
});
});
/* ---------------- Room click / keyboard ---------------- */
function onRoomActivate(e) {
var g = e.target.closest ? e.target.closest(".room") : null;
if (!g) return;
selectRoom(g.getAttribute("data-id"));
}
roomsGroup.addEventListener("click", onRoomActivate);
roomsGroup.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
var g = e.target.closest ? e.target.closest(".room") : null;
if (g) { e.preventDefault(); selectRoom(g.getAttribute("data-id")); }
}
});
/* ---------------- Find / search ---------------- */
function searchRooms(q) {
q = q.trim().toLowerCase();
if (!q) return [];
return ROOMS.filter(function (r) {
return r.name.toLowerCase().indexOf(q) > -1 ||
r.no.toLowerCase().indexOf(q) > -1 ||
(r.ex.title && r.ex.title.toLowerCase().indexOf(q) > -1);
}).slice(0, 7);
}
function renderResults(list) {
resultCursor = -1;
if (!findInput.value.trim()) { hideResults(); return; }
if (!list.length) {
findResults.innerHTML = '<li class="res-empty">No matching space found</li>';
findResults.hidden = false;
return;
}
findResults.innerHTML = list.map(function (r) {
var ic = r.kind === "gallery" ? "▣" : (r.icon || "•");
return '<li role="option" data-id="' + r.id + '">' +
'<span aria-hidden="true">' + ic + '</span>' +
'<span>' + escapeHtml(r.name) + '</span>' +
'<span class="res-floor">' + floorName(r.floor) + '</span></li>';
}).join("");
findResults.hidden = false;
}
function hideResults() {
findResults.hidden = true;
findResults.innerHTML = "";
resultCursor = -1;
}
findInput.addEventListener("input", function () {
renderResults(searchRooms(findInput.value));
});
findResults.addEventListener("click", function (e) {
var li = e.target.closest("li[data-id]");
if (!li) return;
pickResult(li.getAttribute("data-id"));
});
function pickResult(id) {
var r = getRoom(id);
hideResults();
findInput.value = r.name;
selectRoom(id, { found: true, toast: "Highlighting " + r.name + " · " + floorName(r.floor) });
}
findForm.addEventListener("submit", function (e) {
e.preventDefault();
var items = findResults.querySelectorAll("li[data-id]");
if (resultCursor > -1 && items[resultCursor]) {
pickResult(items[resultCursor].getAttribute("data-id"));
return;
}
var list = searchRooms(findInput.value);
if (list.length) {
pickResult(list[0].id);
} else {
toast("No matching space found");
}
});
findInput.addEventListener("keydown", function (e) {
var items = findResults.querySelectorAll("li[data-id]");
if (e.key === "ArrowDown" && items.length) {
e.preventDefault();
resultCursor = Math.min(resultCursor + 1, items.length - 1);
updateCursor(items);
} else if (e.key === "ArrowUp" && items.length) {
e.preventDefault();
resultCursor = Math.max(resultCursor - 1, 0);
updateCursor(items);
} else if (e.key === "Escape") {
hideResults();
}
});
function updateCursor(items) {
Array.prototype.forEach.call(items, function (li, i) {
li.classList.toggle("is-cursor", i === resultCursor);
});
}
document.addEventListener("click", function (e) {
if (!findForm.contains(e.target)) hideResults();
});
/* ---------------- Init ---------------- */
renderFloor("L1");
renderEmpty();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meridian Museum — Floor Map & Wayfinding</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=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="frame">
<header class="topbar">
<div class="brand">
<span class="crest" aria-hidden="true">◈</span>
<div>
<p class="kicker">Meridian Museum of Art</p>
<h1>Floor Map & Wayfinding</h1>
</div>
</div>
<form class="find" role="search" autocomplete="off">
<label class="visually-hidden" for="findInput">Find a gallery or amenity</label>
<span class="find-ic" aria-hidden="true">⌕</span>
<input id="findInput" type="text" placeholder="Find a gallery, restroom, café…" aria-describedby="findHint" />
<button type="submit" class="find-go">Find</button>
<ul id="findResults" class="find-results" role="listbox" aria-label="Search results" hidden></ul>
</form>
</header>
<p id="findHint" class="visually-hidden">Type a gallery or amenity name, then press Enter to highlight it on the map.</p>
<div class="layout">
<main class="map-pane">
<div class="map-tools">
<div class="tabs" role="tablist" aria-label="Select floor">
<button class="tab is-active" role="tab" aria-selected="true" data-floor="L1">Level 1</button>
<button class="tab" role="tab" aria-selected="false" data-floor="L2">Level 2</button>
<button class="tab" role="tab" aria-selected="false" data-floor="B">Lower B</button>
</div>
<p class="floor-meta" id="floorMeta">Main level · Entrance & Grand Galleries</p>
</div>
<div class="stage">
<svg id="floorSvg" viewBox="0 0 720 520" role="group" aria-label="Interactive floor plan" preserveAspectRatio="xMidYMid meet">
<defs>
<pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line x1="0" y1="0" x2="0" y2="8" stroke="rgba(28,27,25,0.06)" stroke-width="2" />
</pattern>
</defs>
<rect class="shell" x="14" y="14" width="692" height="492" rx="10" />
<rect class="shell-fill" x="14" y="14" width="692" height="492" rx="10" fill="url(#hatch)" />
<g id="roomsGroup"></g>
<g id="labelsGroup" class="labels"></g>
</svg>
<div class="compass" aria-hidden="true">
<span class="compass-n">N</span>
<span class="compass-arrow">↑</span>
</div>
</div>
<ul class="legend" aria-label="Map legend">
<li><span class="dot dot-gallery"></span> Gallery</li>
<li><span class="dot dot-amenity"></span> Amenity</li>
<li><span class="dot dot-access"></span> Entrance / Access</li>
<li><span class="dot dot-active"></span> Selected</li>
</ul>
</main>
<aside class="panel" aria-live="polite">
<div id="panelBody" class="panel-body">
<!-- populated by JS -->
</div>
</aside>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Floor Map / Wayfinding
A curatorial wayfinding panel for the fictional Meridian Museum of Art. The map itself is a single inline SVG floor plan — rooms are drawn as soft-cornered rectangles inside a hatched building shell, with a small compass and a legend distinguishing galleries, amenities, entrances and the currently selected space. Galleries carry a colour swatch and a catalogue-style room number; amenities such as restrooms, the information desk, the shop, the café, the cloakroom and the central lift are marked with quiet glyphs.
Three tabs switch between Level 1 (entrance and grand galleries), Level 2 (modern works and works on paper) and the lower level (antiquities, café and shop). Selecting a room — by click, tap or keyboard — highlights it on the plan and fills the side panel with its current exhibition: title, on-view dates and a status badge (On View, Opening Soon, Closed), plus a gradient plate, medium, scope, attribution and curator. Amenity rooms instead show a short location note and a routing action.
The header find field searches galleries and amenities by name, room number or exhibition title, showing a live result list you can drive with the arrow keys and Enter. Choosing a result jumps to the right floor if needed and pulses the room so it is easy to spot. Everything is vanilla HTML, CSS and JavaScript — refined serif display type, a quiet sans for UI, a small toast() helper, visible focus states and a layout that collapses gracefully down to ~360px.
Illustrative UI only — demo data; not a real museum system.