← back to Ventura Corridor

public/3d.html

550 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ventura Blvd · 3D · signal corridor</title>
<style>
  :root { --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724; --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a; --metal: #b89968; --metal-glow: #d4b683; --rust: #6b3a2a; --serif: "Cormorant Garamond", Georgia, serif; --sans: -apple-system, "SF Pro Text", system-ui, sans-serif; }
  *, *::before, *::after { box-sizing: border-box; }
  html, body { margin: 0; height: 100%; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; overflow: hidden; }
  header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; padding: 18px 24px; display: flex; justify-content: space-between; align-items: baseline; gap: 24px; pointer-events: none; }
  header h1 { font-family: var(--serif); font-weight: 300; font-size: 22px; margin: 0; letter-spacing: -0.005em; pointer-events: auto; }
  header h1 em { font-style: italic; color: var(--metal); }
  header .stage { font-size: 10px; letter-spacing: .28em; text-transform: uppercase; color: var(--ink-mute); pointer-events: auto; font-style: italic; font-family: var(--serif); }
  .preview { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 100; background: var(--noir-rise); border: 1px solid var(--rule); padding: 18px 24px; max-width: 520px; min-width: 280px; pointer-events: none; opacity: 0; transition: opacity 280ms ease; }
  .preview.show { opacity: 1; pointer-events: auto; }
  .preview .eyebrow { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 8px; }
  .preview .name { font-family: var(--serif); font-size: 22px; color: var(--metal-glow); margin: 0 0 6px; line-height: 1.18; letter-spacing: -.005em; }
  .preview .meta { font-size: 11px; letter-spacing: .04em; color: var(--ink-mute); margin: 0 0 10px; line-height: 1.5; }
  .preview .url { font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: 0; color: var(--metal); word-break: break-all; }
  #scene { position: absolute; inset: 0; }
  .legend { position: fixed; top: 60px; right: 16px; z-index: 100; background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px 18px; font-size: 10px; letter-spacing: .04em; color: var(--ink-mute); line-height: 1.6; max-width: 240px; }
  .legend b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 12px; letter-spacing: 0; }
  .legend .row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
  .legend .swatch { width: 12px; height: 12px; border: 1px solid var(--rule); }
  .legend .kbd { display: inline-block; padding: 1px 5px; border: 1px solid var(--rule); font-family: 'SFMono-Regular', monospace; font-size: 9px; color: var(--ink-soft); background: var(--noir); }
  .home { position: fixed; bottom: 24px; left: 22px; z-index: 100; padding: 10px 16px; border: 1px solid var(--metal); color: var(--metal-glow); font-size: 10px; letter-spacing: .22em; text-transform: uppercase; text-decoration: none; }
  .home:hover { background: var(--metal); color: var(--noir); }
  .footer-disclaim { position: fixed; bottom: 0; left: 0; right: 0; padding: 6px 24px; z-index: 99; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 10px; text-align: center; pointer-events: none; opacity: .65; }
  .cam-mode { position: fixed; top: 60px; left: 22px; z-index: 100; padding: 8px 14px; border: 1px solid var(--rule); color: var(--metal); font-size: 9px; letter-spacing: .28em; text-transform: uppercase; background: var(--noir-rise); }

  /* On-load orientation overlay — 3s auto-fade so first-time visitors get a moment to orient. */
  #orient { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; pointer-events: none; background: radial-gradient(ellipse at center, rgba(10,10,12,0.0) 30%, rgba(10,10,12,0.55) 100%); transition: opacity 700ms cubic-bezier(.16,.84,.3,1); }
  #orient.hide { opacity: 0; }
  #orient .card { font-family: var(--serif); font-style: italic; font-weight: 300; color: var(--metal-glow); text-align: center; padding: 22px 36px; border: 1px solid var(--rule); background: rgba(19,19,22,0.92); backdrop-filter: blur(8px); }
  #orient .l1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -0.005em; }
  #orient .l2 { font-style: normal; font-family: var(--sans); font-weight: 500; font-size: 10px; letter-spacing: .26em; color: var(--ink-mute); text-transform: uppercase; }
  #orient .l2 .kbd { display: inline-block; padding: 1px 6px; margin: 0 4px; border: 1px solid var(--rule); font-family: 'SFMono-Regular', monospace; font-size: 9px; color: var(--ink-soft); background: var(--noir); letter-spacing: 0; text-transform: none; }
</style>
</head>
<body>
<header>
  <h1>Ventura <em>Boulevard</em> · the signal corridor</h1>
  <span class="stage">Three.js</span>
</header>
<div id="orient" aria-hidden="true">
  <div class="card">
    <p class="l1">Drive mode active.</p>
    <p class="l2"><span class="kbd">Space</span> to orbit · <span class="kbd">click</span> any building to expand its site</p>
  </div>
</div>
<div class="cam-mode" id="cam-mode">Drive · auto</div>
<div class="legend">
  <b>How to read this</b><br>
  <div style="margin: 8px 0 4px; font-style: italic; color: var(--ink-soft); font-family: var(--serif); font-size: 11px;">Every front-page signal on Ventura Blvd, lit by SEO score.</div>
  <div class="row"><span class="swatch" style="background: var(--metal-glow);"></span> Independent</div>
  <div class="row"><span class="swatch" style="background: var(--rust);"></span> Franchise</div>
  <div class="row"><span class="swatch" style="background: var(--noir-rise);"></span> Corporate / chain</div>
  <div class="row"><span class="swatch" style="background: #2a2724;"></span> No website / unscored</div>
  <div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--rule);">
    <span class="kbd">Space</span> · drive ⇄ orbit<br>
    <span class="kbd">click</span> a car · expand site
  </div>
</div>
<a class="home" href="/">← 2D map</a>
<a class="home" href="/dw-radar.html" style="left:130px">DW radar →</a>
<a class="home" href="/signals.html" style="left:240px">Signals →</a>
<a class="home" href="/timeline.html" style="left:340px">Timeline →</a>
<a class="home" href="/pantone.html" style="left:450px">Pantone →</a>
<a class="home" href="/headlines.html" style="left:560px">Voices →</a>
<div class="preview" id="preview" role="status">
  <p class="eyebrow" id="pv-eyebrow">—</p>
  <p class="name" id="pv-name">—</p>
  <p class="meta" id="pv-meta">—</p>
  <p class="url" id="pv-url">—</p>
</div>
<p class="footer-disclaim">Directory only. No referrals, no fee-splits, no representation.</p>
<div id="scene"></div>

<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.168.0/build/three.module.js",
    "three/addons/": "https://unpkg.com/three@0.168.0/examples/jsm/"
  }
}
</script>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const root = document.getElementById('scene');
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a0a0c, 0.008);
scene.background = new THREE.Color(0x0a0a0c);

const camera = new THREE.PerspectiveCamera(58, root.clientWidth / root.clientHeight, 0.1, 4000);
camera.position.set(0, 18, 50);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(root.clientWidth, root.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
root.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 6;
controls.maxDistance = 800;
controls.enabled = false;       // start in DRIVE mode

// Lighting
scene.add(new THREE.AmbientLight(0x1c1916, 0.55));
const key = new THREE.DirectionalLight(0xd4b683, 1.15);
key.position.set(160, 200, 80);
scene.add(key);
const rim = new THREE.DirectionalLight(0x4060a0, 0.3);
rim.position.set(-180, 60, -120);
scene.add(rim);

// Ground
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(2400, 800),
  new THREE.MeshStandardMaterial({ color: 0x131316, roughness: 0.95, metalness: 0.1 })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);

// ─── Geo projection: lat/lng → scene units ───
// Choose origin = polyline midpoint. 1 scene unit ≈ 25 m.
const SCALE_METERS_PER_UNIT = 25;
let origin = { lat: 34.15, lng: -118.45 };
function projectLatLng(lat, lng) {
  const dLat = (lat - origin.lat) * 111000;                                 // m north
  const dLng = (lng - origin.lng) * 111000 * Math.cos(origin.lat * Math.PI / 180); // m east
  return new THREE.Vector3(dLng / SCALE_METERS_PER_UNIT, 0, -dLat / SCALE_METERS_PER_UNIT);
}

// ─── Load + build the corridor curve, then everything else ───
const cars = [];
const buildings = [];
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const previewEl = document.getElementById('preview');

function ownershipColor(o) {
  if (o === 'corporate') return 0x131316;
  if (o === 'franchise') return 0x6b3a2a;
  if (o === 'independent') return 0xb89968;
  return 0x2a2724;
}
function ownershipEmissive(o) {
  if (o === 'corporate') return 0x1a1815;
  if (o === 'franchise') return 0x3a1c10;
  if (o === 'independent') return 0x5a4220;
  return 0x0a0a0c;
}

let curve = null;

async function init() {
  // Polyline first — it sets the curve everything else hangs on.
  const poly = await fetch('/api/polyline').then(r => r.json());
  const pts = poly.points_lat_lng || [];
  if (!pts.length) return;
  // Re-center origin on polyline midpoint
  origin = { lat: pts[Math.floor(pts.length/2)][0], lng: pts[Math.floor(pts.length/2)][1] };
  const curvePts = pts.map(([la, ln]) => projectLatLng(la, ln));
  curve = new THREE.CatmullRomCurve3(curvePts, false, 'centripetal', 0.5);

  // Sample the curve into a smooth tube for the road
  const tubeSeg = Math.min(800, curvePts.length * 8);
  const tube = new THREE.TubeGeometry(curve, tubeSeg, 1.4, 8, false);
  const roadMat = new THREE.MeshStandardMaterial({ color: 0x1a1815, roughness: 0.85, metalness: 0.18 });
  const road = new THREE.Mesh(tube, roadMat);
  road.position.y = 0.15;
  scene.add(road);

  // Champagne hairline along the road's centerline (visible from above)
  const linePts = [];
  for (let i = 0; i <= tubeSeg; i++) {
    const p = curve.getPointAt(i / tubeSeg);
    linePts.push(new THREE.Vector3(p.x, 0.32, p.z));
  }
  const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
  const lineMat = new THREE.LineBasicMaterial({ color: 0xd4b683, transparent: true, opacity: 0.78 });
  scene.add(new THREE.Line(lineGeo, lineMat));

  // ─── Mile markers (Roman numerals I-XVII along the curve) ───
  // Total length ≈ length of the Catmull curve in scene units; each segment
  // covers ~25m, so 17 markers = corridor.length / 17 spacing.
  function romanFor(n) {
    const map = ['I','II','III','IV','V','VI','VII','VIII','IX','X','XI','XII','XIII','XIV','XV','XVI','XVII'];
    return map[n - 1] || String(n);
  }
  function makeTextSprite(text, size = 256, fillCSS = '#b89968') {
    const cv = document.createElement('canvas');
    cv.width = size; cv.height = size;
    const ctx = cv.getContext('2d');
    ctx.clearRect(0, 0, size, size);
    ctx.fillStyle = fillCSS;
    ctx.font = `italic 300 ${Math.round(size * 0.62)}px "Cormorant Garamond", Georgia, serif`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, size / 2, size / 2 + size * 0.04);
    const tex = new THREE.CanvasTexture(cv);
    tex.colorSpace = THREE.SRGBColorSpace;
    return new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.92 });
  }
  for (let i = 1; i <= 17; i++) {
    const t = (i - 1) / 16;     // 17 evenly-spaced positions across the polyline
    const p = curve.getPointAt(t);
    const tan = curve.getTangentAt(t);
    const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
    // Floating sprite ~14m off the south side of the road
    const off = 14 / SCALE_METERS_PER_UNIT * 25;     // ~14 scene units
    const spr = new THREE.Sprite(makeTextSprite(romanFor(i)));
    spr.position.set(p.x + perp.x * off, 8, p.z + perp.z * off);
    spr.scale.set(8, 8, 1);
    scene.add(spr);
    // Plinth — a tiny black-marble pedestal
    const plinth = new THREE.Mesh(
      new THREE.BoxGeometry(1.5, 4.5, 1.5),
      new THREE.MeshStandardMaterial({ color: 0x131316, roughness: 0.55, metalness: 0.4 }),
    );
    plinth.position.set(p.x + perp.x * off, 2.25, p.z + perp.z * off);
    scene.add(plinth);
    // Champagne hairline cap on the plinth
    const cap = new THREE.Mesh(
      new THREE.BoxGeometry(1.7, 0.08, 1.7),
      new THREE.MeshStandardMaterial({ color: 0xb89968, emissive: 0x6b5230, emissiveIntensity: 0.4 }),
    );
    cap.position.set(p.x + perp.x * off, 4.55, p.z + perp.z * off);
    scene.add(cap);
  }

  // ─── Polyline → t lookup table (used by city plinths AND building projection) ───
  const polyTs = [];
  for (let i = 0; i < pts.length; i++) {
    polyTs.push({ t: i / (pts.length - 1), pos: curvePts[i] });
  }

  // ─── City-boundary plinths ───
  // Approximate city transitions along Ventura Blvd by longitude:
  //   Tarzana → Encino   ≈ -118.535
  //   Encino → Sherman Oaks ≈ -118.466
  //   Sherman Oaks → Studio City ≈ -118.405
  //   Studio City → North Hollywood ≈ -118.379
  const cityBoundaries = [
    { lng: -118.535, label: 'TARZANA · ENCINO' },
    { lng: -118.466, label: 'ENCINO · SHERMAN OAKS' },
    { lng: -118.405, label: 'SHERMAN OAKS · STUDIO CITY' },
    { lng: -118.379, label: 'STUDIO CITY · NOHO' },
  ];
  for (const cb of cityBoundaries) {
    // Find polyline t closest to that longitude
    let bestT = 0, bestDx = Infinity;
    for (let i = 0; i < polyTs.length; i++) {
      const lat = pts[i][0]; const lng = pts[i][1];
      const dx = Math.abs(lng - cb.lng);
      if (dx < bestDx) { bestDx = dx; bestT = polyTs[i].t; }
    }
    const p = curve.getPointAt(bestT);
    const tan = curve.getTangentAt(bestT);
    const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
    // Two plinths flanking the road (north + south side)
    for (const side of [1, -1]) {
      const off = 5;
      const obelisk = new THREE.Mesh(
        new THREE.ConeGeometry(0.55, 6, 4),
        new THREE.MeshStandardMaterial({ color: 0x6b5230, roughness: 0.55, metalness: 0.55, emissive: 0x3a2e1a, emissiveIntensity: 0.25 }),
      );
      obelisk.rotation.y = Math.PI / 4;        // square base aligns to cardinal
      obelisk.position.set(p.x + perp.x * off * side, 3, p.z + perp.z * off * side);
      scene.add(obelisk);
    }
    // Small ground-plate text label
    const lbl = new THREE.Sprite(makeTextSprite(cb.label, 512, '#8b857a'));
    const labelOff = 8;
    lbl.position.set(p.x, 0.4, p.z + labelOff);
    lbl.scale.set(14, 3.5, 1);
    scene.add(lbl);
  }

  // Buildings — one per business, projected onto curve, alternating sides
  const r = await fetch('/api/businesses?limit=400').then(r => r.json());
  const businesses = (r.rows || []).filter(b => b.lat != null && b.lng != null);

  // Spatial-index helper for projecting buildings (polyTs already built above).
  function nearestT(pos) {
    let best = polyTs[0]; let bestDist = Infinity;
    for (const p of polyTs) {
      const dx = p.pos.x - pos.x, dz = p.pos.z - pos.z;
      const d = dx*dx + dz*dz;
      if (d < bestDist) { bestDist = d; best = p; }
    }
    return best.t;
  }

  // ─── Screenshot billboards (THE main visual) ───
  // For every business with a captured screenshot, stand a 16:10 plane
  // along the road textured with the actual front-page screenshot.
  // For businesses without a screenshot, fall back to a low ownership-colored
  // pedestal so they still register as a presence on the corridor.
  const texLoader = new THREE.TextureLoader();
  // A frame mesh wraps each screenshot — bronze-edged "billboard".
  const frameMat = new THREE.MeshStandardMaterial({
    color: 0x2a2724, roughness: 0.6, metalness: 0.6,
    emissive: 0x6b5230, emissiveIntensity: 0.08,
  });
  const postMat = new THREE.MeshStandardMaterial({
    color: 0x131316, roughness: 0.5, metalness: 0.55,
  });
  // Shadow / cap below screenshot
  const capMat = new THREE.MeshStandardMaterial({
    color: 0xb89968, emissive: 0x6b5230, emissiveIntensity: 0.4,
  });

  let buildingIdx = 0;
  for (const b of businesses) {
    const pos = projectLatLng(b.lat, b.lng);
    const t = nearestT(pos);
    const onCurve = curve.getPointAt(t);
    const tangent = curve.getTangentAt(t);
    const perp = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
    const side = (buildingIdx++ % 2 === 0) ? 1 : -1;
    const offset = 5.5 + Math.random() * 3.5;
    const baseX = onCurve.x + perp.x * offset * side;
    const baseZ = onCurve.z + perp.z * offset * side;

    if (b.screenshot_path) {
      // ─── Screenshot billboard ───
      // Plane is 16:10 = 8w × 5h units, sitting on a 0.6h post + 0.4h cap.
      const W = 8, H = 5;
      const group = new THREE.Group();
      group.position.set(baseX, 0, baseZ);

      // Face perpendicular to the road (flat side faces the boulevard)
      // Direction the plane should face = from the building toward the road
      const faceVec = new THREE.Vector3(-perp.x * side, 0, -perp.z * side);
      const yaw = Math.atan2(faceVec.x, faceVec.z);
      group.rotation.y = yaw;

      // Posts (two thin black-marble verticals supporting the billboard)
      for (const px of [-W / 2 + 0.15, W / 2 - 0.15]) {
        const post = new THREE.Mesh(new THREE.BoxGeometry(0.18, 4.5, 0.18), postMat);
        post.position.set(px, 2.25, 0);
        group.add(post);
      }
      // Frame (subtle bronze backplate, slightly larger than screenshot)
      const frame = new THREE.Mesh(new THREE.BoxGeometry(W + 0.2, H + 0.2, 0.08), frameMat);
      frame.position.set(0, 4.5 + H / 2, -0.05);
      group.add(frame);
      // Champagne hairline cap rod under the billboard
      const cap = new THREE.Mesh(new THREE.BoxGeometry(W + 0.1, 0.06, 0.16), capMat);
      cap.position.set(0, 4.45, 0);
      group.add(cap);
      // Screenshot plane — front face only, double-sided so back glows softly too
      const screenshotUrl = '/' + b.screenshot_path;
      const tex = texLoader.load(screenshotUrl, (t) => {
        t.colorSpace = THREE.SRGBColorSpace;
        t.minFilter = THREE.LinearMipmapLinearFilter;
        t.generateMipmaps = true;
        t.anisotropy = 8;
      });
      const planeMat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.FrontSide });
      const plane = new THREE.Mesh(new THREE.PlaneGeometry(W, H), planeMat);
      plane.position.set(0, 4.5 + H / 2, 0.03);
      plane.userData = { business: b };
      group.add(plane);
      // Tier dot — small champagne emissive ball above the cap, sized by SEO tier
      const dotR = b.seo_tier === 'A' ? 0.25 : b.seo_tier === 'B' ? 0.18 : 0.13;
      const dotColor = b.seo_score >= 90 ? 0xd4b683 : b.seo_score >= 70 ? 0xb89968 : 0xa07a40;
      const dot = new THREE.Mesh(new THREE.SphereGeometry(dotR, 12, 12),
        new THREE.MeshBasicMaterial({ color: dotColor }));
      dot.position.set(0, 4.5 + H + 0.4, 0);
      group.add(dot);

      group.userData = { business: b };
      scene.add(group);
      buildings.push(plane);   // raycaster targets the screenshot plane
    } else {
      // ─── Pedestal fallback for businesses without screenshots ───
      const h = b.website ? 2.2 : 1.4;
      const color = b.website ? ownershipColor(b.ownership_class) : 0x1a1815;
      const emiss = b.website ? ownershipEmissive(b.ownership_class) : 0x0a0a0c;
      const m = new THREE.Mesh(
        new THREE.BoxGeometry(2.2, h, 2.2),
        new THREE.MeshStandardMaterial({ color, emissive: emiss, emissiveIntensity: 0.08, roughness: 0.85, metalness: 0.18 }),
      );
      m.position.set(baseX, h / 2, baseZ);
      m.userData = { business: b };
      scene.add(m);
      buildings.push(m);
    }
  }

  // Cars — one per business that has a website, traveling along the curve
  const drivers = businesses.filter(b => b.website).slice(0, 60);
  drivers.forEach((b, i) => {
    const carGroup = new THREE.Group();
    const tier = b.seo_score >= 90 ? 0xd4b683 : b.seo_score >= 70 ? 0xb89968 : b.seo_score >= 50 ? 0xa07a40 : 0x5a4220;
    const body = new THREE.Mesh(
      new THREE.BoxGeometry(1.6, 0.5, 0.75),
      new THREE.MeshStandardMaterial({ color: 0x0a0a0c, roughness: 0.4, metalness: 0.65, emissive: tier, emissiveIntensity: 0.45 }),
    );
    body.position.y = 0.4;
    carGroup.add(body);
    const cabin = new THREE.Mesh(
      new THREE.BoxGeometry(0.95, 0.32, 0.62),
      new THREE.MeshStandardMaterial({ color: 0x2a2724, roughness: 0.5, metalness: 0.4 }),
    );
    cabin.position.set(-0.05, 0.82, 0);
    carGroup.add(cabin);
    const head = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 8), new THREE.MeshBasicMaterial({ color: 0xd4b683 }));
    head.position.set(0.85, 0.4, 0);
    carGroup.add(head);

    carGroup.userData = {
      business: b,
      t: Math.random(),
      speed: 0.0035 + Math.random() * 0.005,
      direction: i % 2 === 0 ? 1 : -1,
      laneOffset: i % 2 === 0 ? -1 : 1,
    };
    scene.add(carGroup);
    cars.push(carGroup);
  });
}

