Skip to content

Instantly share code, notes, and snippets.

@ryanbaumann
Created May 29, 2024 20:49
Show Gist options
  • Save ryanbaumann/2b94a611fa643fc66447cdec2d146eb5 to your computer and use it in GitHub Desktop.
Save ryanbaumann/2b94a611fa643fc66447cdec2d146eb5 to your computer and use it in GitHub Desktop.
<!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>
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;
}
{
"dependencies": {
"lit": "^3.0.0",
"@lit/reactive-element": "^2.0.0",
"lit-element": "^4.0.0",
"lit-html": "^3.0.0"
}
}
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;
}
{
"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