← 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
M public/index.htmlM src/server.js
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 →