← 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;