init();

// Auto-fade the orientation overlay 3.2s after the scene mounts.
setTimeout(() => {
  const o = document.getElementById('orient');
  if (o) o.classList.add('hide');
}, 3200);

// ─── Interaction ───
const pvEyebrow = document.getElementById('pv-eyebrow');
const pvName = document.getElementById('pv-name');
const pvMeta = document.getElementById('pv-meta');
const pvUrl = document.getElementById('pv-url');

function showPreview(b) {
  pvEyebrow.textContent = (b.ownership_class || 'unclassified') + (b.parent_brand ? ' · ' + b.parent_brand : '');
  pvName.textContent = b.name;
  const tier = b.seo_tier ? `tier ${b.seo_tier}` : 'unscored';
  const rank = b.category_rank ? `· rank ${b.category_rank}/${b.category_n}` : '';
  pvMeta.textContent = `${b.category || ''} · ${b.city || ''} · ${tier} ${rank}`.trim();
  pvUrl.textContent = b.website || '—';
  previewEl.classList.add('show');
  // Auto-dismiss after 7s of no further click
  clearTimeout(showPreview._t);
  showPreview._t = setTimeout(() => previewEl.classList.remove('show'), 7000);
}

renderer.domElement.addEventListener('pointermove', (e) => {
  const rect = renderer.domElement.getBoundingClientRect();
  pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
});
renderer.domElement.addEventListener('click', () => {
  raycaster.setFromCamera(pointer, camera);
  const hits = raycaster.intersectObjects([...cars, ...buildings], true);
  for (const h of hits) {
    let g = h.object;
    while (g && !g.userData?.business) g = g.parent;
    if (g && g.userData?.business) { showPreview(g.userData.business); return; }
  }
});

