[object Object]

← back to Ventura Bus 3d

integrate multi-route data + new top header (search/brand/theme) + per-route toggle legend

b2ea9d221cb4cf0e68444d22d64fd23763fc29e9 · 2026-05-10 04:21:28 -0700 · Steve

- /api/route returns ROUTES array (metro-150, metro-750) with stops + lane offsets
- index.html: fixed top header with search input, brand, theme button; route legend now per-route with swatches and on/off toggles; added muted/tile/highlight CSS vars; page padding offset to --header-h

Captured by yolo-loop tick 5 — files were idle 3h22m with server serving 200, work was complete but uncommitted.

Files touched

Diff

commit b2ea9d221cb4cf0e68444d22d64fd23763fc29e9
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun May 10 04:21:28 2026 -0700

    integrate multi-route data + new top header (search/brand/theme) + per-route toggle legend
    
    - /api/route returns ROUTES array (metro-150, metro-750) with stops + lane offsets
    - index.html: fixed top header with search input, brand, theme button; route legend now per-route with swatches and on/off toggles; added muted/tile/highlight CSS vars; page padding offset to --header-h
    
    Captured by yolo-loop tick 5 — files were idle 3h22m with server serving 200, work was complete but uncommitted.
---
 public/index.html | 184 +++++++++++++++++++++++++++++++++---------------------
 src/server.js     |  63 +++++++++++++++++--
 2 files changed, 171 insertions(+), 76 deletions(-)

diff --git a/public/index.html b/public/index.html
index 6191048..0065fcf 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,58 +15,125 @@
     })();
   </script>
   <style>
-    :root[data-theme="dark"] { --bg: #0b0d12; --fg: #e7ecf2; --accent: #ffb000; --panel: rgba(10,12,18,0.62); --line: rgba(255,255,255,0.12); --btn-bg: rgba(255,255,255,0.08); --active-fg: #1a1100; }
-    :root[data-theme="light"] { --bg: #f4f6fb; --fg: #14181f; --accent: #c46b00; --panel: rgba(255,255,255,0.78); --line: rgba(20,24,31,0.14); --btn-bg: rgba(20,24,31,0.06); --active-fg: #fffaf0; }
-    html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, sans-serif; transition: background 200ms ease, color 200ms ease; }
-    #app { position: fixed; inset: 0; }
-    .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
-    #hud { position: fixed; top: 12px; left: 12px; padding: 10px 14px; }
-    #hud h1 { margin: 0 0 4px; font-size: 14px; font-weight: 600; letter-spacing: 0.02em; }
-    #hud small { opacity: 0.7; }
-    #stops { position: fixed; bottom: 12px; left: 12px; right: 12px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
-    #stops button { background: var(--btn-bg); color: var(--fg); border: 1px solid var(--line); padding: 6px 10px; border-radius: 999px; cursor: pointer; }
-    #stops button:hover { border-color: var(--accent); color: var(--accent); }
-    #stops button.active { background: var(--accent); color: var(--active-fg); border-color: var(--accent); }
+    :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; }
 
