#!/usr/bin/env node /** * streetview-panorama.mjs * * Fetches and stitches a full Street View panorama for any lat/lon * using the Google Map Tiles API (Street View Tiles endpoint). * * Usage: * node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813 * node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813 --zoom 5 * node tools/streetview-panorama.mjs --lat 30.268066 --lon -97.742813 --radius 100 * * Options: * --lat Latitude (required) * --lon Longitude (required) * --zoom Tile zoom level 0-5 (default: 3, full res = 5) * --radius Search radius in meters for nearest panorama (default: 50) * --outdir Output directory (default: output/) * --key Google Maps API key (default: reads GOOGLE_MAPS_API_KEY from .env or env) * * Requires: Map Tiles API enabled on the Google Cloud project. * Dependencies: sharp (npm install sharp) */ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '..'); // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- function parseArgs() { const args = process.argv.slice(2); const opts = { zoom: 3, radius: 50, outdir: 'output' }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--lat': opts.lat = parseFloat(args[++i]); break; case '--lon': opts.lon = parseFloat(args[++i]); break; case '--zoom': opts.zoom = parseInt(args[++i], 10); break; case '--radius': opts.radius = parseInt(args[++i], 10); break; case '--outdir': opts.outdir = args[++i]; break; case '--key': opts.key = args[++i]; break; case '--help': console.log(`Usage: node tools/streetview-panorama.mjs --lat --lon [--zoom 0-5] [--radius m] [--outdir dir] [--key apikey]`); process.exit(0); } } if (opts.lat === undefined || opts.lon === undefined) { console.error('Error: --lat and --lon are required.'); process.exit(1); } if (opts.zoom < 0 || opts.zoom > 5) { console.error('Error: --zoom must be between 0 and 5.'); process.exit(1); } return opts; } // --------------------------------------------------------------------------- // Load API key from .env or environment // --------------------------------------------------------------------------- function loadApiKey(overrideKey) { if (overrideKey) return overrideKey; if (process.env.GOOGLE_MAPS_API_KEY) return process.env.GOOGLE_MAPS_API_KEY; // Try to load from .env try { const envPath = join(PROJECT_ROOT, '.env'); const envContent = readFileSync(envPath, 'utf8'); for (const line of envContent.split('\n')) { const trimmed = line.trim(); if (trimmed.startsWith('#') || !trimmed.includes('=')) continue; const eqIdx = trimmed.indexOf('='); const key = trimmed.slice(0, eqIdx).trim(); const val = trimmed.slice(eqIdx + 1).trim(); if (key === 'GOOGLE_MAPS_API_KEY' && val) return val; } } catch { /* ignore */ } console.error('Error: No API key found. Set GOOGLE_MAPS_API_KEY in .env or pass --key.'); process.exit(1); } // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- const TILE_API = 'https://tile.googleapis.com/v1'; const COMMON_HEADERS = { 'Referer': 'http://localhost:4173/' }; async function createSession(apiKey) { const url = `${TILE_API}/createSession?key=${apiKey}`; const res = await fetch(url, { method: 'POST', headers: { ...COMMON_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ mapType: 'streetview', language: 'en-US', region: 'US' }), }); if (!res.ok) { const text = await res.text(); throw new Error(`createSession failed (${res.status}): ${text}`); } return res.json(); } async function findPanoId(apiKey, session, lat, lng, radius) { const url = `${TILE_API}/streetview/panoIds?session=${session}&key=${apiKey}`; const res = await fetch(url, { method: 'POST', headers: { ...COMMON_HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ locations: [{ lat, lng }], radius }), }); if (!res.ok) { const text = await res.text(); throw new Error(`panoIds search failed (${res.status}): ${text}`); } const data = await res.json(); const panoId = data.panoIds?.[0]; if (!panoId) { throw new Error(`No Street View panorama found within ${radius}m of ${lat}, ${lng}`); } return panoId; } async function getMetadata(apiKey, session, panoId) { const url = `${TILE_API}/streetview/metadata?session=${session}&key=${apiKey}&panoId=${panoId}`; const res = await fetch(url, { headers: COMMON_HEADERS }); if (!res.ok) { const text = await res.text(); throw new Error(`metadata failed (${res.status}): ${text}`); } return res.json(); } async function fetchTile(apiKey, session, panoId, z, x, y) { const url = `${TILE_API}/streetview/tiles/${z}/${x}/${y}?session=${session}&key=${apiKey}&panoId=${panoId}`; const res = await fetch(url, { headers: COMMON_HEADERS }); if (!res.ok) { // Some edge tiles may be empty — return null if (res.status === 404 || res.status === 204) return null; const text = await res.text(); throw new Error(`tile ${z}/${x}/${y} failed (${res.status}): ${text}`); } return Buffer.from(await res.arrayBuffer()); } // --------------------------------------------------------------------------- // Panorama stitching // --------------------------------------------------------------------------- async function stitchPanorama(apiKey, session, panoId, metadata, zoom) { const { imageWidth, imageHeight, tileWidth, tileHeight } = metadata; // Street View tile grids follow a fixed power-of-2 scheme: // zoom 0: 1×1 (360° FOV, entire panorama in one tile) // zoom 1: 2×1 // zoom 2: 4×2 // zoom 3: 8×4 // zoom 4: 16×8 // zoom 5: 32×16 (11.25° FOV, but actual cols/rows may be fewer // since tiles past the image edge return 404) // // The grid dimensions are independent of imageWidth/imageHeight. // Lower-resolution panoramas (e.g. contributor panos) simply 404 // at higher zoom levels where there aren't enough pixels. // // We use the fixed grid, then let fetchTile silently return null for // any tiles that 404 (edge padding). The stitched canvas size is // grid × tileSize, and real image content fills the upper-left portion. const tilesX = Math.pow(2, zoom); // cols: 1,2,4,8,16,32 const tilesY = Math.max(1, Math.pow(2, zoom - 1)); // rows: 1,1,2,4, 8,16 // The panorama image is an equirectangular projection: width = 360°, // height = 180°. The tile grid is always 2:1 aspect (except zoom 0 // which is 1×1). The image content fills the grid, scaled to fit. const canvasWidth = tilesX * tileWidth; const canvasHeight = tilesY * tileHeight; // For the output, we use full canvas dimensions. The content fills // the grid (the server scales the panorama to fit). Any minor // padding at edges is harmless for equirectangular math. const panoWidth = canvasWidth; const panoHeight = canvasHeight; console.log(`Panorama at zoom ${zoom}: grid ${tilesX}x${tilesY} = ${tilesX * tilesY} tiles (${panoWidth}x${panoHeight}px)`); // Fetch all tiles with concurrency limit const CONCURRENCY = 6; const tiles = []; const queue = []; for (let y = 0; y < tilesY; y++) { for (let x = 0; x < tilesX; x++) { queue.push({ x, y }); } } let completed = 0; const total = queue.length; async function worker() { while (queue.length > 0) { const { x, y } = queue.shift(); const buf = await fetchTile(apiKey, session, panoId, zoom, x, y); tiles.push({ x, y, buf }); completed++; if (completed % 10 === 0 || completed === total) { process.stdout.write(` Fetched ${completed}/${total} tiles\r`); } } } const workers = Array.from({ length: Math.min(CONCURRENCY, total) }, () => worker()); await Promise.all(workers); console.log(` Fetched ${total}/${total} tiles`); // Stitch with sharp console.log(`Stitching ${panoWidth}x${panoHeight} panorama...`); const composites = []; for (const { x, y, buf } of tiles) { if (!buf) continue; composites.push({ input: buf, left: x * tileWidth, top: y * tileHeight, }); } const panorama = sharp({ create: { width: panoWidth, height: panoHeight, channels: 3, background: { r: 0, g: 0, b: 0 }, }, }).composite(composites); const outBuf = await panorama.jpeg({ quality: 92 }).toBuffer(); return { buffer: outBuf, width: panoWidth, height: panoHeight }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { const opts = parseArgs(); const apiKey = loadApiKey(opts.key); console.log(`\nStreet View Panorama Fetcher`); console.log(` Location: ${opts.lat}, ${opts.lon}`); console.log(` Zoom: ${opts.zoom} (0=tiny, 5=full res 13312x6656)`); console.log(` Radius: ${opts.radius}m\n`); // Step 1: Create session console.log('Creating session...'); const sessionData = await createSession(apiKey); const session = sessionData.session; console.log(` Session created (tile size: ${sessionData.tileWidth}x${sessionData.tileHeight})\n`); // Step 2: Find nearest Google panorama (skip contributor panos) // Strategy: probe the requested point + N/S/E/W offsets, collect all // Google panos found (including their linked neighbors), pick closest. console.log(`Searching for Google panorama near ${opts.lat}, ${opts.lon}...`); const isGoogle = (m) => m.copyright?.includes('Google'); const offsetM = 0.00027; // ~30m in degrees const distTo = (m) => { const dy = (m.lat - opts.lat) * 111320; const dx = (m.lng - opts.lon) * 111320 * Math.cos(opts.lat * Math.PI / 180); return Math.sqrt(dy * dy + dx * dx); }; const probePoints = [ { lat: opts.lat, lng: opts.lon }, { lat: opts.lat + offsetM, lng: opts.lon }, { lat: opts.lat - offsetM, lng: opts.lon }, { lat: opts.lat, lng: opts.lon + offsetM }, { lat: opts.lat, lng: opts.lon - offsetM }, { lat: opts.lat + offsetM * 2, lng: opts.lon }, { lat: opts.lat - offsetM * 2, lng: opts.lon }, { lat: opts.lat, lng: opts.lon + offsetM * 2 }, { lat: opts.lat, lng: opts.lon - offsetM * 2 }, ]; const seen = new Map(); // panoId → metadata const googlePanos = []; let fallback = null; for (const pt of probePoints) { let pId; try { pId = await findPanoId(apiKey, session, pt.lat, pt.lng, opts.radius); } catch { continue; } if (seen.has(pId)) continue; const candidate = await getMetadata(apiKey, session, pId); seen.set(pId, candidate); console.log(` Probe ${pt.lat.toFixed(5)},${pt.lng.toFixed(5)} → ${pId.slice(0, 20)}… ${distTo(candidate).toFixed(0)}m (${candidate.copyright})`); if (isGoogle(candidate)) { googlePanos.push(candidate); // Also check immediate links for closer Google panos for (const link of (candidate.links || [])) { if (seen.has(link.panoId)) continue; const linked = await getMetadata(apiKey, session, link.panoId); seen.set(link.panoId, linked); if (isGoogle(linked)) { console.log(` Link ${link.panoId.slice(0, 20)}… ${distTo(linked).toFixed(0)}m (${linked.copyright})`); googlePanos.push(linked); } } break; // found Google cluster, no need to probe further } if (!fallback) fallback = candidate; } // Pick the Google pano closest to the requested point let meta; if (googlePanos.length > 0) { googlePanos.sort((a, b) => distTo(a) - distTo(b)); meta = googlePanos[0]; console.log(` Selected: ${meta.panoId.slice(0, 20)}… (${distTo(meta).toFixed(0)}m away)`); } else { meta = fallback; console.log(` No Google pano found, using best available.`); } console.log(`\nMetadata:`); console.log(` Panorama ID: ${meta.panoId}`); console.log(` Location: ${meta.lat}, ${meta.lng}`); console.log(` Image size: ${meta.imageWidth}x${meta.imageHeight}`); console.log(` Heading: ${meta.heading} degrees`); console.log(` Tilt: ${meta.tilt} degrees`); console.log(` Date: ${meta.date || 'unknown'}`); console.log(` Copyright: ${meta.copyright}`); if (meta.links?.length) { console.log(` Links to ${meta.links.length} adjacent panorama(s):`); for (const link of meta.links) { console.log(` - ${link.text || 'unnamed'} heading=${link.heading.toFixed(1)} panoId=${link.panoId}`); } } console.log(); // Distance from requested location to actual panorama const dlat = (meta.lat - opts.lat) * 111320; const dlng = (meta.lng - opts.lon) * 111320 * Math.cos(opts.lat * Math.PI / 180); const distM = Math.sqrt(dlat * dlat + dlng * dlng); console.log(` Distance from requested point: ${distM.toFixed(1)}m\n`); // Step 4: Fetch and stitch panorama const { buffer: panoBuffer, width: panoWidth, height: panoHeight } = await stitchPanorama( apiKey, session, meta.panoId, meta, opts.zoom ); // Step 5: Save outputs const outdir = resolve(PROJECT_ROOT, opts.outdir); mkdirSync(outdir, { recursive: true }); const panoPath = join(outdir, `panorama_${opts.lat}_${opts.lon}.jpg`); writeFileSync(panoPath, panoBuffer); console.log(`\nSaved: ${panoPath} (${(panoBuffer.length / 1024 / 1024).toFixed(2)} MB)`); console.log(` Panorama: ${panoWidth}x${panoHeight}, heading=${meta.heading.toFixed(1)}, tilt=${meta.tilt.toFixed(1)}`); } main().catch((err) => { console.error('\nError:', err.message); process.exit(1); });