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