← back to Ventura Bus 3d

public/streetview.html

258 lines

<!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 id="places-attr" style="position:absolute;width:1px;height:1px;overflow:hidden;left:-9999px"></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 die = (e) => {
    console.error('[streetview] init failed:', e);
    overlay.innerHTML = `<strong style="color:#ff6b6b">Init error:</strong> ${String(e && e.message || e)}`;
  };
  window.addEventListener('error', ev => die(ev.error || ev.message));
  window.addEventListener('unhandledrejection', ev => die(ev.reason));
  try {
  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();
  // PlacesService requires HTMLDivElement|google.maps.Map — passing the panorama
  // throws InvalidValueError and kills the rest of this IIFE. Use a hidden div.
  const placesSvc  = new google.maps.places.PlacesService(document.getElementById('places-attr'));

  // 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();
  });
  } catch (e) { die(e); }
})();
</script>
</body>
</html>