← back to Ventura Bus 3d
public/js/main.js
569 lines
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ── 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, 260);
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.shadowMap.enabled = true;
app.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.target.set(0, 0, 0);
const ambient = new THREE.HemisphereLight(0xa6c8ff, 0x1a1a1a, 0.6);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffe7b3, 1.0);
sun.position.set(50, 80, 30);
sun.castShadow = true;
scene.add(sun);
// ── 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(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(200, 22),
new THREE.MeshStandardMaterial({ color: 0x2a2d33, roughness: 0.9 })
);
road.rotation.x = -Math.PI / 2;
road.position.y = 0.01;
scene.add(road);
// 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 }
];
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 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: 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 => {
[-1.1, 1.1].forEach(z => {
const w = new THREE.Mesh(wheelGeo, wheelMat);
w.rotation.x = Math.PI / 2;
w.position.set(x, 0.5, z);
w.castShadow = true;
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 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);
});
});
}
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);
}
});
}
// ── 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 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);
}
}
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;
// "Next" depends on travel direction. Buses oscillate along x, so a
// westbound bus's next stop is the nearest stop with smaller x.
// 0.5 m hysteresis avoids flapping when the bus is sitting on a stop.
let next;
if (bus.dir >= 0) {
next = stops.find(s => s.x > x + 0.5);
if (!next) next = stops[stops.length - 1];
} else {
for (let i = stops.length - 1; i >= 0; i--) {
if (stops[i].x < x - 0.5) { next = stops[i]; break; }
}
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 = 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);
}
}
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(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();
});