← back to Ventura Bus 3d
public/js/traffic.js
559 lines
// Ventura traffic animation system.
//
// - Buses follow polylines built from /api/routes (lat/lng → world XZ).
// - Variable cruise speed 25–40 mph, 3–5 s dwell at each stop.
// - Multiple buses per route, pre-populated at evenly-spaced offsets.
// - Ambient cars spawn at world edges, follow lanes parallel to the
// road, occasionally lane-change to a partner lane, despawn at the
// far edge.
// - Simple collision avoidance: each vehicle inspects the nearest
// vehicle ahead on its own path; if the gap is < 4 m the target
// speed is clamped to gap/4 × ahead-speed (and to 0 inside 1 m).
// - Update loop uses a fixed 1/60 s timestep accumulator, decoupled
// from the render frame rate.
//
// If /api/routes can't be reached the module falls back to
// FALLBACK_ROUTE_DATA so the animation still runs in isolation.
import * as THREE from 'three';
const MPH_TO_MPS = 0.44704;
const FIXED_STEP = 1 / 60;
const MAX_STEPS_PER_FRAME = 5;
const WORLD_WIDTH = 180;
const WORLD_HEIGHT = 4;
const DEFAULT_BOUNDS = { west: -118.67, east: -118.37, south: 34.14, north: 34.19 };
const FALLBACK_ROUTE_DATA = {
bounds: DEFAULT_BOUNDS,
routes: [
{
id: 'fallback-east', line: '—', name: 'Eastbound', color: '#ffb000', laneZ: -2,
stops: [
{ id: 'wh', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 },
{ id: 'tz', label: 'Tarzana', lng: -118.5530, lat: 34.1700 },
{ id: 'en', label: 'Encino', lng: -118.5010, lat: 34.1590 },
{ id: 'so', label: 'Sherman Oaks', lng: -118.4490, lat: 34.1510 },
{ id: 'sc', label: 'Studio City', lng: -118.3960, lat: 34.1430 }
]
},
{
id: 'fallback-west', line: '—', name: 'Westbound', color: '#4ea1ff', laneZ: 2,
stops: [
{ id: 'sc2', label: 'Studio City', lng: -118.3960, lat: 34.1430 },
{ id: 'so2', label: 'Sherman Oaks', lng: -118.4490, lat: 34.1510 },
{ id: 'en2', label: 'Encino', lng: -118.5010, lat: 34.1590 },
{ id: 'tz2', label: 'Tarzana', lng: -118.5530, lat: 34.1700 },
{ id: 'wh2', label: 'Woodland Hills', lng: -118.6050, lat: 34.1683 }
]
}
]
};
const DEFAULT_AMBIENT_LANES = [
{ id: 'east-outer', polyline: [[-95, -3.4], [95, -3.4]], partnerId: 'east-inner', speedMph: { min: 30, max: 42 }, spawnSec: { min: 4, max: 9 } },
{ id: 'east-inner', polyline: [[-95, -2.6], [95, -2.6]], partnerId: 'east-outer', speedMph: { min: 25, max: 38 }, spawnSec: { min: 5, max: 11 } },
{ id: 'west-inner', polyline: [[ 95, 2.6], [-95, 2.6]], partnerId: 'west-outer', speedMph: { min: 25, max: 38 }, spawnSec: { min: 5, max: 11 } },
{ id: 'west-outer', polyline: [[ 95, 3.4], [-95, 3.4]], partnerId: 'west-inner', speedMph: { min: 30, max: 42 }, spawnSec: { min: 4, max: 9 } }
];
const CAR_PALETTE = [0xc0392b, 0x16a085, 0x2c3e50, 0xf39c12, 0x8e44ad, 0xecf0f1, 0x34495e, 0xd35400, 0x2980b9, 0x27ae60];
class Path {
constructor(coords) {
this.points = coords.map(([x, z]) => ({ x, z }));
this.segLengths = [];
this.cumLengths = [0];
this.totalLength = 0;
for (let i = 1; i < this.points.length; i++) {
const a = this.points[i - 1], b = this.points[i];
const len = Math.hypot(b.x - a.x, b.z - a.z);
this.segLengths.push(len);
this.totalLength += len;
this.cumLengths.push(this.totalLength);
}
}
distanceAtIndex(idx) {
const i = Math.max(0, Math.min(idx | 0, this.cumLengths.length - 1));
return this.cumLengths[i];
}
sample(d, out) {
const total = this.totalLength;
let dd = total > 0 ? ((d % total) + total) % total : 0;
if (this.segLengths.length === 0) {
out.x = this.points[0].x; out.z = this.points[0].z; out.heading = 0;
return out;
}
let seg = 0;
while (seg < this.segLengths.length - 1 && this.cumLengths[seg + 1] < dd) seg++;
const segLen = this.segLengths[seg] || 1;
const t = (dd - this.cumLengths[seg]) / segLen;
const a = this.points[seg], b = this.points[seg + 1];
const dx = b.x - a.x, dz = b.z - a.z;
out.x = a.x + dx * t;
out.z = a.z + dz * t;
// Mesh convention: forward = +X. atan2(-dz, dx) yields the
// rotation.y that aligns +X with (dx, dz).
out.heading = Math.atan2(-dz, dx);
return out;
}
}
const _s1 = { x: 0, z: 0, heading: 0 };
const _s2 = { x: 0, z: 0, heading: 0 };
function rand(min, max) { return min + Math.random() * (max - min); }
function parseColor(c, fallback = 0xffb000) {
if (typeof c === 'number') return c;
if (typeof c === 'string') {
const n = parseInt(c.replace('#', ''), 16);
return Number.isFinite(n) ? n : fallback;
}
return fallback;
}
function makeBusMesh(color = 0xffb000) {
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(8, 2.4, 2.6),
new THREE.MeshStandardMaterial({ color, metalness: 0.2, roughness: 0.5 })
);
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 })
);
stripe.position.y = 1.2;
group.add(stripe);
const win = new THREE.Mesh(
new THREE.BoxGeometry(7.5, 0.9, 2.4),
new THREE.MeshStandardMaterial({ color: 0x101418, metalness: 0.7, roughness: 0.15, emissive: 0x0a0d10 })
);
win.position.y = 2.3;
group.add(win);
const wheelGeo = new THREE.CylinderGeometry(0.5, 0.5, 0.4, 12);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a });
for (const x of [-2.5, 2.5]) {
for (const z of [-1.1, 1.1]) {
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);
}
}
return group;
}
function makeCarMesh(color) {
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(4.2, 1.1, 1.8),
new THREE.MeshStandardMaterial({ color, metalness: 0.35, roughness: 0.4 })
);
body.position.y = 0.75; body.castShadow = true;
group.add(body);
const cabin = new THREE.Mesh(
new THREE.BoxGeometry(2.2, 0.7, 1.65),
new THREE.MeshStandardMaterial({ color: 0x0e1217, metalness: 0.6, roughness: 0.18 })
);
cabin.position.set(-0.2, 1.55, 0);
group.add(cabin);
const wheelGeo = new THREE.CylinderGeometry(0.34, 0.34, 0.3, 10);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0a });
for (const x of [-1.4, 1.4]) {
for (const z of [-0.85, 0.85]) {
const w = new THREE.Mesh(wheelGeo, wheelMat);
w.rotation.x = Math.PI / 2;
w.position.set(x, 0.34, z);
group.add(w);
}
}
return group;
}
class Bus {
constructor({ route, path, stopDists, cruiseMps, dwellRange, mesh, initialDist, initialStopIdx }) {
this.route = route;
this.path = path;
this.stopDists = stopDists;
this.cruiseMps = cruiseMps;
this.dwellRange = dwellRange;
this.mesh = mesh;
this.dist = initialDist;
this.speed = 0;
this.state = 'cruise';
this.dwellTimer = 0;
this.nextStopIdx = initialStopIdx;
this.gapAhead = Infinity;
this.speedAhead = 0;
}
distToNextStop() {
const target = this.stopDists[this.nextStopIdx];
let d = target - this.dist;
if (d < -0.01) d += this.path.totalLength;
return d;
}
step(dt) {
if (this.state === 'dwell') {
this.dwellTimer -= dt;
this.speed = 0;
if (this.dwellTimer <= 0) {
this.nextStopIdx = (this.nextStopIdx + 1) % this.stopDists.length;
this.cruiseMps = rand(this.route.speedMpsMin, this.route.speedMpsMax);
this.state = 'cruise';
}
this._writeMesh();
return;
}
const distToStop = this.distToNextStop();
const BRAKE_DIST = 12;
let target = this.cruiseMps;
if (distToStop < BRAKE_DIST) {
target = Math.max(0, this.cruiseMps * (distToStop / BRAKE_DIST));
}
if (this.gapAhead < 4) {
if (this.gapAhead < 1) target = 0;
else target = Math.min(target, this.speedAhead * (this.gapAhead / 4));
}
const accel = 4, decel = 6;
if (this.speed < target) this.speed = Math.min(target, this.speed + accel * dt);
else if (this.speed > target) this.speed = Math.max(target, this.speed - decel * dt);
this.dist += this.speed * dt;
if (this.dist >= this.path.totalLength) this.dist -= this.path.totalLength;
if (distToStop < 0.5 && this.speed < 0.3) {
this.state = 'dwell';
this.dwellTimer = rand(this.dwellRange[0], this.dwellRange[1]);
this.dist = this.stopDists[this.nextStopIdx];
this.speed = 0;
}
this._writeMesh();
}
_writeMesh() {
this.path.sample(this.dist, _s1);
this.mesh.position.set(_s1.x, 0, _s1.z);
this.mesh.rotation.y = _s1.heading;
}
}
class AmbientCar {
constructor({ lane, path, partnerPath, cruiseMps, mesh }) {
this.lane = lane;
this.path = path;
this.partnerPath = partnerPath || null;
this.cruiseMps = cruiseMps;
this.mesh = mesh;
this.dist = 0;
this.speed = cruiseMps * 0.85;
this.alive = true;
this.gapAhead = Infinity;
this.speedAhead = 0;
this.laneOffset = 0;
this.laneChangeT = 0;
this.laneChangeDir = 0;
this.laneChangeCooldown = rand(3, 9);
}
step(dt) {
let target = this.cruiseMps;
if (this.gapAhead < 4) {
if (this.gapAhead < 1) target = 0;
else target = Math.min(target, this.speedAhead * (this.gapAhead / 4));
}
const accel = 5, decel = 7;
if (this.speed < target) this.speed = Math.min(target, this.speed + accel * dt);
else if (this.speed > target) this.speed = Math.max(target, this.speed - decel * dt);
this.dist += this.speed * dt;
if (this.dist >= this.path.totalLength) {
this.alive = false;
return;
}
this.laneChangeCooldown -= dt;
if (this.partnerPath && this.laneChangeT === 0 && this.laneChangeCooldown <= 0) {
if (Math.random() < 0.25 * dt) {
this.laneChangeT = 0.0001;
this.laneChangeDir = this.laneOffset === 0 ? 1 : -1;
} else {
this.laneChangeCooldown = rand(2, 4);
}
}
if (this.laneChangeT > 0) {
this.laneChangeT += dt / 1.5;
if (this.laneChangeT >= 1) {
this.laneOffset = this.laneChangeDir === 1 ? 1 : 0;
this.laneChangeT = 0;
this.laneChangeCooldown = rand(8, 18);
}
}
this._writeMesh();
}
_writeMesh() {
this.path.sample(this.dist, _s1);
let x = _s1.x, z = _s1.z;
const heading = _s1.heading;
if (this.partnerPath && (this.laneOffset > 0 || this.laneChangeT > 0)) {
this.partnerPath.sample(this.dist, _s2);
let blend = this.laneOffset;
if (this.laneChangeT > 0) {
const eased = 0.5 - 0.5 * Math.cos(Math.PI * Math.min(1, this.laneChangeT));
blend = this.laneChangeDir === 1 ? eased : (1 - eased);
}
x = x + (_s2.x - x) * blend;
z = z + (_s2.z - z) * blend;
}
this.mesh.position.set(x, 0, z);
this.mesh.rotation.y = heading;
}
}
class TrafficSystem {
constructor(scene, opts = {}) {
this.scene = scene;
this.opts = opts;
this.routes = [];
this.lanes = [];
this.buses = [];
this.ambientCars = [];
this.accumulator = 0;
this.simTime = 0;
this.spawnTimers = new Map();
this._loaded = false;
this._byRoute = new Map();
this._byLane = new Map();
}
async load(url = '/api/routes') {
let data = null;
try {
const res = await fetch(url, { cache: 'no-store' });
if (res.ok) data = await res.json();
} catch (_) { /* fall through */ }
if (!data || !Array.isArray(data.routes) || data.routes.length === 0) {
console.warn('[traffic] route fetch failed; using fallback stub');
data = FALLBACK_ROUTE_DATA;
}
this._build(data);
this._loaded = true;
return this;
}
_project(lat, lng, bounds, laneZ) {
const lonSpan = bounds.east - bounds.west || 1;
const latSpan = bounds.north - bounds.south || 1;
const u = (lng - bounds.west) / lonSpan;
const v = (lat - bounds.south) / latSpan;
const x = (u - 0.5) * WORLD_WIDTH;
const dz = -(v - 0.5) * WORLD_HEIGHT;
return [x, dz + (laneZ || 0)];
}
_build(data) {
const bounds = data.bounds || DEFAULT_BOUNDS;
for (const r of data.routes) {
const stops = Array.isArray(r.stops) ? r.stops : [];
if (stops.length < 2) continue;
const polyline = stops.map(s => this._project(s.lat, s.lng, bounds, r.laneZ));
const path = new Path(polyline);
const stopDists = stops.map((_, i) => path.distanceAtIndex(i));
const speedMphMin = r.speedMph?.min ?? 25;
const speedMphMax = r.speedMph?.max ?? 40;
const route = {
id: r.id || r.line || `route-${this.routes.length}`,
line: r.line || r.short_name || '—',
name: r.name || r.long_name || 'Route',
color: parseColor(r.color, 0xffb000),
path,
stops,
stopDists,
speedMpsMin: speedMphMin * MPH_TO_MPS,
speedMpsMax: speedMphMax * MPH_TO_MPS,
dwellRange: [r.dwellSec?.min ?? 3, r.dwellSec?.max ?? 5],
headwaySec: [r.headwaySec?.min ?? 480, r.headwaySec?.max ?? 900],
initialBuses: Math.max(1, r.initialBuses ?? this.opts.busesPerRoute ?? 3)
};
this.routes.push(route);
const N = route.initialBuses;
const spacing = path.totalLength / N;
for (let i = 0; i < N; i++) {
const initialDist = (spacing * i) % path.totalLength;
let nextIdx = 0;
while (nextIdx < stopDists.length && stopDists[nextIdx] < initialDist + 0.01) nextIdx++;
if (nextIdx >= stopDists.length) nextIdx = 0;
const mesh = makeBusMesh(route.color);
this.scene.add(mesh);
const bus = new Bus({
route, path, stopDists,
cruiseMps: rand(route.speedMpsMin, route.speedMpsMax),
dwellRange: route.dwellRange,
mesh,
initialDist,
initialStopIdx: nextIdx
});
bus.speed = bus.cruiseMps;
this.buses.push(bus);
}
}
const laneDefs = (data.ambientLanes && data.ambientLanes.length) ? data.ambientLanes : DEFAULT_AMBIENT_LANES;
for (const l of laneDefs) {
this.lanes.push({
id: l.id,
partnerId: l.partnerId || null,
path: new Path(l.polyline),
speedMps: { min: (l.speedMph?.min ?? 25) * MPH_TO_MPS, max: (l.speedMph?.max ?? 40) * MPH_TO_MPS },
spawnSec: { min: l.spawnSec?.min ?? 4, max: l.spawnSec?.max ?? 10 }
});
}
for (const lane of this.lanes) {
lane.partner = lane.partnerId ? this.lanes.find(l => l.id === lane.partnerId) : null;
this.spawnTimers.set(lane.id, rand(0, lane.spawnSec.max));
}
}
_spawnAmbientCar(lane) {
const color = CAR_PALETTE[(Math.random() * CAR_PALETTE.length) | 0];
const mesh = makeCarMesh(color);
this.scene.add(mesh);
const cruiseMps = rand(lane.speedMps.min, lane.speedMps.max);
const car = new AmbientCar({
lane,
path: lane.path,
partnerPath: lane.partner ? lane.partner.path : null,
cruiseMps,
mesh
});
car.speed = cruiseMps * 0.9;
this.ambientCars.push(car);
}
_stepCollisions() {
this._byRoute.clear();
for (const b of this.buses) {
let arr = this._byRoute.get(b.route.id);
if (!arr) { arr = []; this._byRoute.set(b.route.id, arr); }
arr.push(b);
}
for (const [, arr] of this._byRoute) {
arr.sort((a, b) => a.dist - b.dist);
for (let i = 0; i < arr.length; i++) {
const me = arr[i];
const nx = arr[(i + 1) % arr.length];
if (nx === me) { me.gapAhead = Infinity; me.speedAhead = 0; continue; }
let gap = nx.dist - me.dist;
if (gap <= 0) gap += me.path.totalLength;
me.gapAhead = gap;
me.speedAhead = nx.speed;
}
}
this._byLane.clear();
for (const c of this.ambientCars) {
let arr = this._byLane.get(c.lane.id);
if (!arr) { arr = []; this._byLane.set(c.lane.id, arr); }
arr.push(c);
}
for (const [, arr] of this._byLane) {
arr.sort((a, b) => a.dist - b.dist);
for (let i = 0; i < arr.length; i++) {
const me = arr[i];
const nx = arr[i + 1];
if (nx) { me.gapAhead = nx.dist - me.dist; me.speedAhead = nx.speed; }
else { me.gapAhead = Infinity; me.speedAhead = 0; }
}
}
}
_step(dt) {
this.simTime += dt;
for (const lane of this.lanes) {
let t = (this.spawnTimers.get(lane.id) || 0) - dt;
if (t <= 0) {
const headOK = !this.ambientCars.some(c => c.lane === lane && c.dist < 6);
if (headOK) {
this._spawnAmbientCar(lane);
t = rand(lane.spawnSec.min, lane.spawnSec.max);
} else {
t = 0.5;
}
}
this.spawnTimers.set(lane.id, t);
}
this._stepCollisions();
for (const b of this.buses) b.step(dt);
for (const c of this.ambientCars) c.step(dt);
for (let i = this.ambientCars.length - 1; i >= 0; i--) {
if (!this.ambientCars[i].alive) {
this.scene.remove(this.ambientCars[i].mesh);
this.ambientCars.splice(i, 1);
}
}
}
// Called once per render frame with the wall-clock delta. Steps the
// simulation in fixed 1/60 s ticks regardless of render rate.
update(dt) {
if (!this._loaded) return;
this.accumulator += Math.min(dt, 0.25);
let steps = 0;
while (this.accumulator >= FIXED_STEP && steps < MAX_STEPS_PER_FRAME) {
this._step(FIXED_STEP);
this.accumulator -= FIXED_STEP;
steps++;
}
if (this.accumulator > FIXED_STEP * MAX_STEPS_PER_FRAME) {
this.accumulator = FIXED_STEP;
}
}
dispose() {
for (const b of this.buses) this.scene.remove(b.mesh);
for (const c of this.ambientCars) this.scene.remove(c.mesh);
this.buses = [];
this.ambientCars = [];
this.routes = [];
this.lanes = [];
this.spawnTimers.clear();
}
stats() {
return {
routes: this.routes.length,
buses: this.buses.length,
ambientCars: this.ambientCars.length,
simTime: this.simTime
};
}
}
export async function createTraffic(scene, opts = {}) {
const sys = new TrafficSystem(scene, opts);
await sys.load(opts.dataUrl || '/api/routes');
return sys;
}
export default createTraffic;