[object Object]

← 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

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 &amp; 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 →