// ─── Camera mode: DRIVE (chase) by default; SPACE toggles to ORBIT ───
let camMode = 'drive';
const camLabel = document.getElementById('cam-mode');
let driveT = 0.05;
const camOffset = new THREE.Vector3(0, 6, -14);
const camLook = new THREE.Vector3();

function setMode(m) {
  camMode = m;
  controls.enabled = (m === 'orbit');
  camLabel.textContent = m === 'drive' ? 'Drive · auto' : 'Orbit · manual';
}
window.addEventListener('keydown', (e) => {
  if (e.code === 'Space') { e.preventDefault(); setMode(camMode === 'drive' ? 'orbit' : 'drive'); }
});
let lastInteractionAt = Date.now();
controls.addEventListener('start', () => { lastInteractionAt = Date.now(); });

window.addEventListener('resize', () => {
  camera.aspect = root.clientWidth / root.clientHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(root.clientWidth, root.clientHeight);
});

// ─── Animate ───
const clock = new THREE.Clock();
function loop() {
  const dt = clock.getDelta();

  // Cars travel along the curve
  if (curve) {
    for (const c of cars) {
      c.userData.t += c.userData.speed * c.userData.direction * dt * 30;
      if (c.userData.t > 1) c.userData.t -= 1;
      if (c.userData.t < 0) c.userData.t += 1;
      const p = curve.getPointAt(c.userData.t);
      const tan = curve.getTangentAt(c.userData.t);
      // Lane offset perpendicular to road
      const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
      c.position.set(
        p.x + perp.x * c.userData.laneOffset,
        0,
        p.z + perp.z * c.userData.laneOffset,
      );
      // Face direction of travel
      const facing = c.userData.direction === 1 ? tan : tan.clone().multiplyScalar(-1);
      c.lookAt(c.position.x + facing.x, c.position.y, c.position.z + facing.z);
    }

    // Drive-cam: slow east-bound chase along the curve
    if (camMode === 'drive') {
      driveT += dt * 0.012;
      if (driveT > 1) driveT -= 1;
      const p = curve.getPointAt(driveT);
      const tan = curve.getTangentAt(driveT);
      // y=7.5 puts the eye line right at the screenshot center (4.5 + 5/2 = 7).
      camera.position.set(p.x - tan.x * 14, 7.5 + Math.sin(driveT * Math.PI * 4) * 0.4, p.z - tan.z * 14);
      camLook.set(p.x + tan.x * 8, 7.0, p.z + tan.z * 8);
      camera.lookAt(camLook);
    }
  }

  // Idle drift back to drive mode after 8s
  if (camMode === 'orbit' && Date.now() - lastInteractionAt > 8000) {
    setMode('drive');
  }

  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(loop);
}
loop();
</script>
</body>
</html>