/* * Public "physical escape rooms" map + list. * * One JSON fetch (website_escaperooms_data) feeds both the Leaflet map (with * marker clustering, so a worldwide dataset stays responsive) and the searchable * table underneath it. */ import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import 'leaflet.markercluster'; import 'leaflet.markercluster/dist/MarkerCluster.css'; import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; import { DataTable } from 'simple-datatables'; import 'simple-datatables/dist/style.css'; import iconUrl from 'leaflet/dist/images/marker-icon.png'; import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; // Webpack rewrites the image paths, so hand them to Leaflet explicitly. delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl, iconRetinaUrl, shadowUrl }); function slugify(name) { return (name || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') || 'room'; } function escapeHtml(value) { const div = document.createElement('div'); div.textContent = value == null ? '' : String(value); return div.innerHTML; } function ratingLabel(avg, count) { if (!count) { return '—'; } return `${avg.toFixed(1)} ★ (${count})`; } function detailUrl(template, room) { return template.replace('__ID__', room.id).replace('__SLUG__', slugify(room.name)); } function buildMap(el, rooms, detailTemplate) { const map = L.map(el, { worldCopyJump: true }).setView([20, 0], 2); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); const cluster = L.markerClusterGroup({ chunkedLoading: true }); const bounds = []; rooms.forEach((room) => { if (typeof room.lat !== 'number' || typeof room.lng !== 'number') { return; } const where = [room.city, room.country].filter(Boolean).join(', '); const popup = ` ${escapeHtml(room.name)}
${where ? `${escapeHtml(where)}
` : ''} ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))}
View & review » `; const marker = L.marker([room.lat, room.lng]).bindPopup(popup); cluster.addLayer(marker); bounds.push([room.lat, room.lng]); }); map.addLayer(cluster); if (bounds.length) { map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 }); } } function buildList(table, rooms, detailTemplate) { const tbody = table.querySelector('tbody'); const frag = document.createDocumentFragment(); rooms.forEach((room) => { const tr = document.createElement('tr'); const where = room.city ? escapeHtml(room.city) : ''; const country = room.country ? escapeHtml(room.country) : ''; const ratingValue = room.reviewCount ? room.avgRating : 0; tr.innerHTML = ` ${escapeHtml(room.name)} ${where} ${country} ${escapeHtml(ratingLabel(room.avgRating, room.reviewCount))} `; frag.appendChild(tr); }); tbody.replaceChildren(frag); new DataTable(table, { perPage: 25, perPageSelect: [25, 50, 100], searchable: true, sortable: true, labels: { placeholder: 'Search rooms…', perPage: '{select} per page', noRows: 'No matching rooms', info: 'Showing {start}–{end} of {rows} rooms', }, }); } document.addEventListener('DOMContentLoaded', () => { const mapEl = document.getElementById('escaperoom-map'); if (!mapEl) { return; } const endpoint = mapEl.dataset.endpoint; const detailTemplate = mapEl.dataset.detailTemplate; const listEl = document.getElementById('escaperoom-list'); const statusEl = document.getElementById('escaperoom-status'); fetch(endpoint, { headers: { Accept: 'application/json' } }) .then((res) => { if (!res.ok) { throw new Error(`HTTP ${res.status}`); } return res.json(); }) .then((payload) => { const rooms = Array.isArray(payload.rooms) ? payload.rooms : []; buildMap(mapEl, rooms, detailTemplate); if (listEl) { buildList(listEl, rooms, detailTemplate); } if (statusEl) { statusEl.textContent = `${rooms.length.toLocaleString()} rooms on the map`; } }) .catch((err) => { if (statusEl) { statusEl.textContent = 'Could not load escape rooms right now.'; } console.error('escaperoom-map:', err); }); });