← back to Ventura Corridor
public/crawl-derby.html
338 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Crawl Derby · DW · Ventura Corridor</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; --turf: #1a3318; --turf-rise: #244a21;
--ink: #f0ece2; --ink-mute: #888475;
--metal: #b89968; --metal-glow: #d4b683;
--rule: #2a2622; --green: #6a9b73; --red: #b66565;
--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,600;1,400;1,600&family=Inter:wght@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; min-height: 100vh; }
* { box-sizing: border-box; }
header { padding: 22px 32px 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; margin: 0; letter-spacing: -0.01em; }
header h1 em { font-style: italic; color: var(--metal); }
.sub { font-size: 11px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
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 track */
.stadium {
padding: 32px 24px;
background:
radial-gradient(ellipse at center, rgba(20,40,18,0.6) 0%, transparent 70%),
var(--noir);
}
.marquee {
text-align: center; margin-bottom: 28px;
font-family: var(--serif); font-style: italic;
color: var(--metal-glow); letter-spacing: 0.05em;
}
.marquee .big { font-size: 32px; line-height: 1.05; }
.marquee .sm { font-size: 12px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink-mute); font-style: normal; font-family: var(--sans); margin-top: 6px; }
.track {
position: relative;
max-width: 1100px; margin: 0 auto;
padding: 22px 0;
background: linear-gradient(180deg, var(--turf) 0%, var(--turf-rise) 100%);
border: 2px solid #553e23;
border-radius: 200px / 80px;
overflow: hidden;
box-shadow: inset 0 8px 28px rgba(0,0,0,0.6), 0 0 0 4px #2e2517;
}
/* Track stripes for that "well-raked turf" look */
.track::before {
content: '';
position: absolute; inset: 0;
background:
repeating-linear-gradient(90deg, transparent 0, transparent 39px, rgba(0,0,0,0.07) 40px, rgba(0,0,0,0.07) 41px);
pointer-events: none;
}
/* Finish line */
.finish {
position: absolute; right: 22px; top: 0; bottom: 0;
width: 12px; background:
repeating-linear-gradient(90deg, white 0 6px, black 6px 12px,
black 0 6px, white 6px 12px);
background-size: 12px 12px;
box-shadow: -2px 0 6px rgba(0,0,0,0.4);
z-index: 5;
}
.finish::after {
content: '🏁';
position: absolute; top: -6px; right: -6px;
font-size: 18px;
}
/* Start line */
.start-gate {
position: absolute; left: 22px; top: 0; bottom: 0;
width: 6px; background: white; opacity: 0.85; z-index: 5;
}
/* Lanes */
.lane {
position: relative;
height: 56px; margin: 6px 22px;
background: rgba(0,0,0,0.18);
border-radius: 28px;
display: flex; align-items: center;
}
.lane .label-strip {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
z-index: 2; font-family: var(--mono); font-size: 10px; letter-spacing: .15em;
text-transform: uppercase; color: rgba(255,255,255,0.55); pointer-events: none;
text-shadow: 0 1px 2px rgba(0,0,0,0.7);
}
.lane .horse {
position: absolute;
top: 50%; transform: translate(-50%, -50%);
font-size: 38px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.6));
transition: left 0.8s cubic-bezier(0.22, 0.61, 0.36, 1);
z-index: 4;
cursor: pointer;
user-select: none;
}
.lane .horse.galloping { animation: bob 0.45s ease-in-out infinite; }
@keyframes bob {
0% { transform: translate(-50%, -50%) translateY(0px); }
50% { transform: translate(-50%, -50%) translateY(-3px); }
100% { transform: translate(-50%, -50%) translateY(0px); }
}
.lane .horse .hat {
position: absolute; top: -6px; left: -2px;
font-size: 16px; filter: none;
}
.lane .pct-pill {
position: absolute; right: 60px; top: 50%; transform: translateY(-50%);
background: rgba(0,0,0,0.6); color: var(--metal-glow);
padding: 3px 10px; font-family: var(--mono); font-size: 11px;
border: 1px solid rgba(212,182,131,0.4);
z-index: 3;
}
/* Lane info table below the track */
.roster {
max-width: 1100px; margin: 32px auto 0; padding: 0 24px;
}
.roster .row {
display: grid; grid-template-columns: auto 1fr auto auto auto auto;
gap: 18px; align-items: center;
padding: 12px 16px; margin-bottom: 8px;
background: var(--noir-rise); border: 1px solid var(--rule);
font-size: 12px;
}
.roster .row .emoji { font-size: 22px; }
.roster .row .name { font-family: var(--serif); font-size: 17px; color: var(--ink); }
.roster .row .name small { display: block; color: var(--ink-mute); font-size: 10px; letter-spacing: .12em; margin-top: 2px; text-transform: uppercase; }
.roster .row .stat { font-family: var(--mono); font-size: 11px; color: var(--metal); min-width: 90px; text-align: right; }
.roster .row .stat .v { font-size: 14px; color: var(--metal-glow); display: block; }
.roster .row .runtime {
font-family: var(--mono); font-size: 9px; letter-spacing: .15em; text-transform: uppercase;
padding: 4px 9px; border: 1px solid var(--rule);
}
.roster .row .runtime.running { color: var(--green); border-color: var(--green); animation: pulse 1.4s ease-in-out infinite; }
.roster .row .runtime.idle { color: var(--ink-mute); }
.roster .row .runtime.done { color: var(--metal-glow); border-color: var(--metal-glow); }
.roster .row .runtime.error { color: var(--red); border-color: var(--red); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
.roster .row .actions button {
background: transparent; border: 1px solid var(--metal); color: var(--metal);
padding: 5px 11px; font-size: 9px; letter-spacing: .18em; text-transform: uppercase;
cursor: pointer; font-family: var(--sans);
}
.roster .row .actions button:hover { background: rgba(184,153,104,0.06); }
.roster .row .actions button.danger { border-color: var(--red); color: var(--red); }
.last-log {
grid-column: 1 / -1; margin-top: 6px; padding: 6px 12px;
background: rgba(0,0,0,0.3); font-family: var(--mono); font-size: 10px;
color: var(--ink-mute); white-space: pre-wrap; word-break: break-all;
}
.legend {
text-align: center; font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
color: var(--ink-mute); margin-top: 14px;
}
</style>
</head>
<body>
<header>
<div>
<h1>🏇 Crawl <em>derby</em></h1>
<div class="sub">17,462 corridor businesses · 5 lanes · live progress · auto-refresh 5s</div>
</div>
<nav class="tabs">
<a href="/">map</a>
<a href="/today.html">today</a>
<a href="/pitches.html">pitches</a>
<a href="/walk-route.html">walk route</a>
<a href="/responses.html">responses</a>
<a href="/postcards.html">postcards</a>
<a href="/buildings.html">buildings</a>
<a href="/crawl-derby.html" class="active">crawl derby</a>
<span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
</nav>
</header>
<section class="stadium">
<div class="marquee">
<div class="big" id="marquee-title">And they're <em>off!</em></div>
<div class="sm" id="marquee-stat">live data race · 17,462 corridor businesses to enrich</div>
</div>
<div class="track" id="track">
<div class="start-gate"></div>
<div class="finish"></div>
<!-- lanes injected -->
</div>
<div class="legend">click any horse to see what fuel it needs · 🏁 = 100% complete</div>
</section>
<section class="roster" id="roster">
<div style="text-align:center;color:var(--ink-mute);padding:40px;font-style:italic">Loading the field…</div>
</section>
<script>
function escHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
const HORSE_HATS = ['🏇', '🐎', '🐴', '🦓', '🦄'];
async function loadStatus() {
try {
const data = await fetch('/api/crawl/status').then(r => r.json());
renderTrack(data.lanes);
renderRoster(data.lanes);
renderMarquee(data.lanes, data.corridor_total);
} catch (e) {
document.getElementById('roster').innerHTML = `<div style="color:var(--red);padding:20px">load failed: ${e.message}</div>`;
}
}
function renderTrack(lanes) {
const track = document.getElementById('track');
// Clear any existing lanes (keep start/finish)
track.querySelectorAll('.lane').forEach(el => el.remove());
for (const l of lanes) {
const galloping = l.runtime?.status === 'running';
const pct = Math.min(100, l.pct);
// The horse position: start at left ~22px, end at right ~22px (just before finish line)
// Use pct% across the lane width minus padding
const lane = document.createElement('div');
lane.className = 'lane';
lane.dataset.id = l.id;
lane.innerHTML = `
<span class="label-strip">${l.emoji} ${escHtml(l.name)}</span>
<span class="pct-pill">${l.pct}% · ${l.done.toLocaleString()}/${l.total.toLocaleString()}</span>
<span class="horse ${galloping ? 'galloping' : ''}" style="left:calc(${pct}% - ${pct * 0.18}px + 22px)" title="${escHtml(l.bottleneck || '')}">
${HORSE_HATS[lanes.indexOf(l) % HORSE_HATS.length]}
</span>
`;
track.appendChild(lane);
}
}
function renderRoster(lanes) {
const root = document.getElementById('roster');
root.innerHTML = lanes.map((l, i) => {
const hat = HORSE_HATS[i % HORSE_HATS.length];
const status = l.runtime?.status || 'idle';
const log = l.runtime?.last_log || '';
const runtimeClass = status;
const runtimeLabel = ({ running: '⚡ running', idle: '◯ idle', done: '✓ finished', error: '✕ stopped' })[status] || status;
return `
<div class="row" data-id="${l.id}">
<div class="emoji">${hat}</div>
<div class="name">
${escHtml(l.name)}
<small>${escHtml(l.bottleneck || '')}</small>
</div>
<div class="stat"><span class="v">${l.pct}%</span>complete</div>
<div class="stat"><span class="v">${l.done.toLocaleString()}</span>${l.total.toLocaleString()} total</div>
<div class="runtime ${runtimeClass}">${runtimeLabel}</div>
<div class="actions">
${status === 'running'
? `<button class="danger" onclick="stopLane('${l.id}')">⏹ stop</button>`
: `<button onclick="startLane('${l.id}')">🏇 start</button>`}
</div>
${log ? `<div class="last-log">${escHtml(log)}</div>` : ''}
</div>
`;
}).join('');
}
function renderMarquee(lanes, total) {
const totalDone = lanes.reduce((a, l) => a + l.done, 0);
const totalSlots = lanes.reduce((a, l) => a + l.total, 0);
const overall = totalSlots ? (100 * totalDone / totalSlots).toFixed(1) : 0;
document.getElementById('marquee-stat').innerHTML = `Overall <b style="color:var(--metal-glow);font-family:var(--mono)">${overall}%</b> · <b style="color:var(--metal);font-family:var(--mono)">${total.toLocaleString()}</b> corridor businesses · refresh 5s`;
const someRunning = lanes.some(l => l.runtime?.status === 'running');
document.getElementById('marquee-title').innerHTML = someRunning
? `<em>Down the stretch they come!</em>`
: `<em>And they're at the post.</em>`;
}
async function startLane(lane) {
if (!confirm(`Start the "${lane}" lane?\n\nIt'll spawn the corresponding npm script as a child process. Some lanes are quota-blocked (Brave) or need API keys (HERE). Click cancel if you're not sure.`)) return;
try {
const r = await fetch('/api/crawl/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane })
});
const d = await r.json();
if (!r.ok) {
if (d.note && confirm(`Lane "${lane}" is flagged unsafe.\n\nNote: ${d.note}\n\nForce-start anyway?`)) {
const r2 = await fetch('/api/crawl/start', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane, force: true })
});
const d2 = await r2.json();
if (!r2.ok) throw new Error(d2.error || 'force-start failed');
alert(`Force-started ${lane} (PID ${d2.pid})`);
}
} else {
alert(d.already_running
? `Lane "${lane}" was already running (PID ${d.pid})`
: `Started "${lane}" (PID ${d.pid})${d.note ? '\n\n' + d.note : ''}`);
}
loadStatus();
} catch (e) {
alert('start failed: ' + e.message);
}
}
async function stopLane(lane) {
if (!confirm(`Stop the "${lane}" lane?`)) return;
try {
await fetch('/api/crawl/stop', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lane })
});
loadStatus();
} catch (e) {
alert('stop failed: ' + e.message);
}
}
loadStatus();
setInterval(loadStatus, 5000);
</script>
</body>
</html>