← 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 &amp; 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 &amp; 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: '&copy; <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>`;
}