← back to Small Business Builder
src/render/map.js
547 lines
// /map — The Whole Map. All geocoded businesses on a Leaflet + MarkerCluster canvas.
// Renderer only: no DB calls here. Server passes pre-computed stats.
// Client fetches /api/map.json and builds all markers in-browser.
import { esc } from '../lib/escape.js';
export function renderMap({ stats = {} } = {}) {
const geocoded = stats.geocoded || 0;
const licensed = stats.licensed || 0;
const cities = stats.cities || 0;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>The Whole Map · LA Salon & Business Directory</title>
<meta name="description" content="Every geocoded shop in the LA Salon Directory — salons, barbers, spas, restaurants, and more — pinned on a single map.">
<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@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" crossorigin>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" crossorigin>
<style>
:root{
--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;
--accent:#5a6e3a;--gold:#b8945a;
--sidebar:240px;
--serif:"Cormorant Garamond",Georgia,serif;
--sans:"Inter",-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
--mono:ui-monospace,"SF Mono",Menlo,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-weight:400;-webkit-font-smoothing:antialiased;height:100%}
a{color:inherit;text-decoration:none}
/* ── TOPBAR ─────────────────────────────────────────── */
.topbar{
padding:16px 28px;display:flex;align-items:center;
justify-content:space-between;border-bottom:1px solid var(--rule);
position:sticky;top:0;background:var(--paper);z-index:500;
}
.brand{font-family:var(--serif);font-size:20px;letter-spacing:-.01em;font-weight:500}
.brand em{font-style:italic;color:var(--accent)}
.brand a{color:inherit}
.topbar-crumb{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);margin-left:12px}
/* ── HERO STRIP ─────────────────────────────────────── */
.hero-strip{
padding:32px 28px 24px;border-bottom:1px solid var(--rule);
background:var(--paper);
}
.hero-eyebrow{
font-family:var(--mono);font-size:10px;letter-spacing:.22em;
text-transform:uppercase;color:var(--accent);margin-bottom:10px;
}
.hero-strip h1{
font-family:var(--serif);font-weight:500;
font-size:clamp(32px,5vw,64px);line-height:.97;
letter-spacing:-.02em;margin-bottom:6px;
}
.hero-strip h1 em{font-style:italic;color:var(--accent)}
.hero-sub{
font-family:var(--serif);font-style:italic;
font-size:clamp(15px,2vw,20px);color:var(--mute);
margin-bottom:12px;
}
.hero-stats{
display:flex;gap:32px;font-family:var(--mono);
font-size:10px;letter-spacing:.16em;text-transform:uppercase;
color:var(--mute);flex-wrap:wrap;
}
.hero-stats b{
display:inline;font-family:var(--serif);font-style:italic;
font-size:18px;letter-spacing:-.01em;color:var(--ink);
font-weight:500;text-transform:none;margin-right:4px;
}
/* ── MAP LAYOUT ─────────────────────────────────────── */
.map-shell{
display:flex;
height:calc(100vh - var(--topbar-h, 56px) - var(--hero-h, 100px));
min-height:80vh;
position:relative;
}
/* ── SIDEBAR ─────────────────────────────────────────── */
.sidebar{
width:var(--sidebar);flex-shrink:0;
border-right:1px solid var(--rule);
background:var(--paper);
overflow-y:auto;padding:20px 16px;
z-index:400;
}
.filter-label{
font-family:var(--mono);font-size:9px;letter-spacing:.22em;
text-transform:uppercase;color:var(--accent);
margin:0 0 6px;display:block;
}
.filter-group{margin-bottom:20px}
.sidebar select,.sidebar input[type=text]{
width:100%;padding:8px 10px;
border:1px solid var(--rule);background:#fff;
font-family:var(--sans);font-size:13px;color:var(--ink);
border-radius:2px;outline:none;
transition:border-color 150ms;
}
.sidebar select:focus,.sidebar input[type=text]:focus{border-color:var(--accent)}
.sidebar select[multiple]{height:140px}
.toggle-label{
display:flex;align-items:center;gap:8px;cursor:pointer;
font-family:var(--sans);font-size:13px;color:var(--ink);
}
.toggle-label input{accent-color:var(--accent);width:15px;height:15px;cursor:pointer}
.sidebar-count{
font-family:var(--mono);font-size:10px;letter-spacing:.12em;
color:var(--mute);text-transform:uppercase;margin-top:6px;
}
.filter-reset{
margin-top:4px;font-family:var(--mono);font-size:9px;
letter-spacing:.16em;text-transform:uppercase;color:var(--mute);
background:none;border:none;cursor:pointer;padding:0;
}
.filter-reset:hover{color:var(--ink)}
/* Sidebar collapse on mobile */
@media (max-width:740px){
.sidebar{
width:100%;border-right:none;border-bottom:1px solid var(--rule);
padding:12px 16px;
}
.map-shell{flex-direction:column}
.sidebar-filters{display:none}
.sidebar-filters.open{display:block}
.sidebar-toggle{
display:flex;align-items:center;justify-content:space-between;
cursor:pointer;
}
.sidebar-toggle-label{
font-family:var(--mono);font-size:10px;letter-spacing:.18em;
text-transform:uppercase;color:var(--accent);
}
.sidebar-toggle-icon{font-size:18px;color:var(--mute)}
}
@media (min-width:741px){
.sidebar-toggle{display:none}
.sidebar-filters{display:block}
}
/* ── MAP ──────────────────────────────────────────────── */
#map{flex:1;min-width:0;z-index:100}
/* ── BOTTOM SHEET ────────────────────────────────────── */
#sheet{
position:fixed;bottom:-400px;left:50%;transform:translateX(-50%);
width:min(520px,calc(100vw - 40px));
background:var(--paper);border-top:3px solid var(--accent);
border-radius:4px 4px 0 0;box-shadow:0 -4px 32px rgba(0,0,0,.14);
z-index:600;padding:24px 24px 28px;
transition:bottom 300ms cubic-bezier(.3,0,.2,1);
}
#sheet.open{bottom:0}
#sheet-close{
position:absolute;top:12px;right:16px;
background:none;border:none;cursor:pointer;
font-size:20px;color:var(--mute);line-height:1;
}
#sheet-close:hover{color:var(--ink)}
#sheet-cat{
font-family:var(--mono);font-size:9px;letter-spacing:.22em;
text-transform:uppercase;color:var(--accent);margin-bottom:6px;
}
#sheet-name{
font-family:var(--serif);font-size:clamp(22px,4vw,32px);
font-weight:500;letter-spacing:-.01em;line-height:1.1;margin-bottom:6px;
}
#sheet-addr{
font-family:var(--mono);font-size:10px;letter-spacing:.08em;
color:var(--mute);text-transform:uppercase;margin-bottom:14px;
}
.sheet-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}
.sheet-btn{
display:inline-block;padding:10px 16px;
font-family:var(--mono);font-size:10px;letter-spacing:.18em;
text-transform:uppercase;border:1px solid var(--ink);
background:none;color:var(--ink);cursor:pointer;
transition:background 150ms,color 150ms;text-decoration:none;
}
.sheet-btn:hover,.sheet-btn:focus{background:var(--ink);color:var(--paper)}
.sheet-btn.gold{border-color:var(--gold);color:var(--gold)}
.sheet-btn.gold:hover{background:var(--gold);color:var(--ink)}
/* ── LOADING OVERLAY ─────────────────────────────────── */
#loading{
position:absolute;inset:0;display:flex;align-items:center;
justify-content:center;background:rgba(245,245,245,.85);
z-index:300;font-family:var(--mono);font-size:11px;
letter-spacing:.2em;text-transform:uppercase;color:var(--mute);
}
/* ── CUSTOM PINS ─────────────────────────────────────── */
.pin-dot{
width:10px;height:10px;border-radius:50%;
border:2px solid rgba(255,255,255,.7);
box-shadow:0 1px 4px rgba(0,0,0,.35);
}
.pin-gold .pin-dot{background:var(--gold)}
.pin-olive .pin-dot{background:var(--accent)}
.pin-ink .pin-dot{background:var(--ink)}
/* ── LEAFLET CLUSTER OVERRIDE ────────────────────────── */
.marker-cluster-small,.marker-cluster-medium,.marker-cluster-large{
background:rgba(90,110,58,.2)!important;
}
.marker-cluster-small div,.marker-cluster-medium div,.marker-cluster-large div{
background:rgba(90,110,58,.7)!important;
color:#fff!important;font-family:var(--mono)!important;
font-size:11px!important;font-weight:600!important;
}
</style>
</head>
<body>
<header class="topbar" id="topbar">
<div class="brand">
<a href="/">LA <em>Salon</em> Directory</a>
<span class="topbar-crumb">/ The Whole Map</span>
</div>
</header>
<div class="hero-strip" id="hero-strip">
<div class="hero-eyebrow">Full Firehose · All Counties</div>
<h1>The Whole <em>Map.</em></h1>
<p class="hero-sub">Every shop. Every block. Pinned.</p>
<div class="hero-stats">
<div><b>${esc(String(geocoded.toLocaleString()))}</b> geocoded</div>
<div><b>${esc(String(licensed.toLocaleString()))}</b> state-licensed</div>
<div><b>${esc(String(cities.toLocaleString()))}</b> cities</div>
</div>
</div>
<div class="map-shell" id="map-shell">
<!-- SIDEBAR -->
<aside class="sidebar" id="sidebar">
<!-- Mobile toggle header -->
<div class="sidebar-toggle" id="sidebar-toggle" onclick="toggleSidebar()">
<span class="sidebar-toggle-label">Filter</span>
<span class="sidebar-toggle-icon" id="toggle-icon">+</span>
</div>
<div class="sidebar-filters" id="sidebar-filters">
<div class="filter-group" style="margin-top:12px">
<label class="toggle-label">
<input type="checkbox" id="filter-licensed">
State-licensed only
</label>
</div>
<div class="filter-group">
<span class="filter-label">Category</span>
<select id="filter-cat" multiple title="Hold Ctrl/Cmd to select multiple">
<option value="">All categories</option>
<option value="salon">Salon / Hair</option>
<option value="barbershop">Barber & Beard</option>
<option value="beard">Beard</option>
<option value="spa">Spa</option>
<option value="nail">Nails</option>
<option value="nail_salon">Nail Salon</option>
<option value="lash">Lash</option>
<option value="brow">Brow</option>
<option value="restaurant">Restaurant</option>
<option value="cafe">Café</option>
<option value="retail">Retail</option>
<option value="health">Health</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">City</span>
<select id="filter-city">
<option value="">All cities</option>
</select>
</div>
<div class="filter-group">
<span class="filter-label">Search name</span>
<input type="text" id="filter-name" placeholder="Fuzzy match…" autocomplete="off" spellcheck="false">
</div>
<div class="sidebar-count" id="sidebar-count">Loading…</div>
<button class="filter-reset" id="filter-reset" onclick="resetFilters()">Reset filters</button>
</div>
</aside>
<!-- MAP CANVAS -->
<div id="map">
<div id="loading">Loading pins…</div>
</div>
</div>
<!-- BOTTOM SHEET -->
<div id="sheet" role="dialog" aria-modal="true" aria-label="Business details">
<button id="sheet-close" aria-label="Close" onclick="closeSheet()">×</button>
<div id="sheet-cat"></div>
<div id="sheet-name"></div>
<div id="sheet-addr"></div>
<div class="sheet-actions" id="sheet-actions"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js" crossorigin></script>
<script>
(function(){
'use strict';
// ── Map init ──────────────────────────────────────────────
function resizeShell(){
var tb = document.getElementById('topbar');
var hs = document.getElementById('hero-strip');
var sh = document.getElementById('map-shell');
var tbH = tb ? tb.offsetHeight : 56;
var hsH = hs ? hs.offsetHeight : 100;
var avail = window.innerHeight - tbH - hsH;
sh.style.height = Math.max(avail, 400) + 'px';
}
resizeShell();
window.addEventListener('resize', resizeShell);
var map = L.map('map', {
center: [34.05, -118.25],
zoom: 10,
zoomControl: true,
attributionControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
// ── State ─────────────────────────────────────────────────
var allData = [];
var clusters = L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 60 });
map.addLayer(clusters);
// ── Pin factory ───────────────────────────────────────────
var SALON_CATS = new Set(['salon','barbershop','beard','hair','nail','nail_salon','lash','brow','spa','blowout','waxing']);
function pinClass(row){
if (row.lic) return 'pin-gold';
if (SALON_CATS.has(row.category)) return 'pin-olive';
return 'pin-ink';
}
function makeIcon(cls){
return L.divIcon({
className: cls,
html: '<div class="pin-dot"></div>',
iconSize: [10,10],
iconAnchor: [5,5],
});
}
// ── Sheet ─────────────────────────────────────────────────
var sheet = document.getElementById('sheet');
function openSheet(row){
var catEl = document.getElementById('sheet-cat');
var nameEl = document.getElementById('sheet-name');
var addrEl = document.getElementById('sheet-addr');
var actsEl = document.getElementById('sheet-actions');
catEl.textContent = row.category || '';
nameEl.textContent = row.name || '';
addrEl.textContent = [row.address, row.city].filter(Boolean).join(' · ');
// Clear previous action buttons
while (actsEl.firstChild) actsEl.removeChild(actsEl.firstChild);
if (row.slug) {
var profBtn = document.createElement('a');
profBtn.href = '/biz/' + encodeURIComponent(row.slug);
profBtn.className = 'sheet-btn';
profBtn.textContent = 'View profile →';
actsEl.appendChild(profBtn);
}
if (row.lic) {
var licBtn = document.createElement('a');
licBtn.href = 'https://search.dca.ca.gov/results?lic=' + encodeURIComponent(row.lic);
licBtn.target = '_blank';
licBtn.rel = 'noopener noreferrer';
licBtn.className = 'sheet-btn gold';
licBtn.textContent = 'Verify license →';
actsEl.appendChild(licBtn);
}
if (row.phone) {
var phBtn = document.createElement('a');
phBtn.href = 'tel:' + row.phone;
phBtn.className = 'sheet-btn';
phBtn.textContent = row.phone;
actsEl.appendChild(phBtn);
}
sheet.classList.add('open');
}
function closeSheet(){ sheet.classList.remove('open'); }
window.closeSheet = closeSheet;
document.addEventListener('keydown', function(e){
if (e.key === 'Escape') closeSheet();
});
// ── Filtering ─────────────────────────────────────────────
var filterLicensed = document.getElementById('filter-licensed');
var filterCat = document.getElementById('filter-cat');
var filterCity = document.getElementById('filter-city');
var filterName = document.getElementById('filter-name');
var sidebarCount = document.getElementById('sidebar-count');
function getSelectedCats(){
var sel = [];
for (var i = 0; i < filterCat.options.length; i++){
if (filterCat.options[i].selected && filterCat.options[i].value)
sel.push(filterCat.options[i].value);
}
return sel;
}
function fuzzy(haystack, needle){
if (!needle) return true;
var h = haystack.toLowerCase();
var n = needle.toLowerCase();
var hi = 0;
for (var ni = 0; ni < n.length; ni++){
hi = h.indexOf(n[ni], hi);
if (hi === -1) return false;
hi++;
}
return true;
}
var renderTimer = null;
function scheduleRender(){
clearTimeout(renderTimer);
renderTimer = setTimeout(renderMarkers, 80);
}
function renderMarkers(){
clusters.clearLayers();
var onlyLic = filterLicensed.checked;
var selCats = getSelectedCats();
var selCity = filterCity.value;
var nameQ = filterName.value.trim();
var count = 0;
var markers = [];
for (var i = 0; i < allData.length; i++){
var row = allData[i];
if (onlyLic && !row.lic) continue;
if (selCats.length && selCats.indexOf(row.category) === -1) continue;
if (selCity && row.city !== selCity) continue;
if (nameQ && !fuzzy(row.name, nameQ)) continue;
var m = L.marker([row.lat, row.lng], { icon: makeIcon(pinClass(row)) });
(function(r){ m.on('click', function(){ openSheet(r); }); })(row);
markers.push(m);
count++;
}
clusters.addLayers(markers);
sidebarCount.textContent = count.toLocaleString() + ' pin' + (count !== 1 ? 's' : '') + ' shown';
}
function resetFilters(){
filterLicensed.checked = false;
for (var i = 0; i < filterCat.options.length; i++) filterCat.options[i].selected = false;
filterCity.value = '';
filterName.value = '';
renderMarkers();
}
window.resetFilters = resetFilters;
filterLicensed.addEventListener('change', scheduleRender);
filterCat.addEventListener('change', scheduleRender);
filterCity.addEventListener('change', scheduleRender);
filterName.addEventListener('input', scheduleRender);
// ── Load data ─────────────────────────────────────────────
fetch('/api/map.json')
.then(function(r){ return r.json(); })
.then(function(data){
allData = data;
// Populate city dropdown (dedupe + sort by frequency)
var cityFreq = {};
for (var i = 0; i < data.length; i++){
var c = data[i].city;
if (c) cityFreq[c] = (cityFreq[c] || 0) + 1;
}
var cities = Object.keys(cityFreq).sort(function(a,b){ return cityFreq[b] - cityFreq[a]; }).slice(0, 30);
var sel = filterCity;
for (var ci = 0; ci < cities.length; ci++){
var opt = document.createElement('option');
opt.value = cities[ci];
opt.textContent = cities[ci] + ' (' + cityFreq[cities[ci]] + ')';
sel.appendChild(opt);
}
// Remove loading overlay
var loadEl = document.getElementById('loading');
if (loadEl) loadEl.parentNode.removeChild(loadEl);
renderMarkers();
})
.catch(function(e){
var loadEl = document.getElementById('loading');
if (loadEl) loadEl.textContent = 'Error loading data — try refreshing.';
console.error('map.json fetch failed', e);
});
// ── Mobile sidebar toggle ─────────────────────────────────
function toggleSidebar(){
var f = document.getElementById('sidebar-filters');
var icon = document.getElementById('toggle-icon');
if (f.classList.contains('open')){
f.classList.remove('open');
icon.textContent = '+';
} else {
f.classList.add('open');
icon.textContent = '−';
}
}
window.toggleSidebar = toggleSidebar;
})();
</script>
</body>
</html>`;
}