Created
May 29, 2024 20:49
-
-
Save ryanbaumann/2b94a611fa643fc66447cdec2d146eb5 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<title>Gemini Maps</title> | |
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no"> | |
<link href="./style.css" rel="stylesheet"> | |
<script | |
async | |
src="https://maps.googleapis.com/maps/api/js?key=AIzaSyCeTDqE1JdyQc7mem3f4pNgKwwSWTXhuc8&v=alpha&libraries=maps3d,places,geometry&callback=initMap" | |
></script> | |
<script type="module"> | |
import { GoogleGenerativeAI } from 'https://esm.run/@google/generative-ai'; | |
// Replace with your Generative AI API Key | |
const GENERATIVE_AI_API_KEY = 'AIzaSyCeTDqE1JdyQc7mem3f4pNgKwwSWTXhuc8'; | |
genAI = new GoogleGenerativeAI(GENERATIVE_AI_API_KEY); | |
</script> | |
<script src="./index.js"></script> | |
</head> | |
<body> | |
<div id="container"> | |
<button id="logs-button">Logs</button> | |
<div id="map-pane"></div> | |
<div id="loading-spinner"></div> </div> | |
<div id="input-area"> | |
<input type="text" id="prompt-input" placeholder="Enter your map request..." /> | |
<button id="run-button">Run <span class="emoji">🤖</span></button> | |
</div> | |
<div id="side-panel"> | |
<span id="close-panel">X</span> | |
<h2>Gemini Maps</h2> | |
<h3>Prompt Sent to Generative API:</h3> | |
<div id="prompt-box"></div> | |
<h3>Response Received from Generative API:</h3> | |
<div id="response-box"></div> | |
<h3>Errors:</h3> | |
<div id="error-box"></div> | |
</div> | |
</div> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
let map; | |
let genAI; | |
function initMap() { | |
map = new google.maps.maps3d.Map3DElement(); | |
document.getElementById('map-pane').appendChild(map); | |
// Initial viewport: Zoomed out to globe level | |
map.center = { lat: 37.3861, lng: -119.6672, altitude: 20000000 }; // Set high altitude | |
map.heading = 0; | |
map.tilt = 0; | |
// Try to get user's location, but keep zoom level at 3 | |
if (navigator.geolocation) { | |
navigator.geolocation.getCurrentPosition( | |
(position) => { | |
set_viewport({ | |
lat: position.coords.latitude, | |
lng: position.coords.longitude, | |
zoom: 3 // Set zoom level to 3 (globe view) | |
}); | |
}, | |
(error) => { | |
console.warn("Unable to get user's location. Using default."); | |
} | |
); | |
} else { | |
console.warn("Geolocation is not supported by this browser."); | |
} | |
document.getElementById('run-button').addEventListener('click', processPrompt); | |
document.getElementById('logs-button').addEventListener('click', toggleLogsPanel); | |
document.getElementById('close-panel').addEventListener('click', toggleLogsPanel); | |
// Keyboard Shortcut (Enter to Run) | |
document.getElementById('prompt-input').addEventListener('keyup', (event) => { | |
if (event.key === 'Enter') { | |
processPrompt(); | |
} | |
}); | |
window.addEventListener('resize', () => { | |
if (map) { | |
google.maps.event.trigger(map, 'resize'); // Trigger map resize event | |
} | |
}); | |
} | |
function toggleLogsPanel() { | |
const sidePanel = document.getElementById('side-panel'); | |
sidePanel.classList.toggle('open'); | |
} | |
async function processPrompt() { | |
showLoadingSpinner(); // Show spinner | |
const userPrompt = document.getElementById('prompt-input').value; | |
const promptBox = document.getElementById('prompt-box'); | |
const responseBox = document.getElementById('response-box'); | |
const errorBox = document.getElementById('error-box'); | |
// Structure the prompt for the Generative AI API | |
const aiPrompt = ` | |
You are a helpful AI assistant that provides structured data for controlling a 3rd party 3D map application. | |
The map can understand commands such as: | |
* "Show me a photorealistic 3D map of [location]." | |
* "Show me a map of all the best [category] in [location]." | |
* "Find me the best route from my current location to [destination]." | |
* "Pan up in the current view." | |
* "Orbit around [location] in an elliptical path." | |
* "Zoom down to [location] from a high altitude." | |
* "Fly horizontally to [location]." | |
* "Spiral down to [location]." | |
* "Do a dolly zoom on [location]." | |
Based on the user's prompt below, provide the structured data needed to control the map. | |
If the user asks for a route, provide the origin and destination addresses. | |
If the user asks for a set of places, provide a list of places in the places attribute. | |
For camera effects, include the location and any relevant parameters. | |
Return the data in JSON format, using the following structure: | |
{ | |
"command": "[command type]", | |
"location": "[location name]", | |
"category": "[category, if applicable]", | |
"viewport": { | |
"lat": [latitude], | |
"lng": [longitude], | |
"zoom": [zoom level] | |
}, | |
"places": [ | |
{ | |
"name": "[place name]", | |
"lat": [latitude], | |
"lng": [longitude], | |
}, | |
// ... more places | |
], | |
"origin": "[origin address, if a route is requested]", | |
"destination": "[destination address, if a route is requested]", | |
"effect": { | |
"type": "[effect type, if applicable]", | |
"duration": [duration in seconds, if applicable], | |
"zoomFactor": [zoom factor for dolly zoom, if applicable] | |
} | |
} | |
The "command type" can be one of the following: "show map", "show route", "show places", "pan up", "orbit", "zoom down", "fly to", "spiral down", "dolly zoom". | |
Your job is to take the user prompt, and respond with the most sensible JSON response based on that input. | |
Your response should be pure and valid JSON, that should be possible to parse with a JSON.parse javascript command. | |
Here is the User Prompt: ${userPrompt} | |
`; | |
promptBox.textContent = aiPrompt; | |
try { | |
const generationConfig = { | |
responseMimeType: "application/json", | |
}; | |
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-pro-latest', generationConfig }); | |
const result = await model.generateContent(aiPrompt); | |
const response = await result.response; | |
const aiResponse = JSON.parse(response.text()); | |
responseBox.textContent = JSON.stringify(aiResponse, 2); // Pretty print JSON | |
// Process the AI response and update the map | |
handleApiResponse(aiResponse); | |
} catch (error) { | |
console.error('Error calling Generative AI API:', error); | |
errorBox.textContent = 'Error: ' + error.message; | |
hideLoadingSpinner(); // Hide spinner in case of error | |
} | |
finally { | |
hideLoadingSpinner(); // Hide spinner in all cases | |
} | |
} | |
function showLoadingSpinner() { | |
document.getElementById('loading-spinner').style.display = 'block'; | |
} | |
function hideLoadingSpinner() { | |
document.getElementById('loading-spinner').style.display = 'none'; | |
} | |
function handleApiResponse(response) { | |
switch (response.command) { | |
case 'show map': | |
set_viewport(response.viewport); | |
break; | |
case 'show places': | |
set_viewport(response.viewport); | |
response.places.forEach(place => { | |
addMarker(place); | |
}); | |
break; | |
case 'show route': | |
drawRoute(response.origin, response.destination); | |
break; | |
case 'pan up': | |
panUp(response.duration || 3); // Default duration 3 seconds | |
break; | |
case 'orbit': | |
orbit(response.location, response.duration || 8); // Default duration 8 seconds | |
break; | |
case 'zoom down': | |
zoomDown(response.location, response.duration || 5); // Default duration 5 seconds | |
break; | |
case 'fly to': | |
flyTo(response.location, response.duration || 4); // Default duration 4 seconds | |
break; | |
case 'spiral down': | |
spiralDown(response.location, response.duration || 6); // Default duration 6 seconds | |
break; | |
case 'dolly zoom': | |
dollyZoom(response.location, response.effect.zoomFactor || 2, response.duration || 4); // Default values | |
break; | |
default: | |
console.warn('Unknown command:', response.command); | |
errorBox.textContent = 'Unknown command:', response.command; | |
} | |
hideLoadingSpinner(); // Hide spinner after processing | |
} | |
function set_viewport(viewport) { | |
map.center = { lat: viewport.lat, lng: viewport.lng, altitude: 500 }; | |
map.range = calculateRangeFromZoom(viewport.zoom); | |
} | |
function calculateRangeFromZoom(zoom) { | |
// Adjust these values to control zoom/range relationship | |
const maxRange = 200000; // Maximum range (meters) | |
const minRange = 100; // Minimum range (meters) | |
const zoomFactor = 1.5; // How quickly range changes with zoom | |
const range = maxRange / (zoomFactor ** zoom); | |
return Math.max(minRange, range); | |
} | |
function addMarker(place) { | |
const marker = new google.maps.maps3d.Polygon3DElement({ | |
outerCoordinates: [ | |
{ lat: place.latitude + 0.001, lng: place.longitude - 0.001, altitude: 0 }, | |
{ lat: place.latitude + 0.001, lng: place.longitude + 0.001, altitude: 0 }, | |
{ lat: place.latitude - 0.001, lng: place.longitude + 0.001, altitude: 0 }, | |
{ lat: place.latitude - 0.001, lng: place.longitude - 0.001, altitude: 0 } | |
], | |
fillColor: '#FF0000', | |
fillOpacity: 0.8, | |
strokeColor: '#000000', | |
strokeOpacity: 1, | |
strokeWidth: 2, | |
altitudeMode: 'RELATIVE_TO_GROUND', | |
zIndex: 10 // Ensure marker is visible above the terrain | |
}); | |
map.appendChild(marker); | |
} | |
// Helper function to get coordinates from a place name/address string | |
async function getPlaceCoordinates(placeString) { | |
const placesService = new google.maps.places.PlacesService(map); | |
const request = { | |
query: placeString, | |
fields: ['geometry.location'] | |
}; | |
console.log('Requesting info on place: ' + JSON.stringify(request, 2, null)); | |
return new Promise((resolve, reject) => { | |
placesService.findPlaceFromQuery(request, (results, status) => { | |
if (status === google.maps.places.PlacesServiceStatus.OK && results?.length > 0) { | |
console.log('Got result: ' + JSON.stringify(results, 2, null)); | |
resolve(results[0].geometry.location); // Return the LatLng object | |
} else { | |
reject(new Error(`Could not find place: ${placeString}`)); | |
} | |
}); | |
}); | |
} | |
function calculateZoomFromBounds(bounds, mapWidth, mapHeight) { | |
const WORLD_DIM = { height: 256, width: 256 }; | |
const ZOOM_MAX = 21; | |
function latRad(lat) { | |
const sin = Math.sin(lat * Math.PI / 180); | |
const radX2 = Math.log((1 + sin) / (1 - sin)) / 2; | |
return Math.max(Math.min(radX2, Math.PI), -Math.PI) / 2; | |
} | |
function zoom(mapPx, worldPx, fraction) { | |
return Math.floor(Math.log(mapPx / worldPx / fraction) / Math.LN2); | |
} | |
const ne = bounds.getNorthEast(); | |
const sw = bounds.getSouthWest(); | |
const latFraction = (latRad(ne.lat()) - latRad(sw.lat())) / Math.PI; | |
const lngDiff = ne.lng() - sw.lng(); | |
const lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360; | |
const latZoom = zoom(mapHeight, WORLD_DIM.height, latFraction); | |
const lngZoom = zoom(mapWidth, WORLD_DIM.width, lngFraction); | |
return Math.min(latZoom, lngZoom, ZOOM_MAX); | |
} | |
async function drawRoute(origin, destination) { | |
const directionsService = new google.maps.DirectionsService(); | |
const directionsRenderer = new google.maps.DirectionsRenderer(); | |
directionsRenderer.setMap(null); // Don't render on a 2D map | |
console.log('Origin: '+ origin + ' dest: ' + destination); | |
try { | |
// 1. Get Origin and Destination Coordinates (if needed) | |
origin = await getPlaceCoordinates(origin); | |
destination = await getPlaceCoordinates(destination); | |
// 2. Make Directions API Call | |
const request = { | |
origin: origin, | |
destination: destination, | |
travelMode: google.maps.TravelMode.DRIVING | |
}; | |
console.log("Sending Request: " + JSON.stringify(request, 2, null)); | |
const response = await directionsService.route(request); | |
// 3. Extract Route and Draw Polyline | |
const route = response.routes[0].overview_path.map(point => ({ | |
lat: point.lat(), | |
lng: point.lng(), | |
altitude: 10 | |
})); | |
const polyline = new google.maps.maps3d.Polyline3DElement({ | |
strokeColor: '#0000FF', | |
strokeWidth: 5, | |
altitudeMode: 'ABSOLUTE', | |
coordinates: route | |
}); | |
map.appendChild(polyline); | |
map.appendChild(polyline); | |
// Adjust viewport to show the route | |
const bounds = new google.maps.LatLngBounds(); | |
route.forEach(point => bounds.extend(point)); | |
set_viewport({ | |
lat: bounds.getCenter().lat(), | |
lng: bounds.getCenter().lng(), | |
zoom: calculateZoomFromBounds(bounds, map.offsetWidth, map.offsetHeight) | |
}); | |
} catch (error) { | |
console.error('Error getting directions:', error); | |
throw error; | |
} | |
} | |
// --- Animation Effects --- | |
function panUp(duration) { | |
const startAltitude = map.center.altitude; | |
const endAltitude = startAltitude + 500; // Pan up by 500 meters | |
animateProperty('altitude', startAltitude, endAltitude, duration); | |
} | |
async function orbit(location, duration) { | |
const center = await getPlaceCoordinates(location); | |
const initialHeading = map.heading; | |
const range = calculateRangeFromZoom(14); // Example: Orbit at zoom level 14 | |
const tilt = 60; | |
let elapsed = 0; | |
const interval = 20; // milliseconds | |
const timer = setInterval(() => { | |
elapsed += interval; | |
const progress = Math.min(elapsed / (duration * 1000), 1); // 0 to 1 | |
// Elliptical path calculation | |
const angle = progress * 2 * Math.PI; | |
const x = range * Math.cos(angle); | |
const y = range * 0.6 * Math.sin(angle); // Adjust 0.6 for ellipse shape | |
const newLatLng = google.maps.geometry.spherical.computeOffset( | |
center, | |
Math.sqrt(x*x + y*y), | |
google.maps.geometry.spherical.computeHeading(center, map.center) + (angle * 180 / Math.PI) | |
); | |
map.center = { lat: newLatLng.lat(), lng: newLatLng.lng(), altitude: map.center.altitude }; | |
map.heading = initialHeading + (progress * 360); // Rotate 360 degrees | |
map.range = range; | |
map.tilt = tilt; | |
if (progress >= 1) clearInterval(timer); | |
}, interval); | |
} | |
function zoomDown(location, duration) { | |
getPlaceCoordinates(location) | |
.then(center => { | |
const startAltitude = 10000; // Start from 10 km above | |
const endAltitude = 100; // Zoom down to 100 meters | |
animateProperty('altitude', startAltitude, endAltitude, duration, center); | |
}) | |
.catch(error => console.error('Error getting coordinates for zoomDown:', error)); | |
} | |
async function flyTo(location, duration) { | |
const endLocation = await getPlaceCoordinates(location); | |
const startLocation = map.center; | |
const heading = google.maps.geometry.spherical.computeHeading(startLocation, endLocation); | |
animateProperty('latitude', startLocation.lat, endLocation.lat(), duration, null, heading); | |
animateProperty('longitude', startLocation.lng, endLocation.lng(), duration); | |
} | |
async function spiralDown(location, duration) { | |
const center = await getPlaceCoordinates(location); | |
const initialHeading = map.heading; | |
const startAltitude = 5000; | |
const endAltitude = 100; | |
const startRange = calculateRangeFromZoom(8); | |
const endRange = calculateRangeFromZoom(15); | |
let elapsed = 0; | |
const interval = 20; | |
const timer = setInterval(() => { | |
elapsed += interval; | |
const progress = Math.min(elapsed / (duration * 1000), 1); | |
const angle = progress * 8 * Math.PI; // 8 rotations | |
const radius = startRange - (progress * (startRange - endRange)); | |
const newLatLng = google.maps.geometry.spherical.computeOffset( | |
center, | |
radius, | |
initialHeading + (angle * 180 / Math.PI) | |
); | |
map.center = { | |
lat: newLatLng.lat(), | |
lng: newLatLng.lng(), | |
altitude: startAltitude - (progress * (startAltitude - endAltitude)) | |
}; | |
map.heading = initialHeading + (angle * 180 / Math.PI); | |
map.range = radius; | |
if (progress >= 1) clearInterval(timer); | |
}, interval); | |
} | |
async function dollyZoom(location, zoomFactor, duration) { | |
const center = await getPlaceCoordinates(location); | |
const startRange = map.range; | |
const endRange = startRange / zoomFactor; | |
animateProperty('range', startRange, endRange, duration); | |
animateProperty('latitude', map.center.lat, center.lat(), duration); | |
animateProperty('longitude', map.center.lng, center.lng(), duration); | |
} | |
// --- Animation Helper Function --- | |
function animateProperty(property, startValue, endValue, duration, targetCenter = null, targetHeading = null) { | |
let elapsed = 0; | |
const interval = 20; // milliseconds | |
const timer = setInterval(() => { | |
elapsed += interval; | |
const progress = Math.min(elapsed / (duration * 1000), 1); | |
const easedProgress = easeInOutQuad(progress); // Smooth easing function | |
if (property === 'altitude') { | |
const newAltitude = startValue + (easedProgress * (endValue - startValue)); | |
map.center = { ...(targetCenter || map.center), altitude: newAltitude }; | |
} else if (property === 'latitude' || property === 'longitude') { | |
const newLat = startValue + (easedProgress * (endValue - startValue)); | |
const newLng = property === 'latitude' | |
? map.center.lng + (easedProgress * (targetCenter.lng - map.center.lng)) | |
: startValue + (easedProgress * (endValue - startValue)); | |
map.center = { lat: newLat, lng: newLng, altitude: map.center.altitude }; | |
if (targetHeading !== null) { | |
map.heading = targetHeading; | |
} | |
} else { | |
map[property] = startValue + (easedProgress * (endValue - startValue)); | |
} | |
if (progress >= 1) clearInterval(timer); | |
}, interval); | |
} | |
// Easing function for smoother animations (easeInOutQuad) | |
function easeInOutQuad(t) { | |
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"dependencies": { | |
"lit": "^3.0.0", | |
"@lit/reactive-element": "^2.0.0", | |
"lit-element": "^4.0.0", | |
"lit-html": "^3.0.0" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
margin: 0; | |
padding: 0; | |
font-family: 'Arial Black', Arial, sans-serif; | |
} | |
#loading-spinner { | |
display: none; /* Hidden by default */ | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
z-index: 1000; /* Ensure spinner is above other elements */ | |
width: 50px; | |
height: 50px; | |
border: 8px solid #f3f3f3; /* Light grey */ | |
border-top: 8px solid #3498db; /* Blue */ | |
border-radius: 50%; | |
animation: spin 2s linear infinite; | |
} | |
@keyframes spin { | |
0% { transform: translate(-50%, -50%) rotate(0deg); } | |
100% { transform: translate(-50%, -50%) rotate(360deg); } | |
} | |
/* Default styles (for larger screens/desktops) */ | |
#container { | |
display: flex; | |
height: 100vh; | |
} | |
#side-panel { | |
width: 300px; | |
background-color: #f0f0f0; | |
padding: 20px; | |
position: fixed; | |
top: 0; | |
right: 0; | |
transform: translateX(400px); | |
transition: transform 0.3s ease-in-out; | |
z-index: 100; | |
height: calc(100% - 80px); /* Adjust height for input area */ | |
} | |
#map-pane { | |
position: absolute; | |
top: 0; | |
bottom: 0; | |
width: 100%; | |
} | |
#input-area { | |
display: flex; | |
justify-content: center; | |
flex-direction: column; /* Stack input and button vertically */ | |
align-items: stretch; /* Make input take full width */ | |
align-items: center; | |
width: 80%; | |
position: absolute; | |
bottom: 20px; | |
left: 50%; | |
transform: translateX(-50%); | |
z-index: 10; | |
} | |
#prompt-input::placeholder { /* Target the placeholder specifically */ | |
color: #f2f2f2; /* Off-white color for placeholder */ | |
} | |
#prompt-input { | |
width: 80%; | |
padding: 8px; /* Smaller padding on mobile */ | |
font-size: 14pt; /* Smaller font size on mobile */ | |
border: none; | |
border-radius: 20px; | |
background: linear-gradient(to right, #e66465, #9198e5); | |
color: white; | |
margin-bottom: 20px; /* Add spacing between input and button */ | |
} | |
#run-button { | |
padding: 8px 16px; /* Smaller padding on mobile */ | |
background: linear-gradient(to right, #9198e5, #e66465); | |
color: white; | |
border: none; | |
border-radius: 20px; | |
cursor: pointer; | |
font-size: 14pt; /* Smaller font size on mobile */ | |
} | |
#logs-button { | |
position: fixed; | |
top: 10px; | |
right: 10px; | |
padding: 8px 16px; /* Smaller padding on mobile */ | |
background-color: red; | |
color: white; | |
border: none; | |
border-radius: 5px; | |
cursor: pointer; | |
z-index: 100; | |
} | |
#side-panel { | |
height: 100%; | |
width: 300px; | |
background-color: #f0f0f0; | |
padding: 20px; | |
position: fixed; | |
top: 0; | |
right: 0; | |
transform: translateX(400px); /* Pushed further right */ | |
transition: transform 0.3s ease-in-out; | |
z-index: 100; | |
} | |
#side-panel.open { | |
transform: translateX(0); | |
} | |
#close-panel { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
font-size: 20px; | |
cursor: pointer; | |
} | |
#prompt-box, #response-box, #error-box { | |
width: 90%; | |
height: 100px; | |
margin-bottom: 10px; | |
border: 1px solid #ccc; | |
padding: 10px; | |
overflow-y: scroll; | |
font-size: 14px; | |
} | |
.emoji { | |
font-size: 18px; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"files": { | |
"style.css": { | |
"position": 0 | |
}, | |
"index.html": { | |
"position": 1 | |
}, | |
"package.json": { | |
"position": 2, | |
"hidden": true | |
}, | |
"index.js": { | |
"position": 3 | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment