← back to Ventura Bus 3d

public/index.html

185 lines

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Ventura Bus 3D</title>
  <script>
    /* anti-flash: apply saved theme before paint */
    (function () {
      try {
        var t = localStorage.getItem('vb3d-theme');
        if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
        document.documentElement.setAttribute('data-theme', t);
      } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }
    })();
  </script>
  <style>
    :root[data-theme="dark"]  { --bg: #0b0d12; --fg: #e7ecf2; --muted: rgba(231,236,242,0.65); --accent: #ffb000; --panel: rgba(10,12,18,0.72); --line: rgba(255,255,255,0.12); --btn-bg: rgba(255,255,255,0.08); --active-fg: #1a1100; --highlight-bg: rgba(255,176,0,0.10); --highlight-border: rgba(255,176,0,0.25); --tile-bg: rgba(255,255,255,0.04); --tile-bg-hover: rgba(255,255,255,0.08); --toggle-off: rgba(255,255,255,0.18); --header-grad: linear-gradient(to bottom, rgba(10,12,18,0.88), rgba(10,12,18,0.55)); }
    :root[data-theme="light"] { --bg: #f4f6fb; --fg: #14181f; --muted: rgba(20,24,31,0.6);  --accent: #c46b00; --panel: rgba(255,255,255,0.86); --line: rgba(20,24,31,0.14); --btn-bg: rgba(20,24,31,0.06); --active-fg: #fffaf0; --highlight-bg: rgba(196,107,0,0.10); --highlight-border: rgba(196,107,0,0.25); --tile-bg: rgba(20,24,31,0.04); --tile-bg-hover: rgba(20,24,31,0.08); --toggle-off: rgba(20,24,31,0.22); --header-grad: linear-gradient(to bottom, rgba(244,246,251,0.95), rgba(244,246,251,0.65)); }
    :root { --header-h: 56px; }
    * { box-sizing: border-box; }
    html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, Segoe UI, sans-serif; overflow: hidden; transition: background 200ms ease, color 200ms ease; }
    button, input, select { font: inherit; color: inherit; }

    /* ── 3D canvas ───────────────────────────────── */
    #app { position: fixed; inset: var(--header-h) 0 0 0; }

    /* ── Top header (search + brand + theme) ─────── */
    #header { position: fixed; top: 0; left: 0; right: 0; height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 16px; background: var(--header-grad); border-bottom: 1px solid var(--line); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 20; }
    #header .brand { font-weight: 700; letter-spacing: 0.02em; font-size: 14px; white-space: nowrap; }
    #header .brand small { display: block; font-weight: 400; opacity: 0.6; font-size: 11px; }
    #search-wrap { flex: 1; max-width: 420px; position: relative; }
    #search { width: 100%; background: var(--btn-bg); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px 8px 36px; outline: none; }
    #search:focus { border-color: var(--accent); }
    #search::placeholder { color: var(--muted); }
    #search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); pointer-events: none; opacity: 0.6; }
    #search-hint { font-size: 11px; opacity: 0.55; white-space: nowrap; }
    #theme-toggle { width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--line); background: var(--btn-bg); color: var(--fg); cursor: pointer; font-size: 14px; line-height: 1; flex: 0 0 auto; }
    #theme-toggle:hover { border-color: var(--accent); color: var(--accent); }

    /* ── Panel chrome ────────────────────────────── */
    .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
    .panel h2 { margin: 0 0 8px; font-size: 11px; font-weight: 700; letter-spacing: 0.10em; text-transform: uppercase; opacity: 0.75; }

    /* ── Left aside: route legend ────────────────── */
    #legend { position: fixed; top: calc(var(--header-h) + 12px); left: 12px; width: 220px; padding: 12px 14px; max-height: calc(100vh - var(--header-h) - 24px); overflow-y: auto; z-index: 10; }
    #legend ul.routes { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
    #legend li.route { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: var(--tile-bg); border: 1px solid transparent; border-radius: 6px; cursor: pointer; user-select: none; }
    #legend li.route:hover { background: var(--tile-bg-hover); border-color: var(--line); }
    #legend li.route.off { opacity: 0.42; }
    #legend li.route .swatch { flex: 0 0 14px; width: 14px; height: 14px; border-radius: 50%; box-shadow: 0 0 0 2px rgba(0,0,0,0.45); }
    #legend li.route .label { flex: 1; min-width: 0; }
    #legend li.route .line { font-weight: 700; font-size: 13px; }
    #legend li.route .name { font-size: 11px; opacity: 0.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    #legend li.route .toggle { flex: 0 0 auto; width: 28px; height: 16px; border-radius: 999px; background: var(--toggle-off); position: relative; transition: background 0.15s; }
    #legend li.route .toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: left 0.15s; box-shadow: 0 1px 2px rgba(0,0,0,0.3); }
    #legend li.route:not(.off) .toggle { background: var(--accent); }
    #legend li.route:not(.off) .toggle::after { left: 14px; }

    /* ── Right aside: info panel ─────────────────── */
    #info { position: fixed; top: calc(var(--header-h) + 12px); right: 12px; width: 260px; padding: 12px 14px; max-height: calc(100vh - var(--header-h) - 140px); overflow-y: auto; z-index: 10; }
    #info .empty { font-size: 12px; opacity: 0.6; }
    #info .route-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-weight: 700; font-size: 11px; color: var(--active-fg); }
    #info .bus-name { margin-top: 6px; font-size: 14px; font-weight: 600; }
    #info .next-stop { margin-top: 10px; padding: 8px 10px; background: var(--highlight-bg); border: 1px solid var(--highlight-border); border-radius: 6px; }
    #info .next-stop .lbl { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.65; }
    #info .next-stop .val { font-size: 13px; font-weight: 600; color: var(--accent); }
    #info ol.stops { list-style: none; margin: 12px 0 0; padding: 0; position: relative; }
    #info ol.stops li { position: relative; padding: 5px 0 5px 22px; font-size: 12px; color: var(--muted); }
    #info ol.stops li::before { content: ""; position: absolute; left: 3px; top: 9px; width: 10px; height: 10px; border-radius: 50%; background: var(--info-color, #4ea1ff); box-shadow: 0 0 0 2px rgba(255,255,255,0.10); }
    #info ol.stops::before { content: ""; position: absolute; left: 7px; top: 8px; bottom: 8px; width: 2px; background: var(--info-color, #4ea1ff); opacity: 0.4; }
    #info ol.stops li.next { color: var(--fg); font-weight: 600; }
    #info ol.stops li.next::before { background: var(--accent); box-shadow: 0 0 0 3px rgba(255,176,0,0.35); }

    /* ── Bottom controls (sliders) ───────────────── */
    #controls { position: fixed; bottom: 12px; left: 12px; right: 12px; padding: 12px 16px; display: flex; gap: 18px; align-items: center; z-index: 10; }
    #controls .ctrl { flex: 1; min-width: 0; }
    #controls .ctrl-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
    #controls .ctrl-row .lbl { font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.75; }
    #controls .ctrl-row .val { font-size: 12px; color: var(--accent); font-variant-numeric: tabular-nums; }
    #controls input[type=range] { width: 100%; accent-color: var(--accent); }
    #controls .ticks { display: flex; justify-content: space-between; font-size: 10px; opacity: 0.5; margin-top: 2px; }
    #controls .phase { font-size: 11px; opacity: 0.65; margin-top: 2px; }

    /* ── Mobile (≤768px): stacked layout ─────────── */
    @media (max-width: 768px) {
      :root { --header-h: 96px; }
      html, body { overflow: auto; }
      #header { flex-wrap: wrap; padding: 8px 12px; height: auto; }
      #header .brand { flex: 1; }
      #search-wrap { max-width: none; flex: 1 1 100%; order: 3; }
      #search-hint { display: none; }
      #app { position: relative; inset: auto; height: 50vh; min-height: 320px; width: 100%; }
      #legend, #info, #controls { position: static; width: auto; max-width: none; max-height: none; margin: 8px 12px; left: auto; right: auto; top: auto; bottom: auto; }
      #legend { margin-top: 12px; }
      #info ol.stops { max-height: 200px; overflow: auto; }
      #controls { flex-direction: column; gap: 12px; align-items: stretch; padding: 14px 16px 18px; }
      #controls .ctrl { width: 100%; }
    }
  </style>
<!-- Meta Pixel — INERT until set-fb-pixel.sh is run (does NOT load fbevents.js or hit Meta until flipped) -->
<script>
(function(){
  var PIXEL_ID = '{{FACEBOOK_PIXEL_ID}}';
  // Inert until flipped — the brace check prevents BOTH the loader IIFE
  // AND the init call from running, so fbevents.js is never fetched.
  if (PIXEL_ID.indexOf('{{') !== -1) return;
  !function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
  n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
  n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
  t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
  document,'script','https://connect.facebook.net/en_US/fbevents.js');
  fbq('init', PIXEL_ID);
  fbq('track', 'PageView');
})();
</script>
<!-- noscript fallback is added by set-fb-pixel.sh on flip (omitted here to avoid unflipped img phone-home) -->
<!-- End Meta Pixel -->
</head>
<body>
  <header id="header">
    <div class="brand">Ventura Bus 3D <small>Live corridor preview</small></div>
    <div id="search-wrap">
      <svg id="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
      <input id="search" type="search" inputmode="numeric" autocomplete="off" placeholder="Search Metro line (150, 750, 234, 233)…" aria-label="Search Metro line number" />
    </div>
    <span id="search-hint">↵ to fly to nearest bus</span>
    <button id="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle theme">☾</button>
  </header>

  <div id="app"></div>

  <aside id="legend" class="panel" aria-label="Route legend">
    <h2>Routes</h2>
    <ul class="routes" id="route-list"></ul>
  </aside>

  <aside id="info" class="panel" aria-label="Selected bus info">
    <h2>Selected bus</h2>
    <div id="info-body" class="empty">Click a bus or search a line to inspect.</div>
  </aside>

  <div id="controls" class="panel">
    <div class="ctrl">
      <div class="ctrl-row"><span class="lbl">Time of day</span><span class="val" id="time-val">12:00</span></div>
      <input type="range" id="time-slider" min="0" max="1440" step="5" value="720" aria-label="Time of day in minutes" />
      <div class="ticks"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
      <div class="phase"><span id="time-phase">Midday</span> · <span id="time-headlights">headlights off</span></div>
    </div>
    <div class="ctrl">
      <div class="ctrl-row"><span class="lbl">Traffic density</span><span class="val" id="traffic-val">50%</span></div>
      <input type="range" id="traffic-slider" min="0" max="100" step="1" value="50" aria-label="Traffic density percent" />
      <div class="ticks"><span>empty</span><span>light</span><span>medium</span><span>heavy</span><span>jam</span></div>
      <div class="phase" id="traffic-phase">Medium flow</div>
    </div>
  </div>

  <script>
    (function () {
      var btn = document.getElementById('theme-toggle');
      var root = document.documentElement;
      function paint() { btn.textContent = root.getAttribute('data-theme') === 'light' ? '☀' : '☾'; }
      paint();
      btn.addEventListener('click', function () {
        var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
        root.setAttribute('data-theme', next);
        try { localStorage.setItem('vb3d-theme', next); } catch (e) {}
        paint();
        window.dispatchEvent(new CustomEvent('vb3d:theme', { detail: next }));
      });
    })();
  </script>

  <script type="importmap">
    {
      "imports": {
        "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
        "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
      }
    }
  </script>
  <script type="module" src="/js/main.js"></script>
</body>
</html>