← back to Ventura Bus 3d
add /streetview.html: animated Street View drive down Ventura Blvd + Places nearby
e0acce75524ebe655ff785074c87b82d910988f5 · 2026-05-10 10:06:34 -0700 · Steve Abrams
Files touched
A public/streetview.htmlM src/server.js
Diff
commit e0acce75524ebe655ff785074c87b82d910988f5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 10 10:06:34 2026 -0700
add /streetview.html: animated Street View drive down Ventura Blvd + Places nearby
---
public/streetview.html | 246 +++++++++++++++++++++++++++++++++++++++++++++++++
src/server.js | 22 ++++-
2 files changed, 267 insertions(+), 1 deletion(-)
diff --git a/public/streetview.html b/public/streetview.html
new file mode 100644
index 0000000..ce0be51
--- /dev/null
+++ b/public/streetview.html
@@ -0,0 +1,246 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Ventura Bus 3D — Street View drive</title>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<style>
+ :root { --bg:#0b0d12; --fg:#e6eaf2; --muted:#8a93a6; --card:#141821; --accent:#ff8c00; --metro:#e53935; --line:#20242f; }
+ *{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px/1.4 -apple-system,system-ui,sans-serif;overflow:hidden}
+ header{padding:12px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:#0e1218}
+ header h1{margin:0;font-size:16px;letter-spacing:.02em}
+ header .meta{color:var(--muted);font-size:12px}
+ header a{color:var(--accent);text-decoration:none}
+ .layout{display:grid;grid-template-columns:1fr 360px;height:calc(100vh - 49px)}
+ #pano{position:relative;background:#000}
+ #pano .overlay{position:absolute;left:14px;bottom:14px;background:rgba(8,10,14,.78);backdrop-filter:blur(6px);padding:10px 14px;border-radius:8px;border:1px solid var(--line);font-size:12px;color:var(--fg);max-width:60%;line-height:1.45;pointer-events:none}
+ #pano .overlay strong{color:var(--accent)}
+ #pano .toolbar{position:absolute;top:14px;left:14px;display:flex;gap:6px;background:rgba(8,10,14,.78);backdrop-filter:blur(6px);padding:6px;border-radius:8px;border:1px solid var(--line)}
+ #pano button{background:#1a1f2b;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 10px;cursor:pointer;font-size:12px;font-family:inherit}
+ #pano button:hover{background:#222a3a}
+ #pano button.active{background:var(--accent);color:#0b0d12;border-color:var(--accent);font-weight:600}
+ #pano .speedlabel{padding:5px 8px;color:var(--muted);font-size:11px;align-self:center}
+ aside{background:#0e1218;border-left:1px solid var(--line);overflow-y:auto;display:flex;flex-direction:column}
+ aside section{border-bottom:1px solid var(--line);padding:12px 14px}
+ aside h2{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
+ .route{margin-bottom:10px}
+ .route .name{font-size:13px;color:var(--fg);margin-bottom:4px;display:flex;align-items:center;gap:6px}
+ .route .swatch{display:inline-block;width:10px;height:10px;border-radius:2px}
+ .stop-row{display:flex;flex-wrap:wrap;gap:4px}
+ .stop-row button{flex:0 0 auto;background:#1a1f2b;color:var(--fg);border:1px solid var(--line);border-radius:4px;padding:4px 8px;font-size:11px;cursor:pointer;font-family:inherit}
+ .stop-row button:hover{background:#222a3a}
+ .stop-row button.active{background:var(--accent);color:#0b0d12;border-color:var(--accent);font-weight:600}
+ .info-block{font-size:12px;line-height:1.5;color:var(--muted)}
+ .info-block .addr{color:var(--fg);font-size:13px;margin-bottom:4px}
+ .nearby{display:flex;flex-direction:column;gap:6px}
+ .nearby .place{padding:6px 8px;background:#161b25;border:1px solid var(--line);border-radius:5px;font-size:12px}
+ .nearby .place .pname{color:var(--fg);font-weight:500}
+ .nearby .place .ptype{color:var(--muted);font-size:11px}
+ .nearby .place .prating{color:var(--accent);font-size:11px}
+ .nokey{padding:14px;color:#ffb;background:#3a2a14;border:1px solid #6a4a14;border-radius:6px;font-size:12px;line-height:1.5}
+ .nokey code{background:rgba(0,0,0,.3);padding:1px 5px;border-radius:3px}
+ @media (max-width: 880px){ .layout{grid-template-columns:1fr;grid-template-rows:1fr 40vh} aside{border-left:none;border-top:1px solid var(--line)} }
+</style>
+</head>
+<body>
+<header>
+ <h1>Ventura Boulevard — Street View drive</h1>
+ <div class="meta">Real Google Street View imagery · Places nearby · click any stop to jump</div>
+ <div class="meta">↩ <a href="/">main scene</a> · <a href="/models-preview.html">model preview</a></div>
+</header>
+
+<div class="layout">
+ <div id="pano">
+ <div class="toolbar" id="toolbar"></div>
+ <div class="overlay" id="overlay">Loading…</div>
+ </div>
+ <aside>
+ <section id="routes-section">
+ <h2>Routes & stops</h2>
+ <div id="routes"></div>
+ </section>
+ <section>
+ <h2>Where you are</h2>
+ <div class="info-block" id="here">—</div>
+ </section>
+ <section>
+ <h2>Nearby places (Google Places)</h2>
+ <div class="nearby" id="nearby"><div class="info-block">—</div></div>
+ </section>
+ <section>
+ <h2>About this view</h2>
+ <div class="info-block">
+ Drive auto-advances every ~2.4s using Street View Service link-following along Ventura Blvd. Toggle <strong>Auto-drive</strong> to pause. Stops are real LA Metro 150/750/234/233 corridor anchors.
+ </div>
+ </section>
+ </aside>
+</div>
+
+<script>
+(async () => {
+ const overlay = document.getElementById('overlay');
+ const here = document.getElementById('here');
+ const nearby = document.getElementById('nearby');
+ const toolbar = document.getElementById('toolbar');
+ const routesEl = document.getElementById('routes');
+
+ const cfg = await fetch('/api/config').then(r => r.json());
+ if (!cfg.hasMaps) {
+ document.querySelector('#pano').innerHTML = `
+ <div class="nokey" style="margin:24px">
+ <strong>No Google Maps API key set.</strong><br>
+ Add <code>GOOGLE_MAPS_API_KEY=…</code> to <code>~/Projects/ventura-bus-3d/.env</code>
+ and restart the server. The key needs <em>Maps JavaScript API</em>, <em>Street View Static API</em>,
+ and <em>Places API</em> enabled in the GCP console.
+ </div>`;
+ return;
+ }
+
+ // Pull routes from the existing API.
+ const routesData = await fetch('/api/routes').then(r => r.json());
+
+ // Inject Maps JS loader.
+ await new Promise((resolve, reject) => {
+ const s = document.createElement('script');
+ s.async = true;
+ s.src = `https://maps.googleapis.com/maps/api/js?key=${cfg.googleMapsApiKey}&libraries=places&v=weekly&callback=__gmapsReady`;
+ window.__gmapsReady = resolve;
+ s.onerror = reject;
+ document.head.appendChild(s);
+ });
+
+ const start = { lat: 34.1683, lng: -118.6050 }; // Woodland Hills end of Ventura Blvd
+ const panorama = new google.maps.StreetViewPanorama(document.getElementById('pano'), {
+ position: start,
+ pov: { heading: 90, pitch: 0 },
+ zoom: 1,
+ addressControl: false,
+ fullscreenControl: false,
+ motionTracking: false,
+ motionTrackingControl: false,
+ enableCloseButton: false,
+ showRoadLabels: true,
+ panControl: true,
+ zoomControl: true,
+ linksControl: true
+ });
+ const svService = new google.maps.StreetViewService();
+ const geocoder = new google.maps.Geocoder();
+ const placesSvc = new google.maps.places.PlacesService(panorama); // panorama works as map surrogate
+
+ // Toolbar controls
+ let autoDrive = true;
+ let driveTimer = null;
+ const toggleBtn = document.createElement('button'); toggleBtn.textContent = 'Auto-drive: ON'; toggleBtn.className = 'active';
+ toggleBtn.onclick = () => { autoDrive = !autoDrive; toggleBtn.textContent = `Auto-drive: ${autoDrive ? 'ON' : 'OFF'}`; toggleBtn.classList.toggle('active', autoDrive); if (autoDrive) scheduleStep(); };
+ const stepBtn = document.createElement('button'); stepBtn.textContent = '→ step';
+ stepBtn.onclick = () => { autoDrive = false; toggleBtn.textContent = 'Auto-drive: OFF'; toggleBtn.classList.remove('active'); stepForward(); };
+ const speedLabel = document.createElement('span'); speedLabel.className = 'speedlabel'; speedLabel.textContent = '~2.4 s/step · eastbound';
+ toolbar.append(toggleBtn, stepBtn, speedLabel);
+
+ // Sidebar — render routes and stop chips
+ for (const r of routesData.routes) {
+ const div = document.createElement('div'); div.className = 'route';
+ const name = document.createElement('div'); name.className = 'name';
+ name.innerHTML = `<span class="swatch" style="background:${r.color}"></span><strong>${r.line}</strong> · ${r.name}`;
+ const row = document.createElement('div'); row.className = 'stop-row';
+ for (const s of r.stops) {
+ const b = document.createElement('button');
+ b.textContent = s.label;
+ b.onclick = () => { autoDrive = false; toggleBtn.textContent = 'Auto-drive: OFF'; toggleBtn.classList.remove('active');
+ jumpTo(s.lat, s.lng, b); };
+ row.appendChild(b);
+ }
+ div.append(name, row);
+ routesEl.appendChild(div);
+ }
+
+ function clearActiveButtons(except){
+ document.querySelectorAll('.stop-row button.active').forEach(x => { if (x !== except) x.classList.remove('active'); });
+ }
+
+ // Step forward by following the panorama's eastbound link (heading closest to 90°).
+ function stepForward(){
+ const links = panorama.getLinks();
+ if (!links || !links.length) return;
+ // Pick the link whose heading is closest to current POV heading (default east 90°).
+ const target = panorama.getPov().heading || 90;
+ let best = links[0], bestDelta = 999;
+ for (const l of links){
+ const d = Math.abs(((l.heading - target + 540) % 360) - 180);
+ if (d < bestDelta) { bestDelta = d; best = l; }
+ }
+ panorama.setPano(best.pano);
+ panorama.setPov({ heading: best.heading, pitch: 0 });
+ }
+
+ function scheduleStep(){
+ clearTimeout(driveTimer);
+ driveTimer = setTimeout(() => {
+ if (autoDrive) stepForward();
+ // re-arm regardless — pano `position_changed` will trigger next schedule
+ }, 2400);
+ }
+
+ function jumpTo(lat, lng, btn){
+ svService.getPanorama({ location: {lat, lng}, radius: 80, source: google.maps.StreetViewSource.OUTDOOR },
+ (data, status) => {
+ if (status === 'OK') {
+ panorama.setPano(data.location.pano);
+ panorama.setPov({ heading: 90, pitch: 0 });
+ if (btn) { clearActiveButtons(btn); btn.classList.add('active'); }
+ } else {
+ overlay.innerHTML = `No outdoor Street View within 80 m of <strong>${lat.toFixed(4)}, ${lng.toFixed(4)}</strong> (status: ${status})`;
+ }
+ }
+ );
+ }
+
+ panorama.addListener('position_changed', () => {
+ const p = panorama.getPosition();
+ if (!p) return;
+ const lat = p.lat(), lng = p.lng();
+ overlay.innerHTML = `📍 <strong>${lat.toFixed(5)}, ${lng.toFixed(5)}</strong> · POV ${Math.round(panorama.getPov().heading)}°`;
+
+ // Reverse-geocode to a nice address.
+ geocoder.geocode({ location: { lat, lng } }, (results, status) => {
+ if (status === 'OK' && results[0]) {
+ const addr = results[0].formatted_address;
+ const street = (results[0].address_components.find(c => c.types.includes('route')) || {}).long_name || '';
+ here.innerHTML = `<div class="addr">${addr}</div><div>street: ${street || '—'}</div>`;
+ } else {
+ here.innerHTML = '<div class="addr">—</div>';
+ }
+ });
+
+ // Pull nearby places via the JS Places library (browser key with referer restrictions
+ // works through the JS API surface; the REST nearbysearch endpoint refuses referer keys).
+ placesSvc.nearbySearch(
+ { location: { lat, lng }, radius: 140 },
+ (results, status) => {
+ if (status !== google.maps.places.PlacesServiceStatus.OK || !results || !results.length) {
+ nearby.innerHTML = `<div class="info-block">No nearby places (status: ${status}).</div>`;
+ return;
+ }
+ nearby.innerHTML = '';
+ for (const p of results.slice(0, 12)) {
+ const el = document.createElement('div'); el.className = 'place';
+ const types = (p.types || []).filter(t => !['point_of_interest','establishment'].includes(t)).slice(0, 2).join(', ');
+ const rating = p.rating ? `★ ${p.rating} (${p.user_ratings_total || 0})` : '';
+ el.innerHTML = `<div class="pname">${p.name}</div><div class="ptype">${types || '—'}</div><div class="prating">${rating}</div>`;
+ nearby.appendChild(el);
+ }
+ }
+ );
+
+ if (autoDrive) scheduleStep();
+ });
+
+ // Kick off once panorama settles.
+ panorama.addListener('status_changed', () => {
+ if (panorama.getStatus() === 'OK' && autoDrive) scheduleStep();
+ });
+})();
+</script>
+</body>
+</html>
diff --git a/src/server.js b/src/server.js
index 1d0576a..cb3a6a0 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1,11 +1,22 @@
const path = require('path');
+const fs = require('fs');
const express = require('express');
const compression = require('compression');
const morgan = require('morgan');
+// Hand-rolled .env loader so we don't pull dotenv just for one key.
+const envPath = path.join(__dirname, '..', '.env');
+if (fs.existsSync(envPath)) {
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
+ const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
+ }
+}
+
const app = express();
const PORT = process.env.PORT || 9783;
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const GMAPS_KEY = process.env.GOOGLE_MAPS_API_KEY || '';
app.use(compression());
app.use(morgan('tiny'));
@@ -91,6 +102,15 @@ app.get('/api/route', (_req, res) => {
res.json({ name: r0.name, stops: r0.stops });
});
+// Browser-side config — exposes only the Maps key (which has to be browser-visible
+// anyway, but we keep it out of source by serving from server-side env).
+app.get('/api/config', (_req, res) => {
+ res.json({
+ googleMapsApiKey: GMAPS_KEY,
+ hasMaps: Boolean(GMAPS_KEY)
+ });
+});
+
app.listen(PORT, () => {
- console.log(`[ventura-bus-3d] listening on http://127.0.0.1:${PORT}`);
+ console.log(`[ventura-bus-3d] listening on http://127.0.0.1:${PORT} (maps=${GMAPS_KEY ? 'on' : 'off'})`);
});
← 9e03e9b task #5: traffic.js — fixed-timestep bus + ambient car sim w
·
back to Ventura Bus 3d
·
fix: direction-aware nextStopFor — westbound bus 'Next stop' 923beeb →