← back to Ventura Corridor
public/pantone.html
329 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Boulevard · pantone</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<style>
:root {
--noir: #0a0a0c; --noir-rise: #131316; --ink: #f0ece2;
--ink-mute: #888475; --metal: #b89968; --metal-glow: #d4b683;
--rule: #2a2622;
--serif: 'Cormorant Garamond', 'Times New Roman', serif;
--sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--mono: 'JetBrains Mono', ui-monospace, monospace;
}
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400&family=Inter:wght@200;300;400;600&display=swap');
html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
* { box-sizing: border-box; }
header {
position: sticky; top: 0; z-index: 10;
background: linear-gradient(180deg, rgba(10,10,12,.97) 70%, rgba(10,10,12,0));
padding: 22px 40px 16px; border-bottom: 1px solid var(--rule);
display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap;
}
header h1 { font-family: var(--serif); font-weight: 400; font-size: 28px; letter-spacing: -0.01em; margin: 0; }
header h1 em { font-style: italic; color: var(--metal); }
.sub { font-size: 11px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 4px; }
nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
nav.tabs a { font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink-mute); text-decoration: none; padding: 6px 12px; border: 1px solid var(--rule); }
nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }
.hero {
padding: 50px 40px 36px; text-align: center;
background: radial-gradient(ellipse at center top, rgba(184,153,104,0.08), transparent 70%);
border-bottom: 1px solid var(--rule);
}
.hero h2 { font-family: var(--serif); font-weight: 300; font-style: italic; font-size: 44px; letter-spacing: -0.02em; margin: 0 0 10px; }
.hero h2 em { color: var(--metal); font-style: normal; }
.hero p { color: var(--ink-mute); max-width: 640px; margin: 0 auto; font-family: var(--serif); font-style: italic; font-size: 15px; line-height: 1.5; }
.stats { padding: 14px 40px; border-bottom: 1px solid var(--rule); display: flex; gap: 40px; flex-wrap: wrap; font-size: 11px; letter-spacing: .12em; color: var(--ink-mute); text-transform: uppercase; }
.stats b { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 18px; font-weight: 400; letter-spacing: 0; margin-right: 8px; font-variant-numeric: tabular-nums; }
/* Mile rule — left rail */
.reel { position: relative; padding: 30px 0 80px; }
.miles-rail {
position: absolute; left: 24px; top: 30px; bottom: 80px; width: 1px; background: var(--rule);
}
.mile-tick {
position: absolute; left: 0; transform: translate(-50%, -50%);
width: 8px; height: 8px; background: var(--noir); border: 1px solid var(--metal);
}
.mile-tick-label {
position: absolute; left: 18px; transform: translateY(-50%);
font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--metal);
font-family: var(--mono); font-variant-numeric: tabular-nums;
}
/* Grid of chips */
.grid {
margin-left: 64px; padding-right: 24px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1px; background: var(--rule); border: 1px solid var(--rule);
}
.chip {
background: var(--noir-rise); position: relative; aspect-ratio: 4 / 3;
overflow: hidden; cursor: pointer;
transition: transform 200ms ease;
}
.chip:hover { transform: scale(1.05); z-index: 2; box-shadow: 0 4px 24px rgba(0,0,0,0.6); }
.chip .swatch {
position: absolute; inset: 0;
transition: opacity 600ms ease; opacity: 0;
}
.chip.painted .swatch { opacity: 1; }
.chip .label {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 8px 10px;
background: linear-gradient(180deg, transparent 0%, rgba(10,10,12,0.92) 70%);
color: var(--ink);
font-size: 11px; line-height: 1.25; letter-spacing: .04em;
overflow: hidden; text-overflow: ellipsis;
transform: translateY(100%); transition: transform 200ms ease;
}
.chip:hover .label { transform: translateY(0); }
.chip .label .nm { font-weight: 400; }
.chip .label .mt { font-size: 9px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; font-family: var(--mono); }
.chip .hex {
position: absolute; top: 6px; left: 8px;
font-family: var(--mono); font-size: 8px; letter-spacing: .15em;
color: var(--ink); text-transform: uppercase;
background: rgba(10,10,12,0.6); padding: 2px 5px;
opacity: 0; transition: opacity 200ms ease;
}
.chip:hover .hex { opacity: 1; }
.chip .miletag {
position: absolute; top: 6px; right: 8px;
font-family: var(--serif); font-style: italic; font-size: 14px;
color: rgba(255,255,255,0.85); text-shadow: 0 1px 2px rgba(0,0,0,0.6);
font-variant-numeric: tabular-nums;
opacity: 0; transition: opacity 200ms ease;
}
.chip:hover .miletag { opacity: 1; }
/* Anchor businesses (DW) */
.chip.steve { outline: 2px solid var(--metal); outline-offset: -2px; z-index: 1; }
.chip.steve::before {
content: 'YOU'; position: absolute; top: 6px; left: 8px;
font-size: 8px; letter-spacing: .26em; padding: 2px 6px;
background: var(--metal); color: var(--noir); font-weight: 600;
z-index: 2;
}
footer {
padding: 24px 40px 40px; border-top: 1px solid var(--rule);
font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; text-align: center;
}
.empty { padding: 80px 32px; text-align: center; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 18px; }
</style>
</head>
<body>
<header>
<div>
<h1>Ventura <em>Boulevard</em> · pantone</h1>
<div class="sub">the dominant color of every corridor website, sorted by mile</div>
</div>
<nav class="tabs" aria-label="Surface navigation">
<a href="/">2D map</a>
<a href="/3d.html">3D corridor</a>
<a href="/timeline.html">Timeline</a>
<a href="/pantone.html" class="active" aria-current="page">Pantone</a>
<a href="/headlines.html">Voices</a>
<a href="/tongues.html">Tongues</a>
<a href="/chains.html">Chains</a>
<a href="/atlas.html">Atlas</a>
<a href="/eulogy.html">Eulogy</a>
<span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
</nav>
</header>
<section class="hero">
<h2>The boulevard <em>has a color</em>.</h2>
<p>Each square is one corridor business. The hue is sampled from their actual website. Read west-to-east — Calabasas to Studio City — and you can see when the corridor goes warm, when it goes white, when it goes neon.</p>
</section>
<div class="stats" id="stats">
<span><b id="s-total">—</b> sites painted</span>
<span><b id="s-pending">—</b> still loading</span>
<span><b id="s-miles">—</b> miles of corridor</span>
</div>
<section class="reel">
<div class="miles-rail" id="miles-rail"></div>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" style="display:none">No painted sites yet.</div>
</section>
<footer>Color sampled from the live front-page screenshot of each site · 16×16 average · grouped by projected position along the Ventura Boulevard centerline polyline.</footer>
<script>
const DW_ID = 17463;
// ---------- mile-marker projection ----------
function haversine(a, b) {
const R = 3958.7613; // miles
const toRad = (x) => x * Math.PI / 180;
const dLat = toRad(b[0] - a[0]);
const dLng = toRad(b[1] - a[1]);
const lat1 = toRad(a[0]), lat2 = toRad(b[0]);
const x = Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
return 2 * R * Math.asin(Math.sqrt(x));
}
// Local equirectangular projection — accurate enough at corridor scale
function project(lat, lng, refLat) {
const cos = Math.cos(refLat * Math.PI / 180);
return [lat * 110.574, lng * 111.320 * cos]; // km in y, x
}
function buildCumMiles(points) {
const cum = [0];
for (let i = 1; i < points.length; i++) {
cum.push(cum[i - 1] + haversine(points[i - 1], points[i]));
}
return cum;
}
function nearestMile(lat, lng, polyline, cumMiles) {
const refLat = polyline[0][0];
const [py, px] = project(lat, lng, refLat);
let bestDist = Infinity, bestMile = 0;
for (let i = 0; i < polyline.length - 1; i++) {
const [ay, ax] = project(polyline[i][0], polyline[i][1], refLat);
const [by, bx] = project(polyline[i + 1][0], polyline[i + 1][1], refLat);
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
let t = len2 === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const cy = ay + t * dy, cx = ax + t * dx;
const d2 = (py - cy) ** 2 + (px - cx) ** 2;
if (d2 < bestDist) {
bestDist = d2;
const segLen = cumMiles[i + 1] - cumMiles[i];
bestMile = cumMiles[i] + t * segLen;
}
}
return { mile: bestMile, distFromCenterline: Math.sqrt(bestDist) * 0.621371 }; // km → mi
}
// ---------- color extraction ----------
function extractDominantColor(img) {
const c = document.createElement('canvas');
c.width = 16; c.height = 12;
const ctx = c.getContext('2d', { willReadFrequently: true });
try { ctx.drawImage(img, 0, 0, 16, 12); } catch (e) { return null; }
let r = 0, g = 0, b = 0, n = 0;
try {
const px = ctx.getImageData(0, 0, 16, 12).data;
for (let i = 0; i < px.length; i += 4) {
const a = px[i + 3];
if (a < 128) continue;
r += px[i]; g += px[i + 1]; b += px[i + 2]; n++;
}
} catch { return null; }
if (!n) return null;
return [Math.round(r / n), Math.round(g / n), Math.round(b / n)];
}
function rgbToHex([r, g, b]) {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// ---------- main ----------
(async () => {
const [polyData, bizData] = await Promise.all([
fetch('/api/polyline').then(r => r.json()),
fetch('/api/businesses?audited=1&limit=5000').then(r => r.json()),
]);
const polyline = polyData.points_lat_lng;
const cumMiles = buildCumMiles(polyline);
const totalMiles = cumMiles[cumMiles.length - 1];
document.getElementById('s-miles').textContent = totalMiles.toFixed(1);
const items = (bizData.rows || [])
.filter(b => b.lat && b.lng && b.screenshot_path)
.map(b => {
const { mile, distFromCenterline } = nearestMile(b.lat, b.lng, polyline, cumMiles);
return { ...b, mile, distFromCenterline };
})
.filter(b => b.distFromCenterline < 0.4) // within ~half a mile of the centerline
.sort((a, b) => a.mile - b.mile);
// Build the mile-rail tick marks
const rail = document.getElementById('miles-rail');
for (let m = 0; m <= Math.floor(totalMiles); m++) {
const tick = document.createElement('div');
tick.className = 'mile-tick';
tick.style.top = `${(m / totalMiles) * 100}%`;
rail.appendChild(tick);
const lbl = document.createElement('div');
lbl.className = 'mile-tick-label';
lbl.style.top = `${(m / totalMiles) * 100}%`;
lbl.textContent = `mi ${m}`;
rail.appendChild(lbl);
}
const grid = document.getElementById('grid');
if (!items.length) { document.getElementById('empty').style.display = 'block'; return; }
let painted = 0, pending = items.length;
document.getElementById('s-pending').textContent = pending;
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const chip = e.target;
const img = chip.querySelector('img');
observer.unobserve(chip);
const tryPaint = () => {
const rgb = extractDominantColor(img);
if (rgb) {
chip.querySelector('.swatch').style.background = `rgb(${rgb.join(',')})`;
chip.querySelector('.hex').textContent = rgbToHex(rgb);
chip.classList.add('painted');
painted++; pending--;
document.getElementById('s-total').textContent = painted;
document.getElementById('s-pending').textContent = pending;
}
};
if (img.complete && img.naturalWidth) tryPaint();
else img.addEventListener('load', tryPaint, { once: true });
img.addEventListener('error', () => { pending--; document.getElementById('s-pending').textContent = pending; }, { once: true });
}
}, { rootMargin: '200px' });
grid.innerHTML = items.map(b => {
const isYou = b.id === DW_ID || b.id == DW_ID;
return `
<div class="chip ${isYou ? 'steve' : ''}" data-id="${b.id}">
<img src="/screenshots/${b.screenshot_path.split('/').pop()}" alt="" style="display:none" loading="lazy" crossorigin="anonymous">
<div class="swatch"></div>
<div class="hex">—</div>
<div class="miletag">mi ${b.mile.toFixed(1)}</div>
<div class="label">
<div class="nm">${b.name.replace(/[<>]/g, '')}</div>
<div class="mt">${(b.city || '').replace(/[<>]/g, '')}</div>
</div>
</div>`;
}).join('');
Array.from(grid.children).forEach(chip => {
observer.observe(chip);
chip.addEventListener('click', () => {
const id = chip.dataset.id;
const b = items.find(x => String(x.id) === id);
if (b?.website) window.open(b.website, '_blank', 'noopener');
});
});
})();
</script>
</body>
</html>