← back to Small Business Builder
src/render/corridor-map.js
417 lines
// /corridor/:slug/map — full-screen Leaflet map of one corridor with live
// geolocation. As the user drives the strip, the blue dot updates, the
// nearest-businesses panel re-ranks by haversine distance, and the next-up-
// ahead callouts swap in. Pins are color-coded:
// gold dot = state-licensed salon (BBCB)
// olive dot = unlicensed salon-category
// charcoal = non-salon business
// Brand-driven overlays (logo + name + address + neighborhood + license link)
// open in a side-panel on click. Reuses the same data-fetch the editorial
// /corridor/:slug page uses, so shipping a new corridor automatically gets
// a /corridor/:slug/map for free.
import { esc, escJsonLd } from '../lib/escape.js';
export function renderCorridorMap({ corridorTag = 'ventura-blvd-20mi', meta, businesses = [] } = {}) {
// Build a slim, JSON-safe payload for the client. We only ship what the
// map needs to render + the side-panel; full profiles live at /biz/:slug.
const payload = businesses
.filter(b => b.latitude != null && b.longitude != null)
.map(b => ({
slug: b.slug,
name: b.name,
cat: b.category || '',
addr: b.address || '',
hood: b.neighborhood || '',
city: b.city || '',
lat: Number(b.latitude),
lng: Number(b.longitude),
lic: b.source_data_json?.source === 'dca_bbcb' ? (b.source_data_json?.license_number || '') : '',
sole: b.source_data_json?.shop_personality === 'sole_prop' ? 1 : 0,
}));
// Editorial naming + accent. Falls back if meta missing.
const title = meta?.title || 'Corridor';
const accent = meta?.accent || '#5a6e3a';
const corridorPath = `/corridor/${corridorTag.replace(/-\d+mi$/, '')}`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>${esc(title)} · Drive Map · LA Salon Directory</title>
<meta name="description" content="Live map of ${esc(title)} — every state-licensed salon, restaurant, and storefront on the strip. Tracks your location as you drive.">
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<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:${accent};--gold:#b8945a;
--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{height:100%;background:var(--ink);color:var(--ink);font-family:var(--sans);overflow:hidden}
a{color:inherit;text-decoration:none}
/* TOP BAR ============================================================ */
.topbar{position:absolute;top:0;left:0;right:0;z-index:1000;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border-bottom:1px solid var(--rule)}
.brand{font-family:var(--serif);font-size:16px;font-weight:500;letter-spacing:-.005em}
.brand em{color:var(--accent);font-style:italic}
.brand a{color:var(--ink)}
.topbar .title{font-family:var(--serif);font-size:16px;color:var(--ink);font-style:italic;text-align:center;flex:1}
.topbar .title em{color:var(--accent);font-style:italic}
.topbar .actions{display:flex;gap:8px;align-items:center}
.btn{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);cursor:pointer;transition:all 150ms ease;border-radius:0}
.btn.is-on{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.btn.is-on:hover{color:var(--paper)}
/* MAP =============================================================== */
#map{position:absolute;top:50px;left:0;right:0;bottom:0;background:#222}
/* USER-LOCATION DOT (custom, pulses) ================================ */
.me-dot{width:18px;height:18px;border-radius:50%;background:#1a73e8;border:3px solid #fff;box-shadow:0 0 0 4px rgba(26,115,232,.25);animation:mePulse 2.4s ease-out infinite}
@keyframes mePulse{0%,100%{box-shadow:0 0 0 4px rgba(26,115,232,.18)}50%{box-shadow:0 0 0 14px rgba(26,115,232,.04)}}
/* PIN STYLES ======================================================== */
.pin{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35)}
.pin-licensed{background:var(--gold)}
.pin-salon{background:var(--accent)}
.pin-other{background:#3a3a3a}
/* NEAREST-LIST PANEL ================================================ */
.nearby{position:absolute;left:14px;bottom:14px;z-index:1000;width:min(360px,calc(100vw - 28px));max-height:42vh;overflow-y:auto;background:rgba(245,245,245,.96);backdrop-filter:blur(10px);border:1px solid var(--rule);box-shadow:0 12px 32px rgba(0,0,0,.18)}
.nearby-head{padding:14px 18px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:baseline;background:rgba(0,0,0,.02)}
.nearby-head h2{font-family:var(--serif);font-size:18px;font-weight:500;letter-spacing:-.005em}
.nearby-head .meta{font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute)}
.nearby-row{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px dotted var(--rule);transition:background 150ms ease;cursor:pointer}
.nearby-row:hover{background:#fff}
.nearby-row:last-child{border-bottom:none}
.n-info{flex:1;min-width:0}
.n-name{font-family:var(--serif);font-size:15px;font-weight:500;letter-spacing:-.005em;line-height:1.2;display:flex;align-items:center;gap:6px}
.n-name .lic-mark{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--gold)}
.n-where{font-family:var(--mono);font-size:9px;color:var(--mute);text-transform:uppercase;letter-spacing:.04em;line-height:1.4;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.n-dist{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--accent);white-space:nowrap}
/* SIDE PANEL ON PIN-CLICK =========================================== */
.detail{position:absolute;top:50px;right:0;bottom:0;z-index:1000;width:min(380px,90vw);background:var(--paper);border-left:1px solid var(--rule);box-shadow:-8px 0 24px rgba(0,0,0,.18);overflow-y:auto;transform:translateX(100%);transition:transform 280ms cubic-bezier(.2,.7,.3,1)}
.detail.open{transform:translateX(0)}
.d-close{position:absolute;top:14px;right:14px;width:32px;height:32px;border:1px solid var(--rule);background:#fff;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;font-family:var(--mono)}
.d-close:hover{border-color:var(--accent);color:var(--accent)}
.d-body{padding:32px 24px}
.d-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
.d-title{font-family:var(--serif);font-size:32px;font-weight:500;line-height:1.05;letter-spacing:-.01em;margin-bottom:16px}
.d-meta{font-family:var(--mono);font-size:10px;letter-spacing:.06em;color:var(--mute);text-transform:uppercase;line-height:1.7;margin-bottom:24px}
.d-meta .row{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px dotted var(--rule)}
.d-meta .row strong{color:var(--ink);font-weight:500}
.d-actions{display:flex;flex-direction:column;gap:8px;margin-top:18px}
.d-btn{display:block;padding:12px 16px;text-align:center;border:1px solid var(--rule);background:#fff;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink);transition:all 150ms ease}
.d-btn.primary{background:var(--accent);color:var(--paper);border-color:var(--accent)}
.d-btn:hover{border-color:var(--accent);color:var(--accent)}
.d-btn.primary:hover{background:var(--ink);border-color:var(--ink);color:var(--paper)}
@media (max-width:640px){
.topbar .title{display:none}
.nearby{max-height:35vh;width:calc(100vw - 28px)}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a href="/">LA <em>Salon</em></a></div>
<div class="title">${esc(title)} · <em>Drive Map</em></div>
<div class="actions">
<button class="btn" id="follow-toggle" type="button">📍 Follow</button>
<a class="btn" href="${esc(corridorPath)}">Read</a>
</div>
</header>
<div id="map"></div>
<aside class="nearby" id="nearby" aria-live="polite">
<div class="nearby-head">
<h2>Up ahead</h2>
<span class="meta" id="nearby-status">geolocate · 5 nearest</span>
</div>
<div id="nearby-list">
<div style="padding:14px 18px;font-family:var(--mono);font-size:11px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">Tap "Follow" to share your location.</div>
</div>
</aside>
<aside class="detail" id="detail" aria-hidden="true">
<button class="d-close" id="detail-close" type="button">×</button>
<div class="d-body" id="detail-body"></div>
</aside>
<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>
// =========================================================================
// Inline payload — all corridor businesses with lat/lng. ${payload.length} total.
// =========================================================================
const SHOPS = ${escJsonLd(JSON.stringify(payload))};
const ACCENT = ${JSON.stringify(accent)};
const CORRIDOR_TITLE = ${JSON.stringify(title)};
// Build a Leaflet map seeded on the median lat/lng of the corridor.
const lats = SHOPS.map(s => s.lat).sort((a,b) => a-b);
const lngs = SHOPS.map(s => s.lng).sort((a,b) => a-b);
const center = lats.length
? [lats[Math.floor(lats.length/2)], lngs[Math.floor(lngs.length/2)]]
: [34.15, -118.45];
const map = L.map('map', { zoomControl: true, attributionControl: true })
.setView(center, 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(map);
// Fit to all pins on first load — this is corridor-scoped so it's bounded.
if (SHOPS.length) {
map.fitBounds(SHOPS.map(s => [s.lat, s.lng]), { padding: [60, 60] });
}
// =========================================================================
// Pins — divIcon dots, color by license/category. Cluster for performance.
// =========================================================================
function pinFor(s) {
const cls = s.lic ? 'pin-licensed' : (['salon','barbershop','beard','nail','lash','brow','spa'].includes(s.cat) ? 'pin-salon' : 'pin-other');
return L.divIcon({
className: '',
html: '<div class="pin ' + cls + '" aria-label="' + (s.name || '').replace(/"/g, '"') + '"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
}
const cluster = L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
maxClusterRadius: 50,
});
const markers = [];
for (const s of SHOPS) {
const m = L.marker([s.lat, s.lng], { icon: pinFor(s) });
m.shop = s;
m.on('click', () => openDetail(s));
cluster.addLayer(m);
markers.push(m);
}
map.addLayer(cluster);
// =========================================================================
// Side-panel detail
// =========================================================================
const detailEl = document.getElementById('detail');
const detailBody = document.getElementById('detail-body');
function openDetail(s) {
const dcaUrl = s.lic ? 'https://search.dca.ca.gov/results?lic=' + encodeURIComponent(s.lic) : '';
// Apple Maps and Google Maps deep-links — both use ?daddr= or ?destination=
const apple = 'https://maps.apple.com/?daddr=' + s.lat + ',' + s.lng;
const google = 'https://www.google.com/maps/dir/?api=1&destination=' + s.lat + ',' + s.lng;
detailBody.innerHTML = ''
+ '<div class="d-eyebrow">' + escapeHtml(s.cat || 'business') + (s.lic ? ' · ◆ State-Licensed' : '') + (s.sole ? ' · Sole-Proprietor' : '') + '</div>'
+ '<h1 class="d-title">' + escapeHtml(s.name) + '</h1>'
+ '<div class="d-meta">'
+ (s.addr ? '<div class="row"><span>Address</span><strong>' + escapeHtml(s.addr) + '</strong></div>' : '')
+ (s.hood ? '<div class="row"><span>Neighborhood</span><strong>' + escapeHtml(s.hood) + '</strong></div>' : '')
+ (s.city ? '<div class="row"><span>City</span><strong>' + escapeHtml(s.city) + '</strong></div>' : '')
+ (s.lic ? '<div class="row"><span>License</span><strong>' + escapeHtml(s.lic) + '</strong></div>' : '')
+ '<div class="row"><span>Distance</span><strong id="d-distance">—</strong></div>'
+ '</div>'
+ '<div id="d-sources" style="margin:8px 0 0;font-family:var(--mono);font-size:10px;color:var(--mute);text-transform:uppercase;letter-spacing:.06em">loading community links…</div>'
+ '<div class="d-actions">'
+ '<a class="d-btn primary" href="/biz/' + encodeURIComponent(s.slug) + '">View Profile</a>'
+ '<a class="d-btn" href="' + apple + '" target="_blank" rel="noopener">Apple Maps · Drive Here</a>'
+ '<a class="d-btn" href="' + google + '" target="_blank" rel="noopener">Google Maps · Drive Here</a>'
+ (dcaUrl ? '<a class="d-btn" href="' + dcaUrl + '" target="_blank" rel="noopener">◆ Verify License (DCA)</a>' : '')
+ '<a class="d-btn" href="/biz/' + encodeURIComponent(s.slug) + '/links" style="margin-top:14px">+ Add Reddit / Discord / Yelp</a>'
+ '</div>';
// Async-fetch the combined panel payload (socials + articles + review-sources).
// Single round-trip; renders inline below the meta block.
fetch('/api/biz/' + encodeURIComponent(s.slug) + '/panel')
.then(r => r.json())
.then(j => {
const el = document.getElementById('d-sources');
if (!el || !j?.ok) { if (el) el.textContent = ''; return; }
const socials = j.socials || [];
const articles = j.articles || [];
const sources = j.review_sources || [];
const blocks = [];
// Website (if known)
if (j.website) {
blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Website</div>'
+ '<a href="' + escapeHtml(j.website) + '" target="_blank" rel="noopener" style="display:block;padding:8px 12px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:13px;font-family:var(--sans);margin-bottom:6px">' + escapeHtml(j.website) + ' ↗</a>');
}
// Socials row
if (socials.length) {
blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Socials</div>'
+ '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px">'
+ socials.map(soc =>
'<a href="' + escapeHtml(soc.url) + '" target="_blank" rel="noopener" style="padding:6px 10px;border:1px solid var(--rule);background:#fff;color:var(--ink);text-transform:none;letter-spacing:0;font-size:11px;font-family:var(--mono)">'
+ escapeHtml(soc.platform) + (soc.handle ? ' · @' + escapeHtml(soc.handle) : '') + ' ↗</a>'
).join('')
+ '</div>');
}
// Articles list
if (articles.length) {
blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Press · ' + articles.length + '</div>'
+ articles.map(a =>
'<a href="' + escapeHtml(a.url) + '" target="_blank" rel="noopener" style="display:block;padding:9px 12px;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans);line-height:1.35">'
+ '<div style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:2px">' + escapeHtml(a.publisher || 'press') + '</div>'
+ '<div style="font-family:var(--serif);font-size:14px;font-weight:500">' + escapeHtml(a.title || a.url) + '</div>'
+ '</a>'
).join(''));
}
// Community sources
if (sources.length) {
blocks.push('<div style="margin:18px 0 8px;color:var(--accent);font-weight:600;font-size:11px;letter-spacing:.18em;text-transform:uppercase;font-family:var(--mono)">Community</div>'
+ sources.map(src =>
'<a href="' + escapeHtml(src.url) + '" target="_blank" rel="noopener" style="display:block;padding:8px 0;border-bottom:1px dotted var(--rule);color:var(--ink);text-transform:none;letter-spacing:0;font-size:12px;font-family:var(--sans)">'
+ '<span style="font-family:var(--mono);font-size:9px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-right:8px">' + escapeHtml(src.source_type) + '</span>'
+ escapeHtml(src.label || src.url) + '</a>'
).join(''));
}
el.innerHTML = blocks.join('');
})
.catch(() => { const el = document.getElementById('d-sources'); if (el) el.textContent = ''; });
detailEl.classList.add('open');
detailEl.setAttribute('aria-hidden', 'false');
// If we have a location, fill in the distance now.
if (currentPos) {
document.getElementById('d-distance').textContent = formatDist(haversineMi(currentPos.lat, currentPos.lng, s.lat, s.lng));
}
// Pan map to the shop with a small offset so the panel doesn't cover it.
map.setView([s.lat, s.lng], Math.max(map.getZoom(), 16));
}
document.getElementById('detail-close').addEventListener('click', () => {
detailEl.classList.remove('open');
detailEl.setAttribute('aria-hidden', 'true');
});
// =========================================================================
// Geolocation — watchPosition, blue-dot + auto-recenter when "Follow" is on.
// =========================================================================
let currentPos = null;
let meMarker = null;
let follow = false;
let watchId = null;
const followBtn = document.getElementById('follow-toggle');
followBtn.addEventListener('click', () => {
if (!navigator.geolocation) {
setStatus('geolocation unsupported');
return;
}
if (!follow) startFollow(); else stopFollow();
});
function startFollow() {
follow = true;
followBtn.classList.add('is-on');
followBtn.textContent = '📍 Following';
setStatus('locating…');
watchId = navigator.geolocation.watchPosition(onPos, onPosErr, {
enableHighAccuracy: true,
maximumAge: 5000,
timeout: 15000,
});
}
function stopFollow() {
follow = false;
followBtn.classList.remove('is-on');
followBtn.textContent = '📍 Follow';
setStatus('paused · 5 nearest');
if (watchId != null) navigator.geolocation.clearWatch(watchId);
watchId = null;
}
function onPos(p) {
const lat = p.coords.latitude, lng = p.coords.longitude;
currentPos = { lat, lng };
if (!meMarker) {
meMarker = L.marker([lat, lng], {
icon: L.divIcon({ className:'', html:'<div class="me-dot"></div>', iconSize:[18,18], iconAnchor:[9,9] }),
zIndexOffset: 1000,
interactive: false,
}).addTo(map);
} else {
meMarker.setLatLng([lat, lng]);
}
if (follow) map.setView([lat, lng], Math.max(map.getZoom(), 15));
refreshNearby(lat, lng);
}
function onPosErr(e) {
setStatus('location ' + (e.code === 1 ? 'denied' : 'unavailable'));
stopFollow();
}
// =========================================================================
// Nearby panel — sort by haversine, render top 5.
// =========================================================================
function haversineMi(lat1, lng1, lat2, lng2) {
const R = 3958.8; // earth radius in miles
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLng/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
function formatDist(mi) {
if (mi < 0.1) return Math.round(mi * 5280) + ' ft';
if (mi < 10) return mi.toFixed(2) + ' mi';
return mi.toFixed(1) + ' mi';
}
function setStatus(t) { document.getElementById('nearby-status').textContent = t; }
function refreshNearby(lat, lng) {
const ranked = SHOPS
.map(s => ({ ...s, _d: haversineMi(lat, lng, s.lat, s.lng) }))
.sort((a, b) => a._d - b._d)
.slice(0, 5);
const list = document.getElementById('nearby-list');
list.innerHTML = ranked.map(s =>
'<div class="nearby-row" data-slug="' + escapeHtml(s.slug) + '">'
+ '<div class="n-info">'
+ '<div class="n-name">' + (s.lic ? '<span class="lic-mark" title="state-licensed"></span>' : '') + escapeHtml(s.name) + '</div>'
+ '<div class="n-where">' + escapeHtml(s.cat) + (s.hood ? ' · ' + escapeHtml(s.hood) : '') + '</div>'
+ '</div>'
+ '<div class="n-dist">' + formatDist(s._d) + '</div>'
+ '</div>'
).join('');
setStatus(CORRIDOR_TITLE.split(' ')[0] + ' · 5 nearest');
// Wire each row to open the side-panel for that shop.
Array.from(list.querySelectorAll('.nearby-row')).forEach(row => {
row.addEventListener('click', () => {
const slug = row.getAttribute('data-slug');
const s = SHOPS.find(x => x.slug === slug);
if (s) openDetail(s);
});
});
// If detail panel is open, update its distance row live.
const dEl = document.getElementById('d-distance');
if (dEl) {
// Find which shop is currently shown and recompute.
const open = SHOPS.find(s => detailBody.querySelector('.d-title')?.textContent === s.name);
if (open) dEl.textContent = formatDist(haversineMi(lat, lng, open.lat, open.lng));
}
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
</script>
</body>
</html>`;
}