← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>