← back to Ventura Bus 3d
add 8 CC-BY vehicle GLBs (3 buses + 5 cars) + ATTRIBUTION.md
bcac288ef96a006318e8504f73a623c74a3fa734 · 2026-05-10 08:52:35 -0700 · Steve Abrams
Files touched
M public/js/main.jsA public/models/ATTRIBUTION.mdA public/models/bus-ladot-dash.glbA public/models/bus-metro-articulated.glbA public/models/bus-school.glbA public/models/car-delivery-van.glbA public/models/car-pickup.glbA public/models/car-sedan.glbA public/models/car-sports.glbA public/models/car-suv.glb
Diff
commit bcac288ef96a006318e8504f73a623c74a3fa734
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 10 08:52:35 2026 -0700
add 8 CC-BY vehicle GLBs (3 buses + 5 cars) + ATTRIBUTION.md
---
public/js/main.js | 658 +++++++++++++++++++++++---------
public/models/ATTRIBUTION.md | 65 ++++
public/models/bus-ladot-dash.glb | Bin 0 -> 389620 bytes
public/models/bus-metro-articulated.glb | Bin 0 -> 319292 bytes
public/models/bus-school.glb | Bin 0 -> 236852 bytes
public/models/car-delivery-van.glb | Bin 0 -> 44152 bytes
public/models/car-pickup.glb | Bin 0 -> 273012 bytes
public/models/car-sedan.glb | Bin 0 -> 164752 bytes
public/models/car-sports.glb | Bin 0 -> 175100 bytes
public/models/car-suv.glb | Bin 0 -> 181608 bytes
10 files changed, 536 insertions(+), 187 deletions(-)
diff --git a/public/js/main.js b/public/js/main.js
index dd28f3f..217dc72 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -1,28 +1,29 @@
import * as THREE from 'three';
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');
-
+// ── DOM refs ────────────────────────────────────────────────
+const app = document.getElementById('app');
+const routeListEl = document.getElementById('route-list');
+const infoBodyEl = document.getElementById('info-body');
+const searchEl = document.getElementById('search');
+const timeSlider = document.getElementById('time-slider');
+const timeValEl = document.getElementById('time-val');
+const timePhaseEl = document.getElementById('time-phase');
+const timeHeadEl = document.getElementById('time-headlights');
+const trafficSlider = document.getElementById('traffic-slider');
+const trafficValEl = document.getElementById('traffic-val');
+const trafficPhaseEl = document.getElementById('traffic-phase');
+
+// ── Scene / renderer ────────────────────────────────────────
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0d12);
-scene.fog = new THREE.Fog(0x0b0d12, 60, 220);
+scene.fog = new THREE.Fog(0x0b0d12, 60, 260);
-const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 1000);
-camera.position.set(40, 30, 60);
+const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
+camera.position.set(40, 32, 64);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
-renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
app.appendChild(renderer.domElement);
@@ -37,123 +38,89 @@ 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);
+// ── Static ground / road ────────────────────────────────────
+const grid = new THREE.GridHelper(220, 44, 0x444444, 0x222222);
grid.position.y = -0.01;
scene.add(grid);
const ground = new THREE.Mesh(
- new THREE.PlaneGeometry(200, 60),
+ new THREE.PlaneGeometry(220, 80),
new THREE.MeshStandardMaterial({ color: 0x1a1d24, roughness: 0.95 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
+// Wide central road covers all 4 lanes (z = -8..+8)
const road = new THREE.Mesh(
- new THREE.PlaneGeometry(180, 8),
+ new THREE.PlaneGeometry(200, 22),
new THREE.MeshStandardMaterial({ color: 0x2a2d33, roughness: 0.9 })
);
road.rotation.x = -Math.PI / 2;
road.position.y = 0.01;
scene.add(road);
-const stopMarkers = [];
+// Lane stripe markers (purely cosmetic)
+[-5, 0, 5].forEach(z => {
+ const stripe = new THREE.Mesh(
+ new THREE.PlaneGeometry(200, 0.12),
+ new THREE.MeshStandardMaterial({ color: 0xc9b65a, roughness: 0.6 })
+ );
+ stripe.rotation.x = -Math.PI / 2;
+ stripe.position.set(0, 0.02, z);
+ scene.add(stripe);
+});
+
+// ── Time-of-day blending stops ──────────────────────────────
+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 }
+];
-function makeBus() {
+const _ca = new THREE.Color();
+const _cb = new THREE.Color();
+const lerpHex = (a, b, t) => _ca.setHex(a).lerp(_cb.setHex(b), t).getHex();
+
+// ── Global state ────────────────────────────────────────────
+const state = {
+ routes: [], // [{ id, line, name, color, laneZ, stops:[{label,x,z}], xMin, xMax, hexColor }]
+ buses: [], // [{ group, body, route, t, speed, dir, headlightMat }]
+ trafficCars: [], // pool of generic cars; .visible toggled by density
+ selectedBus: null,
+ enabledRoutes: new Set(),
+ headlightsOn: false,
+ timeMinutes: Number(timeSlider?.value ?? 720),
+ trafficDensity: Number(trafficSlider?.value ?? 50),
+ flyTo: null // { pos: Vec3, target: Vec3 }
+};
+
+const X_MIN = -90, X_MAX = 90;
+const ROAD_SPAN = X_MAX - X_MIN;
+
+// ── Bus factory ─────────────────────────────────────────────
+function makeBus(hexColor) {
const group = new THREE.Group();
- const body = new THREE.Mesh(
- new THREE.BoxGeometry(8, 2.4, 2.6),
- new THREE.MeshStandardMaterial({ color: 0xffb000, metalness: 0.2, roughness: 0.5 })
- );
+ const bodyMat = new THREE.MeshStandardMaterial({ color: hexColor, metalness: 0.2, roughness: 0.5 });
+ const body = new THREE.Mesh(new THREE.BoxGeometry(8, 2.4, 2.6), bodyMat);
body.position.y = 1.6;
body.castShadow = true;
group.add(body);
+
const stripe = new THREE.Mesh(
new THREE.BoxGeometry(8.05, 0.3, 2.65),
- new THREE.MeshStandardMaterial({ color: 0x222222 })
+ new THREE.MeshStandardMaterial({ color: 0x111111 })
);
stripe.position.y = 1.2;
group.add(stripe);
+
const wheelGeo = new THREE.CylinderGeometry(0.5, 0.5, 0.4, 16);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111 });
[-2.5, 2.5].forEach(x => {
@@ -165,110 +132,427 @@ function makeBus() {
group.add(w);
});
});
+
+ // Headlights — emissive material, intensity toggled by sun height
+ const headlightMat = new THREE.MeshStandardMaterial({
+ color: 0xffeebb, emissive: 0xffeebb, emissiveIntensity: 0
+ });
+ const hlGeo = new THREE.SphereGeometry(0.28, 10, 10);
+ [-0.9, 0.9].forEach(zOff => {
+ const hl = new THREE.Mesh(hlGeo, headlightMat);
+ hl.position.set(4.0, 1.4, zOff);
+ group.add(hl);
+ });
+
+ // Tail lights — small dim red discs
+ const tailMat = new THREE.MeshStandardMaterial({
+ color: 0x661010, emissive: 0xff2a2a, emissiveIntensity: 0.25
+ });
+ [-0.9, 0.9].forEach(zOff => {
+ const t = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 8), tailMat);
+ t.position.set(-4.0, 1.4, zOff);
+ group.add(t);
+ });
+
+ body.userData.busRef = null; // populated after spawn
+ group.userData.body = body;
+ group.userData.bodyMat = bodyMat;
+ group.userData.headlightMat = headlightMat;
+ return group;
+}
+
+// ── Generic traffic car ─────────────────────────────────────
+const TRAFFIC_COLORS = [0xeeeeee, 0x222222, 0xc0392b, 0x2980b9, 0x27ae60, 0xf39c12, 0x8e44ad, 0xd35400];
+function makeCar() {
+ const group = new THREE.Group();
+ const color = TRAFFIC_COLORS[Math.floor(Math.random() * TRAFFIC_COLORS.length)];
+ const body = new THREE.Mesh(
+ new THREE.BoxGeometry(3.6, 1.2, 1.8),
+ new THREE.MeshStandardMaterial({ color, metalness: 0.4, roughness: 0.5 })
+ );
+ body.position.y = 0.9;
+ body.castShadow = true;
+ group.add(body);
+ const cab = new THREE.Mesh(
+ new THREE.BoxGeometry(2.0, 0.9, 1.6),
+ new THREE.MeshStandardMaterial({ color: 0x111111, metalness: 0.6, roughness: 0.3 })
+ );
+ cab.position.set(-0.2, 1.85, 0);
+ group.add(cab);
return group;
}
-const bus = makeBus();
-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());
- const span = 160;
- routeStops = r.stops.map((s, i) => {
- const t = r.stops.length === 1 ? 0.5 : i / (r.stops.length - 1);
- const x = -span / 2 + t * span;
- return { ...s, x, z: 0 };
+const TRAFFIC_POOL = 80;
+function buildTrafficPool() {
+ for (let i = 0; i < TRAFFIC_POOL; i++) {
+ const c = makeCar();
+ c.userData.t = Math.random();
+ c.userData.speed = 0.06 + Math.random() * 0.10;
+ c.userData.dir = Math.random() < 0.5 ? 1 : -1;
+ // Spread across lanes (-9..+9), nudged off bus lanes
+ c.userData.lane = (Math.random() * 18) - 9;
+ c.position.z = c.userData.lane;
+ c.rotation.y = c.userData.dir < 0 ? 0 : Math.PI;
+ c.visible = false;
+ scene.add(c);
+ state.trafficCars.push(c);
+ }
+}
+
+function applyTrafficDensity(pct) {
+ const visible = Math.round((pct / 100) * TRAFFIC_POOL);
+ state.trafficCars.forEach((c, i) => { c.visible = i < visible; });
+ if (trafficValEl) trafficValEl.textContent = `${pct}%`;
+ if (trafficPhaseEl) {
+ const label =
+ pct < 10 ? 'Empty road' :
+ pct < 35 ? 'Light flow' :
+ pct < 65 ? 'Medium flow' :
+ pct < 90 ? 'Heavy flow' : 'Gridlock';
+ trafficPhaseEl.textContent = label;
+ }
+}
+
+// ── Time-of-day application ─────────────────────────────────
+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
+ const sunAngle = ((m - 360) / 720) * Math.PI;
+ const r = 90;
+ const sunY = Math.sin(sunAngle) * r;
+ sun.position.set(Math.cos(sunAngle) * r, sunY, 30);
+ sun.visible = sunY > -5;
+
+ // Headlights threshold: on when sun is low or below horizon (sunY < 12)
+ const headlightsOn = sunY < 12;
+ if (headlightsOn !== state.headlightsOn) {
+ state.headlightsOn = headlightsOn;
+ state.buses.forEach(b => {
+ b.group.userData.headlightMat.emissiveIntensity = headlightsOn ? 1.6 : 0;
+ });
+ }
+
+ // HUD
+ const hh = String(Math.floor(m / 60)).padStart(2, '0');
+ const mm = String(Math.floor(m % 60)).padStart(2, '0');
+ if (timeValEl) timeValEl.textContent = `${hh}:${mm}`;
+ if (timePhaseEl) timePhaseEl.textContent = (t < 0.5 ? lo.name : hi.name);
+ if (timeHeadEl) timeHeadEl.textContent = headlightsOn ? 'headlights on' : 'headlights off';
+
+ state.timeMinutes = m;
+}
+
+// ── Route + bus spawn ───────────────────────────────────────
+async function loadRoutes() {
+ const r = await fetch('/api/routes').then(r => r.json());
+ state.routes = (r.routes || []).map(route => {
+ const stops = route.stops.map((s, i) => {
+ const tt = route.stops.length === 1 ? 0.5 : i / (route.stops.length - 1);
+ return { ...s, x: X_MIN + tt * ROAD_SPAN, z: route.laneZ };
+ });
+ const hexNum = parseInt((route.color || '#888').replace('#', ''), 16);
+ return {
+ ...route,
+ stops,
+ xMin: X_MIN, xMax: X_MAX,
+ hexColor: hexNum
+ };
+ });
+ state.routes.forEach(rt => state.enabledRoutes.add(rt.id));
+}
+
+function buildStopMarkers() {
+ state.routes.forEach(rt => {
+ rt.markers = [];
+ rt.stops.forEach(s => {
+ const m = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.55, 0.55, 3.6, 12),
+ new THREE.MeshStandardMaterial({ color: rt.hexColor, emissive: rt.hexColor, emissiveIntensity: 0.25 })
+ );
+ m.position.set(s.x, 1.8, s.z + (rt.laneZ < 0 ? -2.2 : 2.2));
+ m.castShadow = true;
+ scene.add(m);
+ rt.markers.push(m);
+ });
});
+}
- 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 })
- );
- m.position.set(s.x, 2, s.z + 5);
- m.castShadow = true;
- scene.add(m);
- stopMarkers.push(m);
-
- const btn = document.createElement('button');
- btn.textContent = s.label;
- 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 spawnBuses() {
+ state.routes.forEach(rt => {
+ const count = rt.line === '750' ? 2 : 2; // 2 buses per route
+ for (let i = 0; i < count; i++) {
+ const bus = {
+ group: makeBus(rt.hexColor),
+ route: rt,
+ t: (i / count) + Math.random() * 0.05,
+ speed: rt.line === '750' ? 0.04 : 0.025, // rapid bus is faster
+ dir: 1
+ };
+ bus.group.userData.body.userData.busRef = bus;
+ bus.group.position.z = rt.laneZ;
+ bus.group.rotation.y = -Math.PI / 2;
+ scene.add(bus.group);
+ state.buses.push(bus);
}
});
}
-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);
+// ── Route legend rendering ──────────────────────────────────
+function renderLegend() {
+ if (!routeListEl) return;
+ routeListEl.innerHTML = '';
+ state.routes.forEach(rt => {
+ const li = document.createElement('li');
+ li.className = 'route';
+ li.dataset.routeId = rt.id;
+ li.title = `Toggle ${rt.name}`;
+ li.innerHTML = `
+ <span class="swatch" style="background:${rt.color}"></span>
+ <span class="label">
+ <div class="line">${rt.line}</div>
+ <div class="name">${rt.name.replace(/^Metro \d+\s*[—-]\s*/, '')}</div>
+ </span>
+ <span class="toggle" aria-hidden="true"></span>
+ `;
+ li.addEventListener('click', () => toggleRoute(rt.id));
+ routeListEl.appendChild(li);
});
+ syncLegendVisibility();
}
-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);
+function toggleRoute(id) {
+ if (state.enabledRoutes.has(id)) state.enabledRoutes.delete(id);
+ else state.enabledRoutes.add(id);
+ syncLegendVisibility();
+
+ // If selected bus belongs to a now-disabled route, clear selection
+ if (state.selectedBus && !state.enabledRoutes.has(state.selectedBus.route.id)) {
+ state.selectedBus = null;
+ renderInfo(null);
+ }
}
-window.addEventListener('resize', () => {
- camera.aspect = window.innerWidth / window.innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize(window.innerWidth, window.innerHeight);
+function syncLegendVisibility() {
+ state.routes.forEach(rt => {
+ const enabled = state.enabledRoutes.has(rt.id);
+ const li = routeListEl?.querySelector(`li.route[data-route-id="${rt.id}"]`);
+ if (li) li.classList.toggle('off', !enabled);
+ rt.markers?.forEach(m => { m.visible = enabled; });
+ });
+ state.buses.forEach(b => { b.group.visible = state.enabledRoutes.has(b.route.id); });
+}
+
+// ── Info panel rendering ────────────────────────────────────
+function nextStopFor(bus) {
+ const x = bus.group.position.x;
+ const stops = bus.route.stops;
+ // Forward direction along x: find first stop with stop.x > x; if none, wrap to first
+ let next = stops.find(s => s.x > x);
+ if (!next) next = stops[0];
+ const idx = stops.indexOf(next);
+ return { stop: next, idx };
+}
+
+function renderInfo(bus) {
+ if (!infoBodyEl) return;
+ if (!bus) {
+ infoBodyEl.classList.add('empty');
+ infoBodyEl.innerHTML = 'Click a bus or search a line to inspect.';
+ infoBodyEl.style.removeProperty('--info-color');
+ return;
+ }
+ infoBodyEl.classList.remove('empty');
+ infoBodyEl.style.setProperty('--info-color', bus.route.color);
+ const { stop: next, idx: nextIdx } = nextStopFor(bus);
+ const stopsHtml = bus.route.stops
+ .map((s, i) => `<li class="${i === nextIdx ? 'next' : ''}">${escapeHtml(s.label)}</li>`)
+ .join('');
+ infoBodyEl.innerHTML = `
+ <div class="route-tag" style="background:${bus.route.color}">Line ${escapeHtml(bus.route.line)}</div>
+ <div class="bus-name">${escapeHtml(bus.route.name)}</div>
+ <div class="next-stop">
+ <div class="lbl">Next stop</div>
+ <div class="val">${escapeHtml(next.label)}</div>
+ </div>
+ <ol class="stops">${stopsHtml}</ol>
+ `;
+}
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+}
+
+// ── Selection + camera fly ──────────────────────────────────
+function selectBus(bus) {
+ state.selectedBus = bus;
+ renderInfo(bus);
+}
+
+function flyToBus(bus) {
+ if (!bus) return;
+ const p = bus.group.position;
+ state.flyTo = {
+ pos: new THREE.Vector3(p.x + 18, 18, p.z + 28),
+ target: new THREE.Vector3(p.x, 1.5, p.z)
+ };
+}
+
+// Click-to-select via raycaster
+const raycaster = new THREE.Raycaster();
+const ndc = new THREE.Vector2();
+renderer.domElement.addEventListener('click', (ev) => {
+ const rect = renderer.domElement.getBoundingClientRect();
+ ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ raycaster.setFromCamera(ndc, camera);
+ const targets = state.buses
+ .filter(b => state.enabledRoutes.has(b.route.id))
+ .map(b => b.group.userData.body);
+ const hits = raycaster.intersectObjects(targets, false);
+ if (hits.length) {
+ const bus = hits[0].object.userData.busRef;
+ if (bus) selectBus(bus);
+ }
});
+// ── Search by Metro line number ─────────────────────────────
+function searchLine(query) {
+ const q = String(query || '').trim().replace(/[^0-9]/g, '');
+ if (!q) return;
+ const route = state.routes.find(r => r.line === q);
+ if (!route) return;
+ // Re-enable the route if user toggled it off
+ if (!state.enabledRoutes.has(route.id)) {
+ state.enabledRoutes.add(route.id);
+ syncLegendVisibility();
+ }
+ // Find the bus on that route nearest the camera
+ const camPos = camera.position;
+ let nearest = null, best = Infinity;
+ state.buses.forEach(b => {
+ if (b.route.id !== route.id) return;
+ const d = b.group.position.distanceTo(camPos);
+ if (d < best) { best = d; nearest = b; }
+ });
+ if (nearest) {
+ selectBus(nearest);
+ flyToBus(nearest);
+ }
+}
+
+if (searchEl) {
+ searchEl.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter') { ev.preventDefault(); searchLine(searchEl.value); }
+ });
+ searchEl.addEventListener('change', () => searchLine(searchEl.value));
+}
+
+// ── Slider wiring ───────────────────────────────────────────
+if (timeSlider) {
+ timeSlider.addEventListener('input', () => applyTimeOfDay(Number(timeSlider.value)));
+}
+if (trafficSlider) {
+ trafficSlider.addEventListener('input', () => {
+ state.trafficDensity = Number(trafficSlider.value);
+ applyTrafficDensity(state.trafficDensity);
+ });
+}
+
+// ── Resize handling (renderer follows #app, not window) ─────
+function resize() {
+ const w = app.clientWidth || window.innerWidth;
+ const h = app.clientHeight || window.innerHeight;
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ renderer.setSize(w, h, false);
+}
+window.addEventListener('resize', resize);
+if (typeof ResizeObserver !== 'undefined') new ResizeObserver(resize).observe(app);
+resize();
+
+// ── Animate ─────────────────────────────────────────────────
const clock = new THREE.Clock();
function animate() {
- const dt = clock.getDelta();
- if (routeStops.length >= 2) {
- busT += dt * 0.05;
- if (busT > 1) busT = 0;
- const xMin = routeStops[0].x;
- const xMax = routeStops[routeStops.length - 1].x;
- 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; }
+ const dt = Math.min(clock.getDelta(), 0.1);
+
+ // Buses cruise back and forth along x
+ state.buses.forEach(b => {
+ b.t += dt * b.speed * b.dir;
+ if (b.t >= 1) { b.t = 1; b.dir = -1; b.group.rotation.y = Math.PI / 2; }
+ if (b.t <= 0) { b.t = 0; b.dir = 1; b.group.rotation.y = -Math.PI / 2; }
+ b.group.position.x = b.route.xMin + (b.route.xMax - b.route.xMin) * b.t;
+ b.group.position.z = b.route.laneZ;
+ });
+
+ // Traffic cars
+ state.trafficCars.forEach(c => {
+ if (!c.visible) return;
+ c.userData.t += dt * c.userData.speed;
+ if (c.userData.t > 1) c.userData.t -= 1;
+ const xt = c.userData.t;
+ c.position.x = c.userData.dir > 0
+ ? X_MIN + xt * ROAD_SPAN
+ : X_MAX - xt * ROAD_SPAN;
+ });
+
+ // Camera fly-to lerp
+ if (state.flyTo) {
+ camera.position.lerp(state.flyTo.pos, 0.10);
+ controls.target.lerp(state.flyTo.target, 0.10);
+ if (camera.position.distanceTo(state.flyTo.pos) < 0.5) state.flyTo = null;
+ }
+
+ // Live-update info panel "next stop" as the selected bus moves
+ if (state.selectedBus && state.enabledRoutes.has(state.selectedBus.route.id)) {
+ // Light-touch refresh: only re-render when next-stop index flips
+ const cur = nextStopFor(state.selectedBus).idx;
+ if (cur !== state.selectedBus._lastNext) {
+ state.selectedBus._lastNext = cur;
+ renderInfo(state.selectedBus);
}
- setActiveStop(nearest);
- if (legendProgressEl) legendProgressEl.textContent = `${Math.round(busT * 100)}%`;
}
+
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
-loadRoute().then(animate).catch(err => {
- console.error('route load failed', err);
- animate();
-});
+// ── Boot ────────────────────────────────────────────────────
+buildTrafficPool();
+applyTrafficDensity(state.trafficDensity);
+applyTimeOfDay(state.timeMinutes);
+
+loadRoutes()
+ .then(() => {
+ buildStopMarkers();
+ spawnBuses();
+ renderLegend();
+ syncLegendVisibility();
+ // Ensure headlights pick up initial sun position
+ state.buses.forEach(b => {
+ b.group.userData.headlightMat.emissiveIntensity = state.headlightsOn ? 1.6 : 0;
+ });
+ animate();
+ })
+ .catch(err => {
+ console.error('routes load failed', err);
+ animate();
+ });
diff --git a/public/models/ATTRIBUTION.md b/public/models/ATTRIBUTION.md
new file mode 100644
index 0000000..eab40a3
--- /dev/null
+++ b/public/models/ATTRIBUTION.md
@@ -0,0 +1,65 @@
+# Vehicle Model Attribution
+
+All glTF models in this directory were sourced from [Poly Pizza](https://poly.pizza),
+which redistributes Google Poly assets and accepts community uploads. Every
+model below is licensed under **Creative Commons Attribution 3.0**
+(<https://creativecommons.org/licenses/by/3.0/>) — free for commercial and
+personal use, attribution required.
+
+This file IS that attribution. Keep it shipped alongside the models.
+
+## Buses (3)
+
+| File | Source title | Author | Source URL | License | Size |
+|---|---|---|---|---|---|
+| `bus-metro-articulated.glb` | Bus | Poly by Google | https://poly.pizza/m/9Rj7a89ypPQ | CC-BY 3.0 | 312 KB |
+| `bus-ladot-dash.glb` | Montreal Bus | Nick Ladd | https://poly.pizza/m/fFCCghvRImG | CC-BY 3.0 | 380 KB |
+| `bus-school.glb` | Schoolbus | Poly by Google | https://poly.pizza/m/8xacyNqdJ3t | CC-BY 3.0 | 231 KB |
+
+`bus-metro-articulated.glb` and `bus-ladot-dash.glb` are stand-ins —
+they're city-transit-bus models being repurposed for LA Metro orange
+articulated and LADOT DASH livery roles in the scene. Apply per-instance
+material overrides (orange/black for Metro, green/white for DASH) at
+runtime in the renderer rather than baking new textures.
+
+## Cars (5)
+
+| File | Source title | Author | Source URL | License | Size |
+|---|---|---|---|---|---|
+| `car-sedan.glb` | Car | Quaternius | https://poly.pizza/m/Cz6yDaUcM9 | CC-BY 3.0 | 161 KB |
+| `car-suv.glb` | SUV | Quaternius | https://poly.pizza/m/xsMtZhBkxL | CC-BY 3.0 | 177 KB |
+| `car-sports.glb` | Sports Car | Quaternius | https://poly.pizza/m/1mkmFkAz5v | CC-BY 3.0 | 171 KB |
+| `car-pickup.glb` | Pickup Truck | Quaternius | https://poly.pizza/m/qn4grQgHm8 | CC-BY 3.0 | 267 KB |
+| `car-delivery-van.glb` | Van | Poly by Google | https://poly.pizza/m/aT_24cDaW1a | CC-BY 3.0 | 43 KB |
+
+## License compliance checklist
+
+CC-BY 3.0 requires:
+
+- [x] **Attribution** — author name credited above for each file.
+- [x] **Source URL** — link back to the original model page on poly.pizza.
+- [x] **License URL** — `https://creativecommons.org/licenses/by/3.0/`.
+- [x] **No implication of endorsement** — none of the listed authors endorse this project.
+- [x] If you modify a model (rescale, recolor, replace texture), note the
+ change here and keep the original credit. (No modifications as of
+ initial commit.)
+
+If the project ever ships in a context where in-app credits are
+practical (about page, splash, settings → credits), echo this list there
+too. The file-on-disk attribution above is the legal minimum; an in-app
+credit screen is best practice.
+
+## Source authors
+
+- **Quaternius** — https://quaternius.com/ — releases prolific CC0 + CC-BY game-asset packs.
+- **Poly by Google** — Google Poly was Google's 3D model marketplace; assets archived under CC-BY by the Internet Archive / Poly Pizza after Poly's 2021 shutdown.
+- **Nick Ladd** — community contributor on Poly Pizza.
+
+## Performance budget
+
+All eight models are under 500 KB each (largest = 380 KB,
+`bus-ladot-dash.glb`). Total directory weight ≈ 1.7 MB. With instanced
+rendering in Three.js, 50+ vehicles on screen at 60 fps is well within
+budget on any current desktop GPU. For mobile, prefer
+`InstancedMesh` per model + a max of ~30 active vehicles in the camera
+frustum.
diff --git a/public/models/bus-ladot-dash.glb b/public/models/bus-ladot-dash.glb
new file mode 100644
index 0000000..1e21297
Binary files /dev/null and b/public/models/bus-ladot-dash.glb differ
diff --git a/public/models/bus-metro-articulated.glb b/public/models/bus-metro-articulated.glb
new file mode 100644
index 0000000..bf10865
Binary files /dev/null and b/public/models/bus-metro-articulated.glb differ
diff --git a/public/models/bus-school.glb b/public/models/bus-school.glb
new file mode 100644
index 0000000..1cd2f53
Binary files /dev/null and b/public/models/bus-school.glb differ
diff --git a/public/models/car-delivery-van.glb b/public/models/car-delivery-van.glb
new file mode 100644
index 0000000..23d902b
Binary files /dev/null and b/public/models/car-delivery-van.glb differ
diff --git a/public/models/car-pickup.glb b/public/models/car-pickup.glb
new file mode 100644
index 0000000..17344bb
Binary files /dev/null and b/public/models/car-pickup.glb differ
diff --git a/public/models/car-sedan.glb b/public/models/car-sedan.glb
new file mode 100644
index 0000000..8a7cace
Binary files /dev/null and b/public/models/car-sedan.glb differ
diff --git a/public/models/car-sports.glb b/public/models/car-sports.glb
new file mode 100644
index 0000000..dd5fd11
Binary files /dev/null and b/public/models/car-sports.glb differ
diff --git a/public/models/car-suv.glb b/public/models/car-suv.glb
new file mode 100644
index 0000000..db24770
Binary files /dev/null and b/public/models/car-suv.glb differ
← b2ea9d2 integrate multi-route data + new top header (search/brand/th
·
back to Ventura Bus 3d
·
add /models-preview.html: orbit-camera grid of all 8 sourced 6a8f84b →