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