← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>