← back to Ventura Corridor

public/tongues.html

268 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Boulevard · tongues</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: #050507; --noir-rise: #0e0e12; --ink: #f0ece2;
    --ink-mute: #6c6a62; --metal: #b89968; --metal-glow: #d4b683;
    --rule: #1f1c19; --rule-hot: #2a261f;
    --serif: 'Cormorant Garamond', 'Times New Roman', serif;
    --sans: 'Inter', -apple-system, 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;0,500;1,300;1,400;1,500&family=Inter:wght@100;200;300;400;600&family=JetBrains+Mono:wght@200;300;400&display=swap');
  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; overflow-x: auto; overflow-y: hidden; height: 100vh; }
  body { background: radial-gradient(ellipse at center, #0a0a10 0%, var(--noir) 70%); }
  * { box-sizing: border-box; }
  header {
    position: fixed; top: 0; left: 0; right: 0; z-index: 10;
    background: linear-gradient(180deg, rgba(5,5,7,.97), rgba(5,5,7,0));
    padding: 18px 32px 14px;
    display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap;
    pointer-events: none;
  }
  header > * { pointer-events: auto; }
  header h1 { font-family: var(--serif); font-weight: 400; font-size: 22px; letter-spacing: -0.005em; margin: 0; color: var(--ink); }
  header h1 em { font-style: italic; color: var(--metal); }
  header .sub { font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
  header .meta { font-family: var(--mono); font-size: 9px; letter-spacing: .15em; color: var(--ink-mute); align-self: center; }
  nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
  nav.tabs a { font-size: 9px; letter-spacing: .26em; text-transform: uppercase;
    color: var(--ink-mute); text-decoration: none; padding: 5px 10px; border: 1px solid var(--rule); }
  nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }

  /* The instrument: a wide horizontal SVG canvas */
  #stage {
    position: relative;
    height: 100vh;
    min-width: 12000px;  /* will be set by JS based on data */
  }
  #stage svg { display: block; height: 100%; }

  /* Voice typography */
  .word { dominant-baseline: middle; cursor: pointer; transition: opacity 200ms ease, fill 200ms ease; }
  .word.v-1 { font-family: var(--sans); font-weight: 200; font-size: 12px; fill: #555; opacity: 0.45; }
  .word.v-2 { font-family: var(--sans); font-weight: 300; font-size: 14px; fill: #b8b3a2; }
  .word.v-3 { font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 18px; fill: var(--metal); }
  .word.v-4 { font-family: var(--serif); font-style: italic; font-weight: 500; font-size: 26px; fill: var(--metal-glow); }
  .word:hover { opacity: 1 !important; fill: #fff; }

  .has-pixel { animation: breathe 5s ease-in-out infinite; transform-origin: center; transform-box: fill-box; }
  @keyframes breathe {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.06); }
  }

  /* The boulevard line */
  .corridor-line { stroke: var(--rule); stroke-width: 1; }
  .corridor-tick { stroke: var(--rule); stroke-width: 1; }
  .mile-label { font-family: var(--mono); font-size: 9px; letter-spacing: .15em; fill: var(--ink-mute); text-transform: uppercase; }
  .city-label { font-family: var(--serif); font-style: italic; font-size: 18px; fill: var(--ink-mute); }

  /* Live-ads halo */
  .live-halo { fill: none; stroke: var(--metal-glow); stroke-width: 0.8; opacity: 0.4; }

  /* Hover detail panel */
  #detail {
    position: fixed; bottom: 24px; left: 32px; right: 32px;
    background: rgba(10,10,14,0.92); border: 1px solid var(--rule);
    padding: 18px 22px; max-width: 700px;
    transform: translateY(120%); transition: transform 220ms ease;
    pointer-events: none;
  }
  #detail.show { transform: translateY(0); }
  #detail .vtag { display: inline-block; font-size: 8px; letter-spacing: .26em; text-transform: uppercase; padding: 2px 6px; border: 1px solid var(--metal); color: var(--metal); margin-bottom: 6px; }
  #detail .nm { font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--ink); margin-bottom: 4px; }
  #detail .h1 { font-family: var(--serif); font-style: italic; font-size: 22px; line-height: 1.2; color: var(--metal-glow); margin-bottom: 6px; }
  #detail .meta { font-family: var(--mono); font-size: 10px; color: var(--ink-mute); letter-spacing: .12em; }

  /* Scroll hint */
  #hint {
    position: fixed; bottom: 22px; right: 32px;
    font-family: var(--mono); font-size: 9px; letter-spacing: .26em; color: var(--ink-mute); text-transform: uppercase;
    transition: opacity 600ms ease;
  }
  body.scrolled #hint { opacity: 0; }

  .legend {
    position: fixed; left: 32px; bottom: 22px;
    display: flex; gap: 20px; align-items: baseline;
    font-family: var(--mono); font-size: 9px; letter-spacing: .15em; color: var(--ink-mute); text-transform: uppercase;
    pointer-events: none;
  }
  .legend span { display: flex; align-items: baseline; gap: 6px; }
  .legend i { display: inline-block; width: 10px; height: 1px; background: currentColor; vertical-align: middle; }
  .lg-1 { color: #555; opacity: 0.6; }
  .lg-2 { color: #b8b3a2; }
  .lg-3 { color: var(--metal); }
  .lg-4 { color: var(--metal-glow); }
</style>
</head>
<body>

<header>
  <div>
    <h1>Ventura <em>Boulevard</em> · tongues</h1>
    <div class="sub">every voice, in its geography — west to east</div>
  </div>
  <nav class="tabs">
    <a href="/">map</a>
    <a href="/3d.html">3d</a>
    <a href="/timeline.html">timeline</a>
    <a href="/pantone.html">pantone</a>
    <a href="/headlines.html">voices</a>
    <a href="/tongues.html" class="active">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>
  <div class="meta" id="counter">— voices · — miles</div>
</header>

<div id="stage"><svg id="canvas" preserveAspectRatio="none"></svg></div>

<div class="legend">
  <span class="lg-1"><i></i>template</span>
  <span class="lg-2"><i></i>functional</span>
  <span class="lg-3"><i></i>sharp</span>
  <span class="lg-4"><i></i>exceptional</span>
  <span style="margin-left:18px;color:var(--metal-glow)">○ paid pixel — breathing</span>
</div>

<div id="detail"><div class="vtag" id="d-vtag">VOICE</div><div class="nm" id="d-nm">—</div><div class="h1" id="d-h1">—</div><div class="meta" id="d-meta">—</div></div>

<div id="hint">scroll horizontally → →</div>

<script>
const VOICE_LABEL = { 1: 'TEMPLATE', 2: 'FUNCTIONAL', 3: 'SHARP', 4: 'EXCEPTIONAL' };
function escapeXml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }

// Track scroll for hint dismissal
let scrollFired = false;
window.addEventListener('scroll', () => { if (!scrollFired) { document.body.classList.add('scrolled'); scrollFired = true; } }, { passive: true });

(async () => {
  const data = await fetch('/api/tongues').then(r => r.json());
  const rows = data.rows || [];
  if (!rows.length) { document.getElementById('counter').textContent = 'no voices'; return; }

  const W = 14000;          // total stage width
  const H = window.innerHeight;
  const PAD_X = 200;
  const ROADLINE_Y = H * 0.78;
  const TOP_PAD = 90;
  const minLng = data.west_lng, maxLng = data.east_lng;
  const milePerDeg = 54.6; // approx miles/deg lng at LA latitude
  const totalMi = (maxLng - minLng) * milePerDeg;

  document.getElementById('counter').textContent =
    `${rows.length.toLocaleString()} voices · ${totalMi.toFixed(1)} mi`;

  const stage = document.getElementById('stage');
  stage.style.minWidth = W + 'px';
  const svg = document.getElementById('canvas');
  svg.setAttribute('width', W);
  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);

  // Project lng to x-pixel
  const lngToX = (lng) => PAD_X + ((lng - minLng) / (maxLng - minLng)) * (W - 2 * PAD_X);

  // Pseudo-random but stable per-row: y jitter
  function hashY(name, voice) {
    let h = 0;
    for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
    h = Math.abs(h);
    // Larger voices float higher; smaller stay near the line
    const baseY = ROADLINE_Y - 20 - (voice * 30);
    const range = voice >= 3 ? 320 : 180;
    return baseY - ((h % range));
  }

  // ── DRAW ────────────────────────────────────────────
  const parts = [];

  // Corridor base line
  parts.push(`<line class="corridor-line" x1="${PAD_X}" y1="${ROADLINE_Y}" x2="${W - PAD_X}" y2="${ROADLINE_Y}"/>`);

  // Mile ticks
  for (let m = 0; m <= Math.floor(totalMi); m++) {
    const lng = minLng + m / milePerDeg;
    const x = lngToX(lng);
    parts.push(`<line class="corridor-tick" x1="${x}" y1="${ROADLINE_Y - 6}" x2="${x}" y2="${ROADLINE_Y + 6}"/>`);
    parts.push(`<text class="mile-label" x="${x}" y="${ROADLINE_Y + 22}" text-anchor="middle">mi ${m}</text>`);
  }

  // City callouts — sample every ~2 miles, pick the modal city in that range
  const cityRanges = {};
  for (const r of rows) {
    const m = Math.floor((r.lng - minLng) * milePerDeg / 2) * 2;
    cityRanges[m] = cityRanges[m] || {};
    const c = (r.city || '').toUpperCase();
    if (c) cityRanges[m][c] = (cityRanges[m][c] || 0) + 1;
  }
  for (const [m, counts] of Object.entries(cityRanges)) {
    const top = Object.entries(counts).sort((a,b) => b[1] - a[1])[0];
    if (!top) continue;
    const lng = minLng + (parseInt(m) + 1) / milePerDeg;
    parts.push(`<text class="city-label" x="${lngToX(lng)}" y="${ROADLINE_Y + 56}" text-anchor="middle">${escapeXml(top[0])}</text>`);
  }

  // Voices
  for (const r of rows) {
    const x = lngToX(r.lng);
    const v = r.headline_voice || 2;
    const y = hashY(r.name, v);
    const text = escapeXml(r.headline || '');
    const cls = `word v-${v}` + (r.paid_count > 0 ? ' has-pixel' : '');
    // Vertical "stem" connecting word to road line for high-voice ones
    if (v >= 3) {
      parts.push(`<line stroke="${v === 4 ? '#d4b683' : '#b89968'}" stroke-width="0.4" stroke-opacity="0.3" x1="${x}" y1="${y + 10}" x2="${x}" y2="${ROADLINE_Y}"/>`);
    }
    // Live-ads halo
    if (r.live_ads > 100) {
      const radius = Math.min(40, 8 + Math.log10(r.live_ads) * 6);
      parts.push(`<circle class="live-halo" cx="${x}" cy="${y}" r="${radius}"/>`);
    }
    parts.push(
      `<text class="${cls}" x="${x}" y="${y}" text-anchor="middle" data-id="${r.id}" data-name="${escapeXml(r.name)}" data-h1="${text}" data-v="${v}" data-city="${escapeXml(r.city || '')}" data-mile="${((r.lng - minLng) * milePerDeg).toFixed(2)}" data-paid="${r.paid_count || 0}" data-live="${r.live_ads || 0}" data-website="${escapeXml(r.website || '')}">${text}</text>`
    );
  }

  svg.innerHTML = parts.join('');

  // Hover detail
  const det = document.getElementById('detail');
  svg.addEventListener('mouseover', (e) => {
    const t = e.target;
    if (!t.classList.contains('word')) return;
    const d = t.dataset;
    document.getElementById('d-vtag').textContent = VOICE_LABEL[+d.v] || '';
    document.getElementById('d-nm').textContent = d.name;
    document.getElementById('d-h1').textContent = d.h1;
    let m = `mile ${d.mile} · ${d.city}`;
    if (+d.paid > 0) m += ` · ${d.paid} pixel${d.paid > 1 ? 's' : ''}`;
    if (+d.live > 0) m += ` · ${(+d.live).toLocaleString()} live ads`;
    document.getElementById('d-meta').textContent = m;
    det.classList.add('show');
  });
  svg.addEventListener('mouseout', (e) => {
    if (e.target.classList.contains('word')) det.classList.remove('show');
  });
  svg.addEventListener('click', (e) => {
    const t = e.target;
    if (!t.classList.contains('word')) return;
    const url = t.dataset.website;
    if (url) window.open(url, '_blank', 'noopener');
  });
})();
</script>
</body>
</html>