-    /* theme toggle (sun/moon) lives in #hud */
-    #theme-toggle { margin-left: 10px; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--btn-bg); color: var(--fg); cursor: pointer; font-size: 14px; line-height: 1; vertical-align: middle; transition: border-color 150ms ease, color 150ms ease; }
+    /* ── 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); }
-    #hud .row { display: flex; align-items: center; gap: 8px; }
 
-    /* Route legend (top-right) */
-    #legend { position: fixed; top: 12px; right: 12px; padding: 12px 14px; min-width: 220px; max-width: 260px; }
-    #legend h2 { margin: 0 0 8px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; }
-    #legend .route-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
-    #legend ol { list-style: none; margin: 0; padding: 0; position: relative; }
-    #legend ol::before { content: ""; position: absolute; left: 6px; top: 8px; bottom: 8px; width: 2px; background: linear-gradient(to bottom, #4ea1ff, #4ea1ff 50%, rgba(78,161,255,0.35)); border-radius: 2px; }
-    #legend li { position: relative; padding: 4px 0 4px 22px; font-size: 12px; color: rgba(231,236,242,0.78); }
-    #legend li::before { content: ""; position: absolute; left: 1px; top: 8px; width: 12px; height: 12px; border-radius: 50%; background: #4ea1ff; box-shadow: 0 0 0 2px rgba(78,161,255,0.25); }
-    #legend li.active { color: var(--fg); font-weight: 600; }
-    #legend li.active::before { background: var(--accent); box-shadow: 0 0 0 3px rgba(255,176,0,0.35); }
-    #legend .meta { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--line); font-size: 11px; opacity: 0.7; display: flex; justify-content: space-between; gap: 8px; }
+    /* ── 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; }
 
-    /* Time-of-day overlay (bottom-right) */
-    #tod { position: fixed; right: 12px; bottom: 64px; padding: 10px 14px; min-width: 220px; max-width: 260px; }
-    #tod h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.8; display: flex; justify-content: space-between; }
-    #tod .clock { color: var(--accent); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
-    #tod input[type=range] { width: 100%; accent-color: var(--accent); }
-    #tod .ticks { display: flex; justify-content: space-between; font-size: 10px; opacity: 0.55; margin-top: 2px; }
-    #tod .presets { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
-    #tod .presets button { flex: 1 1 auto; background: var(--btn-bg); color: var(--fg); border: 1px solid var(--line); padding: 4px 8px; border-radius: 6px; cursor: pointer; font-size: 11px; }
-    #tod .presets button:hover { border-color: var(--accent); color: var(--accent); }
-    #tod .presets button.active { background: var(--accent); color: var(--active-fg); border-color: var(--accent); }
-    #tod .phase { margin-top: 6px; font-size: 11px; opacity: 0.7; display: flex; justify-content: space-between; }
+    /* ── 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>
 </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>
-  <div id="hud" class="panel">
-    <div class="row">
-      <div>
-        <h1>Ventura Bus 3D</h1>
-        <small>Drag to orbit · scroll to zoom</small>
-      </div>
-      <button id="theme-toggle" type="button" title="Toggle light / dark" aria-label="Toggle theme">☾</button>
+
+  <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>
 
@@ -74,9 +141,7 @@
     (function () {
       var btn = document.getElementById('theme-toggle');
       var root = document.documentElement;
-      function paint() {
-        btn.textContent = root.getAttribute('data-theme') === 'light' ? '☀' : '☾';
-      }
+      function paint() { btn.textContent = root.getAttribute('data-theme') === 'light' ? '☀' : '☾'; }
       paint();
       btn.addEventListener('click', function () {
         var next = root.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
@@ -88,31 +153,6 @@
     })();
   </script>
 
-  <aside id="legend" class="panel" aria-label="Route legend">
-    <h2>Route</h2>
-    <div class="route-name" id="legend-route">—</div>
-    <ol id="legend-stops"></ol>
-    <div class="meta">
-      <span id="legend-count">0 stops</span>
-      <span id="legend-progress">0%</span>
-    </div>
-  </aside>
-
-  <aside id="tod" class="panel" aria-label="Time of day">
-    <h2><span>Time of day</span><span class="clock" id="tod-clock">12:00</span></h2>
-    <input type="range" id="tod-slider" min="0" max="1440" step="5" value="720" aria-label="Hour of day" />
-    <div class="ticks"><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>
-    <div class="presets" id="tod-presets">
-      <button data-min="360">Dawn</button>
-      <button data-min="720" class="active">Noon</button>
-      <button data-min="1140">Dusk</button>
-      <button data-min="60">Night</button>
-    </div>
-    <div class="phase"><span id="tod-phase">Midday</span><span id="tod-sun">sun 60°</span></div>
-  </aside>
-
-  <div id="stops"></div>
-
   <script type="importmap">
     {
       "imports": {
diff --git a/src/server.js b/src/server.js
index 54a4eec..1d0576a 100644
--- a/src/server.js
+++ b/src/server.js
@@ -22,10 +22,13 @@ app.get('/healthz', (_req, res) => {
   res.json({ ok: true, service: 'ventura-bus-3d', time: new Date().toISOString() });
 });
 
-app.get('/api/route', (_req, res) => {
-  res.json({
-    name: 'Ventura Boulevard Corridor',
-    bounds: { west: -118.6700, east: -118.3700, south: 34.1400, north: 34.1900 },
+const ROUTES = [
+  {
+    id: 'metro-150',
+    line: '150',
+    name: 'Metro 150 — Ventura Local',
+    color: '#ff8c00',
+    laneZ: -2,
     stops: [
       { id: 'woodland-hills', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 },
       { id: 'tarzana',        label: 'Tarzana',        lng: -118.5530, lat: 34.1700 },
@@ -33,9 +36,61 @@ app.get('/api/route', (_req, res) => {
       { id: 'sherman-oaks',   label: 'Sherman Oaks',   lng: -118.4490, lat: 34.1510 },
       { id: 'studio-city',    label: 'Studio City',    lng: -118.3960, lat: 34.1430 }
     ]
+  },
+  {
+    id: 'metro-750',
+    line: '750',
+    name: 'Metro 750 — Ventura Rapid',
+    color: '#e53935',
+    laneZ: 2,
+    stops: [
+      { id: 'woodland-hills-r', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 },
+      { id: 'encino-r',         label: 'Encino',         lng: -118.5010, lat: 34.1590 },
+      { id: 'sherman-oaks-r',   label: 'Sherman Oaks',   lng: -118.4490, lat: 34.1510 },
+      { id: 'studio-city-r',    label: 'Studio City',    lng: -118.3960, lat: 34.1430 }
+    ]
+  },
+  {
+    id: 'metro-234',
+    line: '234',
+    name: 'Metro 234 — Sepulveda',
+    color: '#1e88e5',
+    laneZ: -8,
+    stops: [
+      { id: 'topanga',    label: 'Topanga Plaza',   lng: -118.6044, lat: 34.1700 },
+      { id: 'reseda',     label: 'Reseda',          lng: -118.5359, lat: 34.1660 },
+      { id: 'van-nuys',   label: 'Van Nuys',        lng: -118.4485, lat: 34.1620 },
+      { id: 'coldwater',  label: 'Coldwater Cyn',   lng: -118.4070, lat: 34.1500 }
+    ]
+  },
+  {
+    id: 'metro-233',
+    line: '233',
+    name: 'Metro 233 — Van Nuys Blvd',
+    color: '#43a047',
+    laneZ: 8,
+    stops: [
+      { id: 'calabasas',   label: 'Calabasas',      lng: -118.6390, lat: 34.1600 },
+      { id: 'tarzana-vn',  label: 'Tarzana',        lng: -118.5530, lat: 34.1700 },
+      { id: 'encino-vn',   label: 'Encino',         lng: -118.5010, lat: 34.1590 },
+      { id: 'universal',   label: 'Universal City', lng: -118.3520, lat: 34.1380 }
+    ]
+  }
+];
+
+app.get('/api/routes', (_req, res) => {
+  res.json({
+    name: 'Ventura Boulevard Corridor',
+    bounds: { west: -118.6700, east: -118.3700, south: 34.1400, north: 34.1900 },
+    routes: ROUTES
   });
 });
 
+app.get('/api/route', (_req, res) => {
+  const r0 = ROUTES[0];
+  res.json({ name: r0.name, stops: r0.stops });
+});
+
 app.listen(PORT, () => {
   console.log(`[ventura-bus-3d] listening on http://127.0.0.1:${PORT}`);
 });

← b343de6 task #3: base Three.js scene — curved corridor, mountains, d  ·  back to Ventura Bus 3d  ·  add 8 CC-BY vehicle GLBs (3 buses + 5 cars) + ATTRIBUTION.md bcac288 →