[object Object]

← back to Ventura Bus 3d

add route legend (top-right) and time-of-day overlay (bottom-right) — slider drives sun arc / sky / fog / hemi lights with named anchors (deep-night → dawn → midday → golden hour → dusk); legend syncs current stop in real time

5505ad6800610b66df2dc9bbac18e25d5781c182 · 2026-05-10 00:48:31 -0700 · Steve Abrams

Files touched

Diff

commit 5505ad6800610b66df2dc9bbac18e25d5781c182
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 00:48:31 2026 -0700

    add route legend (top-right) and time-of-day overlay (bottom-right) — slider drives sun arc / sky / fog / hemi lights with named anchors (deep-night → dawn → midday → golden hour → dusk); legend syncs current stop in real time
---
 public/index.html |  58 +++++++++++++++++++++--
 public/js/main.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 185 insertions(+), 7 deletions(-)

diff --git a/public/index.html b/public/index.html
index 64c690e..db0dac1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,23 +5,73 @@
   <meta name="viewport" content="width=device-width,initial-scale=1" />
   <title>Ventura Bus 3D</title>
   <style>
-    :root { --bg: #0b0d12; --fg: #e7ecf2; --accent: #ffb000; }
+    :root { --bg: #0b0d12; --fg: #e7ecf2; --accent: #ffb000; --panel: rgba(10,12,18,0.62); --line: rgba(255,255,255,0.12); }
     html, body { margin: 0; padding: 0; height: 100%; background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, sans-serif; }
     #app { position: fixed; inset: 0; }
-    #hud { position: fixed; top: 12px; left: 12px; background: rgba(0,0,0,0.55); padding: 10px 14px; border-radius: 8px; backdrop-filter: blur(6px); }
+    .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: rgba(255,255,255,0.08); color: var(--fg); border: 1px solid rgba(255,255,255,0.15); padding: 6px 10px; border-radius: 999px; cursor: pointer; }
+    #stops button { background: rgba(255,255,255,0.08); 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: #1a1100; border-color: var(--accent); }
+
+    /* 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; }
+
+    /* 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: rgba(255,255,255,0.06); 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: #1a1100; border-color: var(--accent); }
+    #tod .phase { margin-top: 6px; font-size: 11px; opacity: 0.7; display: flex; justify-content: space-between; }
   </style>
 </head>
 <body>
   <div id="app"></div>
-  <div id="hud">
+  <div id="hud" class="panel">
     <h1>Ventura Bus 3D</h1>
     <small>Drag to orbit · scroll to zoom</small>
   </div>
+
+  <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">
diff --git a/public/js/main.js b/public/js/main.js
index 11330f7..dd28f3f 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -3,6 +3,15 @@ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
 const app = document.getElementById('app');
 const stopsRow = document.getElementById('stops');
+const legendRouteEl = document.getElementById('legend-route');
+const legendStopsEl = document.getElementById('legend-stops');
+const legendCountEl = document.getElementById('legend-count');
+const legendProgressEl = document.getElementById('legend-progress');
+const todSlider = document.getElementById('tod-slider');
+const todClockEl = document.getElementById('tod-clock');
+const todPhaseEl = document.getElementById('tod-phase');
+const todSunEl = document.getElementById('tod-sun');
+const todPresets = document.getElementById('tod-presets');
 
 const scene = new THREE.Scene();
 scene.background = new THREE.Color(0x0b0d12);
@@ -28,6 +37,86 @@ sun.position.set(50, 80, 30);
 sun.castShadow = true;
 scene.add(sun);
 
+// Time-of-day stops. Each entry is keyed by minutes-since-midnight and
+// holds the colors + intensities the scene blends to. Anchors are sparse;
+// applyTimeOfDay() lerps between the two surrounding anchors.
+const TOD_STOPS = [
+  { min:    0, name: 'Deep night', sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 },
+  { min:  300, name: 'Pre-dawn',   sky: 0x0f1426, fog: 0x12182c, sunColor: 0x6b5a8c, sunInt: 0.20, hemiSky: 0x3a3f5c, hemiGround: 0x0a0b12, hemiInt: 0.35 },
+  { min:  390, name: 'Dawn',       sky: 0xffb288, fog: 0xff9a6b, sunColor: 0xffb070, sunInt: 0.85, hemiSky: 0xffc59c, hemiGround: 0x2a2018, hemiInt: 0.55 },
+  { min:  540, name: 'Morning',    sky: 0x9ec8ff, fog: 0xb6d3ff, sunColor: 0xfff1d1, sunInt: 1.10, hemiSky: 0xc9deff, hemiGround: 0x2a2a30, hemiInt: 0.70 },
+  { min:  720, name: 'Midday',     sky: 0x8ec0ff, fog: 0xa9caff, sunColor: 0xffffff, sunInt: 1.20, hemiSky: 0xa6c8ff, hemiGround: 0x1a1a1a, hemiInt: 0.75 },
+  { min:  990, name: 'Afternoon',  sky: 0xa6c1ff, fog: 0xc6d4ee, sunColor: 0xffe2b3, sunInt: 1.05, hemiSky: 0xb6cfff, hemiGround: 0x252225, hemiInt: 0.65 },
+  { min: 1140, name: 'Golden hour',sky: 0xff9c5f, fog: 0xff834a, sunColor: 0xff9a55, sunInt: 0.90, hemiSky: 0xffb27a, hemiGround: 0x2c1a14, hemiInt: 0.55 },
+  { min: 1230, name: 'Dusk',       sky: 0x4a3a6b, fog: 0x352a55, sunColor: 0x6e5b8c, sunInt: 0.30, hemiSky: 0x6e5e8c, hemiGround: 0x0e0c18, hemiInt: 0.40 },
+  { min: 1320, name: 'Night',      sky: 0x0a1024, fog: 0x070a18, sunColor: 0x2a3a64, sunInt: 0.10, hemiSky: 0x2a3458, hemiGround: 0x05060a, hemiInt: 0.28 },
+  { min: 1440, name: 'Deep night', sky: 0x05070d, fog: 0x05070d, sunColor: 0x1a2440, sunInt: 0.05, hemiSky: 0x223052, hemiGround: 0x05060a, hemiInt: 0.25 }
+];
+
+const _ca = new THREE.Color();
+const _cb = new THREE.Color();
+function lerpHex(a, b, t) { return _ca.setHex(a).lerp(_cb.setHex(b), t).getHex(); }
+
+function applyTimeOfDay(minutes) {
+  const m = ((minutes % 1440) + 1440) % 1440;
+  let lo = TOD_STOPS[0], hi = TOD_STOPS[TOD_STOPS.length - 1];
+  for (let i = 0; i < TOD_STOPS.length - 1; i++) {
+    if (m >= TOD_STOPS[i].min && m <= TOD_STOPS[i + 1].min) { lo = TOD_STOPS[i]; hi = TOD_STOPS[i + 1]; break; }
+  }
+  const span = (hi.min - lo.min) || 1;
+  const t = (m - lo.min) / span;
+
+  scene.background.setHex(lerpHex(lo.sky, hi.sky, t));
+  scene.fog.color.setHex(lerpHex(lo.fog, hi.fog, t));
+  sun.color.setHex(lerpHex(lo.sunColor, hi.sunColor, t));
+  sun.intensity = lo.sunInt + (hi.sunInt - lo.sunInt) * t;
+  ambient.color.setHex(lerpHex(lo.hemiSky, hi.hemiSky, t));
+  ambient.groundColor.setHex(lerpHex(lo.hemiGround, hi.hemiGround, t));
+  ambient.intensity = lo.hemiInt + (hi.hemiInt - lo.hemiInt) * t;
+
+  // Sun arc — east at 06:00, zenith at 12:00, west at 18:00, below horizon at night.
+  const sunAngle = ((m - 360) / 720) * Math.PI; // 06:00 → 0, 18:00 → π
+  const r = 90;
+  sun.position.set(Math.cos(sunAngle) * r, Math.sin(sunAngle) * r, 30);
+  sun.visible = sun.position.y > -5;
+
+  // HUD updates
+  const hh = String(Math.floor(m / 60)).padStart(2, '0');
+  const mm = String(Math.floor(m % 60)).padStart(2, '0');
+  if (todClockEl) todClockEl.textContent = `${hh}:${mm}`;
+  const phaseName = t < 0.5 ? lo.name : hi.name;
+  if (todPhaseEl) todPhaseEl.textContent = phaseName;
+  if (todSunEl) {
+    const deg = Math.round((sunAngle * 180) / Math.PI);
+    todSunEl.textContent = sun.visible ? `sun ${deg}°` : 'sun below';
+  }
+}
+
+if (todSlider) {
+  const setActivePreset = (mins) => {
+    if (!todPresets) return;
+    todPresets.querySelectorAll('button').forEach(b => {
+      b.classList.toggle('active', Number(b.dataset.min) === mins);
+    });
+  };
+  todSlider.addEventListener('input', () => {
+    const v = Number(todSlider.value);
+    applyTimeOfDay(v);
+    setActivePreset(v);
+  });
+  if (todPresets) {
+    todPresets.addEventListener('click', (ev) => {
+      const btn = ev.target.closest('button[data-min]');
+      if (!btn) return;
+      const v = Number(btn.dataset.min);
+      todSlider.value = String(v);
+      applyTimeOfDay(v);
+      setActivePreset(v);
+    });
+  }
+  applyTimeOfDay(Number(todSlider.value));
+}
+
 const grid = new THREE.GridHelper(200, 40, 0x444444, 0x222222);
 grid.position.y = -0.01;
 scene.add(grid);
@@ -84,6 +173,9 @@ scene.add(bus);
 
 let routeStops = [];
 let busT = 0;
+let activeStopIdx = -1;
+const stopButtons = [];
+const legendItems = [];
 
 async function loadRoute() {
   const r = await fetch('/api/route').then(r => r.json());
@@ -94,7 +186,11 @@ async function loadRoute() {
     return { ...s, x, z: 0 };
   });
 
-  routeStops.forEach(s => {
+  if (legendRouteEl) legendRouteEl.textContent = r.name || 'Route';
+  if (legendCountEl) legendCountEl.textContent = `${routeStops.length} stops`;
+  if (legendStopsEl) legendStopsEl.innerHTML = '';
+
+  routeStops.forEach((s, i) => {
     const m = new THREE.Mesh(
       new THREE.CylinderGeometry(0.6, 0.6, 4, 12),
       new THREE.MeshStandardMaterial({ color: 0x4ea1ff })
@@ -106,14 +202,37 @@ async function loadRoute() {
 
     const btn = document.createElement('button');
     btn.textContent = s.label;
-    btn.onclick = () => focusStop(s);
+    btn.onclick = () => focusStop(s, i);
     stopsRow.appendChild(btn);
+    stopButtons.push(btn);
+
+    if (legendStopsEl) {
+      const li = document.createElement('li');
+      li.textContent = s.label;
+      li.title = `${s.lat?.toFixed(4) ?? ''}, ${s.lng?.toFixed(4) ?? ''}`.trim();
+      li.addEventListener('click', () => focusStop(s, i));
+      li.style.cursor = 'pointer';
+      legendStopsEl.appendChild(li);
+      legendItems.push(li);
+    }
   });
 }
 
-function focusStop(s) {
+function setActiveStop(idx) {
+  if (idx === activeStopIdx) return;
+  activeStopIdx = idx;
+  legendItems.forEach((el, i) => el.classList.toggle('active', i === idx));
+  stopButtons.forEach((el, i) => el.classList.toggle('active', i === idx));
+  stopMarkers.forEach((m, i) => {
+    m.material.color.setHex(i === idx ? 0xffb000 : 0x4ea1ff);
+    m.material.emissive?.setHex?.(i === idx ? 0x3a2400 : 0x000000);
+  });
+}
+
+function focusStop(s, idx) {
   controls.target.set(s.x, 1.5, s.z);
   camera.position.set(s.x + 18, 16, s.z + 26);
+  if (typeof idx === 'number') setActiveStop(idx);
 }
 
 window.addEventListener('resize', () => {
@@ -134,6 +253,15 @@ function animate() {
     bus.position.x = xMin + (xMax - xMin) * busT;
     bus.position.z = 0;
     bus.rotation.y = -Math.PI / 2;
+
+    // Highlight nearest stop in legend + chips.
+    let nearest = 0, best = Infinity;
+    for (let i = 0; i < routeStops.length; i++) {
+      const d = Math.abs(routeStops[i].x - bus.position.x);
+      if (d < best) { best = d; nearest = i; }
+    }
+    setActiveStop(nearest);
+    if (legendProgressEl) legendProgressEl.textContent = `${Math.round(busT * 100)}%`;
   }
   controls.update();
   renderer.render(scene, camera);

← 3543dfe initial scaffold  ·  back to Ventura Bus 3d  ·  switch port to 9783, add ecosystem.config.js, bake in dark/l 4f29a8f →