← 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>