← 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 => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
  }[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();
  });