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