Project 4: Map & Location-Based App - ChatGPT Apps Deep Dive
Project 4: Map & Location-Based App - ChatGPT Apps Deep Dive
Transform geographic data into interactive experiences within ChatGPT
Project Overview
What youโll build: A store locator / delivery tracker app with interactive maps, marker clustering, location search, and detail panesโdemonstrating geographic visualization in ChatGPT.
Why this matters: Maps are one of the most visually impressive and practical component types in ChatGPT Apps. Users expect rich, interactive geographic interfaces when asking about locations, directions, or nearby services. This project teaches you how to integrate complex third-party mapping libraries within the constraints of ChatGPTโs widget environment while handling geographic data efficiently.
Real-world applications:
- Store/restaurant locators
- Delivery tracking systems
- Real estate search tools
- Field service management
- Event venue finders
- Travel planning assistants
Metadata
- File: LEARN_CHATGPT_APPS_DEEP_DIVE.md โ P04-map-location-based-app.md
- Main Programming Language: TypeScript/React
- Alternative Programming Languages: Vue, Svelte
- Coolness Level: Level 4: Hardcore Tech Flex
- Business Potential: 3. The โService & Supportโ Model
- Difficulty: Level 2: Intermediate
- Knowledge Area: Maps / Geolocation / Visualization
- Software or Tool: Mapbox GL JS, Leaflet, or Google Maps
- Main Book: โMapping Hacksโ by Schuyler Erle
- Time Estimate: 1-2 weeks
- Prerequisites: Projects 1-3 completed, basic geographic concepts
Section 1: Theoretical Foundation
1.1 Geographic Coordinate Systems
Latitude and Longitude Fundamentals
Geographic coordinates use a spherical coordinate system to identify any location on Earth:
Coordinate System Basics:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Geographic Coordinates โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ LATITUDE (ฯ): โ
โ โโ Range: -90ยฐ to +90ยฐ โ
โ โโ 0ยฐ: Equator โ
โ โโ +90ยฐ: North Pole โ
โ โโ -90ยฐ: South Pole โ
โ โโ Also called: Parallels โ
โ โ
โ LONGITUDE (ฮป): โ
โ โโ Range: -180ยฐ to +180ยฐ โ
โ โโ 0ยฐ: Prime Meridian (Greenwich, UK) โ
โ โโ +180ยฐ/-180ยฐ: International Date Line โ
โ โโ Also called: Meridians โ
โ โ
โ DECIMAL DEGREES: โ
โ โโ Standard format: [longitude, latitude] โ
โ โโ Example: San Francisco = [-122.4194, 37.7749] โ
โ โโ NOT [latitude, longitude] - common mistake! โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Coordinate Precision
Understanding precision is crucial for performance:
Decimal Places โ Precision:
โโ 0 (1.0): ~111 km (country level)
โโ 1 (1.2): ~11.1 km (city level)
โโ 2 (1.23): ~1.1 km (neighborhood)
โโ 3 (1.234): ~111 m (street)
โโ 4 (1.2345): ~11 m (individual tree)
โโ 5 (1.23456): ~1.1 m (door/person)
โโ 6 (1.234567): ~11 cm (survey precision)
For most apps: 5-6 decimal places is sufficient
Map Projections
Maps flatten the 3D Earth onto 2D screens using projections:
Common Projections:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Web Mercator (EPSG:3857) โ
โ โโ Used by: Google Maps, Mapbox, OpenStreetMap โ
โ โโ Pros: Preserves angles, good for navigation โ
โ โโ Cons: Distorts area (Greenland looks huge) โ
โ โโ Formula: x = ฮป, y = ln(tan(ฯ/2 + ฯ/4)) โ
โ โ
โ WGS84 (EPSG:4326) โ
โ โโ Used by: GPS, GeoJSON standard โ
โ โโ Pros: No distortion of coordinates โ
โ โโ Cons: Not suitable for direct display โ
โ โโ Formula: Simple lat/lng pairs โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1.2 Web Mapping Libraries
Mapbox GL JS Architecture
Mapbox GL JS is a JavaScript library for interactive, customizable maps:
Mapbox GL JS Stack:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Your React App โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ mapboxgl.Map Instance โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ WebGL Renderer (GPU-accelerated) โ โ โ
โ โ โ โโ Vector Tile Rendering โ โ โ
โ โ โ โโ Custom Layers โ โ โ
โ โ โ โโ Marker Rendering โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ โ
โ โ Style Definition (JSON) โ โ
โ โ โโ Sources: Where data comes from โ โ
โ โ โโ Layers: How data is rendered โ โ
โ โ โโ Sprites: Icons and symbols โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ Mapbox Services API โ
โ (tiles, geocoding, routing) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Key Mapbox Concepts
// Map initialization
const map = new mapboxgl.Map({
container: 'map', // HTML element ID
style: 'mapbox://styles/mapbox/streets-v12', // Style URL
center: [-122.4194, 37.7749], // [lng, lat]
zoom: 12, // Zoom level (0-22)
pitch: 0, // Tilt angle (0-60)
bearing: 0 // Rotation (0-360)
});
// Zoom levels explained:
// 0 = Whole world
// 3 = Continent
// 10 = City
// 15 = Streets
// 20 = Buildings
Leaflet Alternative
Leaflet is a lighter alternative to Mapbox:
Leaflet vs Mapbox GL JS:
โโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโ
โ Feature โ Leaflet โ Mapbox GL JS โ
โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโค
โ Size โ 42 KB โ 268 KB โ
โ Rendering โ Canvas/SVG โ WebGL (GPU) โ
โ Performance โ Good (100s) โ Excellent (1000s) โ
โ Vector Tiles โ Plugin โ Native โ
โ 3D Support โ No โ Yes โ
โ Mobile โ Excellent โ Good โ
โ Learning Curve โ Easy โ Moderate โ
โ Customization โ Good โ Excellent โ
โโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโ
Recommendation: Use Mapbox GL JS for ChatGPT apps
Reason: Better performance, modern features, richer UX
1.3 Geocoding and Reverse Geocoding
Geocoding: Address โ Coordinates
Converting human-readable addresses to geographic coordinates:
Geocoding Process:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ User Input โ
โ "123 Main St, San Francisco, CA" โ
โ โ โ
โ Geocoding API โ
โ โโ Parse address components โ
โ โโ Match against database โ
โ โโ Normalize and validate โ
โ โโ Return candidates โ
โ โ โ
โ Results (Multiple Candidates) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ 1. 123 Main St, San Francisco, CA 94102 โ โ
โ โ [-122.4194, 37.7749] โญ confidence: 0.95 โ โ
โ โ โ โ
โ โ 2. 123 Main St, San Francisco, CA 94103 โ โ
โ โ [-122.4087, 37.7699] confidence: 0.78 โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Reverse Geocoding: Coordinates โ Address
Converting coordinates back to addresses:
// Mapbox Geocoding API
const geocodeAddress = async (address) => {
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}.json?access_token=${token}`
);
const data = await response.json();
return data.features.map(feature => ({
address: feature.place_name,
coordinates: feature.center, // [lng, lat]
relevance: feature.relevance,
bbox: feature.bbox // Bounding box
}));
};
// Reverse geocoding
const reverseGeocode = async (lng, lat) => {
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${lng},${lat}.json?access_token=${token}`
);
const data = await response.json();
return data.features[0].place_name;
};
Geocoding Best Practices
Best Practices:
โโ Cache results to reduce API calls
โโ Handle ambiguity (multiple matches)
โโ Use bounding boxes to bias results
โโ Implement debouncing for search-as-you-type
โโ Store coordinates, not just addresses
โโ Have fallback for geocoding failures
1.4 Marker Clustering for Performance
Why Clustering Matters
When displaying hundreds or thousands of markers, performance degrades:
Performance Impact:
โโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโ
โ # Markers โ No Cluster โ With Cluster โ Improvementโ
โโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโโโโโค
โ 100 โ 60 FPS โ 60 FPS โ None โ
โ 500 โ 45 FPS โ 60 FPS โ 33% โ
โ 1,000 โ 25 FPS โ 60 FPS โ 140% โ
โ 5,000 โ 8 FPS โ 60 FPS โ 650% โ
โ 10,000 โ 2 FPS โ 60 FPS โ 2900% โ
โโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโโโโโ
Clustering Algorithm (Supercluster)
Supercluster is the industry-standard clustering library:
import Supercluster from 'supercluster';
// Initialize cluster
const cluster = new Supercluster({
radius: 40, // Cluster radius in pixels
maxZoom: 16, // Max zoom to cluster points
minZoom: 0, // Min zoom
minPoints: 2 // Minimum points to form cluster
});
// Load points
const points = shops.map(shop => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [shop.lng, shop.lat]
},
properties: {
id: shop.id,
name: shop.name,
// ... other properties
}
}));
cluster.load(points);
// Get clusters for current map view
const bounds = map.getBounds();
const zoom = Math.floor(map.getZoom());
const clusters = cluster.getClusters(
[bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()],
zoom
);
// clusters contains both individual points and cluster summaries
clusters.forEach(cluster => {
if (cluster.properties.cluster) {
// This is a cluster
const count = cluster.properties.point_count;
const clusterId = cluster.properties.cluster_id;
// Render cluster marker with count
} else {
// This is an individual point
// Render normal marker
}
});
Cluster Visualization
Visual Cluster Design:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Cluster Strategies โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ SIZE-BASED: โ
โ โโโโโโ โโโโโโโโ โโโโโโโโโโโ โ
โ โ 5 โ โ 12 โ โ 47 โ โ
โ โโโโโโ โโโโโโโโ โโโโโโโโโโโ โ
โ Small Medium Large โ
โ โ
โ COLOR-BASED: โ
โ โโโโโโ โโโโโโโโ โโโโโโโโโโโ โ
โ โ 5 โ โ 12 โ โ 47 โ โ
โ โโโโโโ โโโโโโโโ โโโโโโโโโโโ โ
โ Green Yellow Red โ
โ (few) (medium) (many) โ
โ โ
โ DONUT/PIE CHART (Multi-category): โ
โ โโโโโโโโโโโ โ
โ โ โฑโฒ 15 โ Categories: โ
โ โโฑ โฒ โ โโ Coffee: 8 (red) โ
โ โโฒ โฑ โ โโ Food: 5 (blue) โ
โ โ โฒโฑ โ โโ Retail: 2 (green) โ
โ โโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1.5 Responsive Map Sizing in iframe
ChatGPT Widget Constraints
Maps in ChatGPT widgets face unique challenges:
Widget Environment:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ChatGPT Conversation View โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ User message... โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ iframe (Your Widget) โ โ
โ โ โโ Width: Fluid (fills container) โ โ
โ โ โโ Default Height: ~300-400px โ โ
โ โ โโ Max Width: ~700px on desktop โ โ
โ โ โโ Mobile: Full width of screen โ โ
โ โ โโ Can request fullscreen โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Map Container (must fill iframe) โ โ โ
โ โ โ โโ Position: absolute OR flex-grow โ โ โ
โ โ โ โโ Height: 100% OR calc() โ โ โ
โ โ โ โโ Min height: 300px (usability) โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Responsive Layout Pattern
// Proper responsive map container
function MapWidget() {
return (
<div className="flex flex-col h-full min-h-[400px]">
{/* Header - Fixed height */}
<div className="flex-none p-4 border-b">
<h2>Coffee Shops Near You</h2>
<SearchInput />
</div>
{/* Map - Grows to fill space */}
<div className="flex-1 relative min-h-[300px]">
<MapContainer />
</div>
{/* Detail pane - Conditionally shown */}
{selectedShop && (
<div className="flex-none p-4 border-t max-h-[200px] overflow-y-auto">
<ShopDetails shop={selectedShop} />
</div>
)}
</div>
);
}
Map Resize Handling
// Mapbox requires resize notification
useEffect(() => {
if (!map.current) return;
// Resize observer for container changes
const resizeObserver = new ResizeObserver(() => {
map.current?.resize();
});
resizeObserver.observe(mapContainer.current);
return () => resizeObserver.disconnect();
}, []);
// Handle fullscreen transitions
useEffect(() => {
const handleFullscreenChange = () => {
setTimeout(() => {
map.current?.resize();
}, 100); // Allow DOM to settle
};
window.addEventListener('fullscreenchange', handleFullscreenChange);
return () => window.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
Section 2: Solution Architecture
2.1 Overall System Design
Map-Based App Architecture:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ChatGPT โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ User: "Find coffee shops near Union Square, SF" โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ChatGPT calls: find_nearby_places โ โ
โ โ Parameters: { โ โ
โ โ query: "coffee shops", โ โ
โ โ location: "Union Square, San Francisco", โ โ
โ โ radius_miles: 0.5 โ โ
โ โ } โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ MCP Server (Python) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ @mcp.tool() โ โ
โ โ def find_nearby_places(...): โ โ
โ โ 1. Geocode "Union Square, San Francisco" โ โ
โ โ โ [lng: -122.4075, lat: 37.7880] โ โ
โ โ โ โ
โ โ 2. Search Places API (Yelp/Google/Foursquare) โ โ
โ โ Within 0.5 miles of coordinates โ โ
โ โ โ โ
โ โ 3. Return structured data: โ โ
โ โ { โ โ
โ โ "center": {lng, lat}, โ โ
โ โ "places": [...], โ โ
โ โ "uiComponent": { โ โ
โ โ "type": "iframe", โ โ
โ โ "url": "https://.../map-widget.html" โ โ
โ โ } โ โ
โ โ } โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ React Widget (Map Component) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ useEffect(() => { โ โ
โ โ const data = window.openai.toolOutput; โ โ
โ โ โ โ
โ โ // Initialize map โ โ
โ โ map = new mapboxgl.Map({ โ โ
โ โ center: [data.center.lng, data.center.lat], โ โ
โ โ zoom: 14 โ โ
โ โ }); โ โ
โ โ โ โ
โ โ // Add markers with clustering โ โ
โ โ const cluster = new Supercluster(); โ โ
โ โ cluster.load(convertToGeoJSON(data.places)); โ โ
โ โ renderClusters(cluster); โ โ
โ โ }, []); โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
2.2 Integrating Mapbox GL JS in React
Component Structure
// src/components/MapWidget.tsx
import { useEffect, useRef, useState } from 'react';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import Supercluster from 'supercluster';
// Set Mapbox token
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
interface Place {
id: string;
name: string;
lat: number;
lng: number;
address: string;
rating?: number;
distance?: number;
category?: string;
}
interface MapData {
center: { lng: number; lat: number };
places: Place[];
query?: string;
location?: string;
}
export function MapWidget() {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const markers = useRef<mapboxgl.Marker[]>([]);
const cluster = useRef<Supercluster | null>(null);
const [data, setData] = useState<MapData | null>(null);
const [selectedPlace, setSelectedPlace] = useState<Place | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// Load data from window.openai.toolOutput
useEffect(() => {
const toolOutput = (window as any).openai?.toolOutput;
if (toolOutput) {
setData(toolOutput);
}
}, []);
// Initialize map
useEffect(() => {
if (!mapContainer.current || !data || map.current) return;
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/streets-v12',
center: [data.center.lng, data.center.lat],
zoom: 14,
attributionControl: false
});
// Add navigation controls
map.current.addControl(
new mapboxgl.NavigationControl(),
'top-right'
);
// Wait for map to load before adding markers
map.current.on('load', () => {
initializeClusters();
});
// Update clusters on zoom/pan
map.current.on('moveend', updateClusters);
return () => {
map.current?.remove();
map.current = null;
};
}, [data]);
// Initialize clustering
const initializeClusters = () => {
if (!data) return;
cluster.current = new Supercluster({
radius: 60,
maxZoom: 16,
minPoints: 2
});
// Convert places to GeoJSON features
const features = data.places.map(place => ({
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [place.lng, place.lat]
},
properties: place
}));
cluster.current.load(features);
updateClusters();
};
// Update markers based on current view
const updateClusters = () => {
if (!map.current || !cluster.current) return;
// Clear existing markers
markers.current.forEach(marker => marker.remove());
markers.current = [];
const bounds = map.current.getBounds();
const zoom = Math.floor(map.current.getZoom());
const clusters = cluster.current.getClusters(
[
bounds.getWest(),
bounds.getSouth(),
bounds.getEast(),
bounds.getNorth()
],
zoom
);
clusters.forEach(cluster => {
if (cluster.properties.cluster) {
addClusterMarker(cluster);
} else {
addPlaceMarker(cluster.properties);
}
});
};
// Add cluster marker
const addClusterMarker = (cluster: any) => {
const count = cluster.properties.point_count;
const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';
const el = document.createElement('div');
el.className = `cluster-marker cluster-${size}`;
el.textContent = count.toString();
const marker = new mapboxgl.Marker({ element: el })
.setLngLat(cluster.geometry.coordinates)
.addTo(map.current!);
// Click to zoom
el.addEventListener('click', () => {
const expansionZoom = cluster.current!.getClusterExpansionZoom(
cluster.properties.cluster_id
);
map.current!.easeTo({
center: cluster.geometry.coordinates,
zoom: expansionZoom
});
});
markers.current.push(marker);
};
// Add individual place marker
const addPlaceMarker = (place: Place) => {
const el = document.createElement('div');
el.className = 'place-marker';
el.innerHTML = 'โ'; // Use appropriate icon
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([place.lng, place.lat])
.addTo(map.current!);
// Click to show details
el.addEventListener('click', () => {
setSelectedPlace(place);
// Center map on selected place
map.current!.easeTo({
center: [place.lng, place.lat],
zoom: 16
});
});
markers.current.push(marker);
};
const handleFullscreen = () => {
if (isFullscreen) {
(window as any).openai?.exitFullscreen();
} else {
(window as any).openai?.requestFullscreen();
}
setIsFullscreen(!isFullscreen);
};
const handleGetDirections = (place: Place) => {
const url = `https://www.google.com/maps/dir/?api=1&destination=${place.lat},${place.lng}`;
window.open(url, '_blank');
};
const handleCallPlace = (place: Place) => {
(window as any).openai?.callTool('get_place_details', {
placeId: place.id
});
};
if (!data) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading map...</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-[500px]">
{/* Header */}
<div className="flex-none p-4 bg-white border-b">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">
{data.query} near {data.location}
</h2>
<p className="text-sm text-gray-600">
{data.places.length} places found
</p>
</div>
<button
onClick={handleFullscreen}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50"
>
{isFullscreen ? 'โ Exit' : 'โถ Fullscreen'}
</button>
</div>
</div>
{/* Map Container */}
<div className="flex-1 relative">
<div ref={mapContainer} className="absolute inset-0" />
</div>
{/* Selected Place Details */}
{selectedPlace && (
<div className="flex-none p-4 bg-white border-t">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{selectedPlace.name}</h3>
<p className="text-sm text-gray-600">{selectedPlace.address}</p>
{selectedPlace.rating && (
<div className="mt-1">
<span className="text-yellow-500">โญ</span>
<span className="ml-1 text-sm">{selectedPlace.rating}</span>
</div>
)}
{selectedPlace.distance && (
<p className="text-sm text-gray-500">
{selectedPlace.distance.toFixed(1)} mi away
</p>
)}
</div>
<button
onClick={() => setSelectedPlace(null)}
className="text-gray-400 hover:text-gray-600"
>
โ
</button>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleGetDirections(selectedPlace)}
className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
>
Directions
</button>
<button
onClick={() => handleCallPlace(selectedPlace)}
className="px-4 py-2 text-sm border rounded hover:bg-gray-50"
>
Details
</button>
</div>
</div>
)}
</div>
);
}
Cluster Marker Styles
/* src/styles/map.css */
.cluster-marker {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
font-weight: bold;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: transform 0.2s;
}
.cluster-marker:hover {
transform: scale(1.1);
}
.cluster-small {
width: 40px;
height: 40px;
background-color: #51bbd6;
font-size: 14px;
}
.cluster-medium {
width: 50px;
height: 50px;
background-color: #f1f075;
color: #333;
font-size: 16px;
}
.cluster-large {
width: 60px;
height: 60px;
background-color: #f28cb1;
font-size: 18px;
}
.place-marker {
font-size: 24px;
cursor: pointer;
transition: transform 0.2s;
filter: drop-shadow(0 2px 2px rgba(0,0,0,0.3));
}
.place-marker:hover {
transform: scale(1.2);
}
2.3 Marker Management and Click Handlers
Efficient Marker Updates
// Marker pooling for better performance
class MarkerPool {
private pool: mapboxgl.Marker[] = [];
private active: Set<mapboxgl.Marker> = new Set();
acquire(options?: mapboxgl.MarkerOptions): mapboxgl.Marker {
let marker = this.pool.pop();
if (!marker) {
marker = new mapboxgl.Marker(options);
}
this.active.add(marker);
return marker;
}
release(marker: mapboxgl.Marker) {
marker.remove();
this.active.delete(marker);
this.pool.push(marker);
}
releaseAll() {
this.active.forEach(marker => {
marker.remove();
this.pool.push(marker);
});
this.active.clear();
}
}
// Usage in component
const markerPool = useRef(new MarkerPool());
const updateMarkers = () => {
// Release all current markers
markerPool.current.releaseAll();
// Add new markers
places.forEach(place => {
const marker = markerPool.current.acquire();
marker.setLngLat([place.lng, place.lat]).addTo(map.current!);
});
};
Advanced Click Handling
// Popup on hover, select on click
const addInteractiveMarker = (place: Place) => {
const el = document.createElement('div');
el.className = 'place-marker';
el.innerHTML = getCategoryIcon(place.category);
// Create popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
offset: 15
});
// Hover to show popup
el.addEventListener('mouseenter', () => {
popup
.setLngLat([place.lng, place.lat])
.setHTML(`
<div class="p-2">
<div class="font-bold">${place.name}</div>
<div class="text-sm">${place.rating}โญ ยท ${place.distance}mi</div>
</div>
`)
.addTo(map.current!);
});
el.addEventListener('mouseleave', () => {
popup.remove();
});
// Click to select
el.addEventListener('click', (e) => {
e.stopPropagation();
setSelectedPlace(place);
// Highlight selected marker
document.querySelectorAll('.place-marker').forEach(m => {
m.classList.remove('selected');
});
el.classList.add('selected');
});
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([place.lng, place.lat])
.addTo(map.current!);
return marker;
};
// Category-specific icons
const getCategoryIcon = (category?: string): string => {
const icons: Record<string, string> = {
'coffee': 'โ',
'restaurant': '๐ฝ๏ธ',
'retail': '๐๏ธ',
'bar': '๐บ',
'park': '๐ณ'
};
return icons[category || ''] || '๐';
};
2.4 Location Search Tool Design
MCP Tool Implementation
# server/mcp_server.py
from fastmcp import FastMCP
from typing import Optional, List
import requests
from dataclasses import dataclass
mcp = FastMCP("Location Services")
@dataclass
class Place:
id: str
name: str
lat: float
lng: float
address: str
rating: Optional[float] = None
distance: Optional[float] = None
category: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
def geocode_location(location: str) -> dict:
"""Convert address/place name to coordinates using Mapbox Geocoding API"""
token = os.getenv("MAPBOX_TOKEN")
url = f"https://api.mapbox.com/geocoding/v5/mapbox.places/{location}.json"
response = requests.get(url, params={"access_token": token})
data = response.json()
if not data.get("features"):
raise ValueError(f"Could not geocode location: {location}")
feature = data["features"][0]
lng, lat = feature["center"]
return {
"lat": lat,
"lng": lng,
"place_name": feature["place_name"]
}
def search_places_yelp(query: str, lat: float, lng: float, radius_miles: float = 1.0, limit: int = 50) -> List[Place]:
"""Search for places using Yelp Fusion API"""
api_key = os.getenv("YELP_API_KEY")
url = "https://api.yelp.com/v3/businesses/search"
headers = {"Authorization": f"Bearer {api_key}"}
params = {
"term": query,
"latitude": lat,
"longitude": lng,
"radius": int(radius_miles * 1609), # Convert miles to meters
"limit": limit,
"sort_by": "distance"
}
response = requests.get(url, headers=headers, params=params)
data = response.json()
places = []
for business in data.get("businesses", []):
places.append(Place(
id=business["id"],
name=business["name"],
lat=business["coordinates"]["latitude"],
lng=business["coordinates"]["longitude"],
address=", ".join(business["location"]["display_address"]),
rating=business.get("rating"),
distance=business.get("distance", 0) / 1609, # meters to miles
category=business["categories"][0]["alias"] if business.get("categories") else None,
phone=business.get("phone"),
website=business.get("url")
))
return places
@mcp.tool()
def find_nearby_places(
query: str,
location: str,
radius_miles: float = 1.0,
limit: int = 50
) -> dict:
"""
Use this when user wants to find places near a location.
Examples:
- "Find coffee shops near Union Square, San Francisco"
- "Show me restaurants within 2 miles of Times Square"
- "What gyms are near 123 Main St, Boston?"
"""
try:
# Geocode the location
coords = geocode_location(location)
# Search for places
places = search_places_yelp(
query=query,
lat=coords["lat"],
lng=coords["lng"],
radius_miles=radius_miles,
limit=limit
)
# Convert to dict for JSON serialization
places_data = [
{
"id": p.id,
"name": p.name,
"lat": p.lat,
"lng": p.lng,
"address": p.address,
"rating": p.rating,
"distance": round(p.distance, 2) if p.distance else None,
"category": p.category,
"phone": p.phone,
"website": p.website
}
for p in places
]
return {
"structuredContent": {
"center": {
"lat": coords["lat"],
"lng": coords["lng"]
},
"places": places_data,
"query": query,
"location": coords["place_name"],
"total": len(places_data)
},
"uiComponent": {
"type": "iframe",
"url": os.getenv("WIDGET_URL") + "/map-widget.html"
}
}
except Exception as e:
return {
"structuredContent": {
"error": True,
"message": str(e)
}
}
@mcp.tool()
def get_place_details(place_id: str) -> dict:
"""
Use this when user wants detailed information about a specific place.
"""
api_key = os.getenv("YELP_API_KEY")
url = f"https://api.yelp.com/v3/businesses/{place_id}"
headers = {"Authorization": f"Bearer {api_key}"}
response = requests.get(url, headers=headers)
data = response.json()
# Extract hours
hours = []
if data.get("hours"):
for day_hours in data["hours"][0].get("open", []):
hours.append({
"day": day_hours["day"],
"start": day_hours["start"],
"end": day_hours["end"]
})
details = {
"id": data["id"],
"name": data["name"],
"photos": data.get("photos", []),
"rating": data.get("rating"),
"review_count": data.get("review_count"),
"price": data.get("price"),
"hours": hours,
"is_open_now": data.get("hours", [{}])[0].get("is_open_now"),
"categories": [cat["title"] for cat in data.get("categories", [])],
"phone": data.get("display_phone"),
"website": data.get("url")
}
return {
"structuredContent": details
}
@mcp.tool()
def get_directions(
from_location: str,
to_location: str,
mode: str = "driving"
) -> dict:
"""
Use this when user wants directions between two locations.
Mode can be: driving, walking, cycling, transit
"""
# Geocode both locations
from_coords = geocode_location(from_location)
to_coords = geocode_location(to_location)
# Use Mapbox Directions API
token = os.getenv("MAPBOX_TOKEN")
url = f"https://api.mapbox.com/directions/v5/mapbox/{mode}/{from_coords['lng']},{from_coords['lat']};{to_coords['lng']},{to_coords['lat']}"
params = {
"access_token": token,
"geometries": "geojson",
"steps": True,
"overview": "full"
}
response = requests.get(url, params=params)
data = response.json()
if not data.get("routes"):
return {
"structuredContent": {
"error": True,
"message": "No route found"
}
}
route = data["routes"][0]
return {
"structuredContent": {
"distance": route["distance"], # meters
"duration": route["duration"], # seconds
"geometry": route["geometry"], # GeoJSON LineString
"steps": [
{
"instruction": step["maneuver"]["instruction"],
"distance": step["distance"],
"duration": step["duration"]
}
for step in route["legs"][0]["steps"]
]
},
"uiComponent": {
"type": "iframe",
"url": os.getenv("WIDGET_URL") + "/directions-widget.html"
}
}
2.5 Map State Management
Widget State Persistence
// Persist map state across conversation turns
interface MapState {
center: [number, number];
zoom: number;
selectedPlaceId: string | null;
searchHistory: string[];
}
function MapWidget() {
// Load initial state
const [mapState, setMapState] = useState<MapState>(() => {
const savedState = (window as any).openai?.widgetState;
return savedState || {
center: [-122.4194, 37.7749],
zoom: 12,
selectedPlaceId: null,
searchHistory: []
};
});
// Persist state on changes
useEffect(() => {
(window as any).openai?.setWidgetState(mapState);
}, [mapState]);
// Update state when map moves
useEffect(() => {
if (!map.current) return;
const handleMoveEnd = () => {
const center = map.current!.getCenter();
const zoom = map.current!.getZoom();
setMapState(prev => ({
...prev,
center: [center.lng, center.lat],
zoom
}));
};
map.current.on('moveend', handleMoveEnd);
return () => {
map.current?.off('moveend', handleMoveEnd);
};
}, []);
// Handle place selection
const handleSelectPlace = (placeId: string) => {
setMapState(prev => ({
...prev,
selectedPlaceId: placeId
}));
};
// Add to search history
const addToSearchHistory = (query: string) => {
setMapState(prev => ({
...prev,
searchHistory: [query, ...prev.searchHistory.slice(0, 4)]
}));
};
return (
// ... component JSX
);
}
Handling Multiple Map Instances
// Use widgetSessionId for multi-map scenarios
function MapWidget() {
const sessionId = (window as any).openai?.widgetSessionId;
// Each session gets its own map instance
useEffect(() => {
const mapId = `map-${sessionId}`;
// Check if we already have this map in a cache
const existingMap = mapCache.get(mapId);
if (existingMap) {
map.current = existingMap;
return;
}
// Create new map
const newMap = new mapboxgl.Map({
container: mapContainer.current!,
style: 'mapbox://styles/mapbox/streets-v12',
center: [data.center.lng, data.center.lat],
zoom: 14
});
map.current = newMap;
mapCache.set(mapId, newMap);
}, [sessionId]);
}
Section 3: Implementation Guide
3.1 Project Setup
Initialize React Project
# Create Vite project with React + TypeScript
npm create vite@latest map-widget -- --template react-ts
cd map-widget
# Install dependencies
npm install mapbox-gl supercluster
npm install -D @types/mapbox-gl @types/supercluster
# Install build tools
npm install -D vite-plugin-singlefile
# Install OpenAI Apps SDK UI (optional)
npm install @openai/apps-sdk-ui
Environment Variables
# .env
VITE_MAPBOX_TOKEN=pk.eyJ1IjoieW91cnVzZXJuYW1lIiw...
VITE_WIDGET_URL=https://your-app.com
Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
export default defineConfig({
plugins: [
react(),
viteSingleFile() // Bundle into single HTML file
],
build: {
outDir: 'dist',
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});
3.2 Step-by-Step Implementation
Step 1: Create Basic Map Component
// src/components/Map.tsx
import { useEffect, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_TOKEN;
export function Map() {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
useEffect(() => {
if (map.current) return; // Initialize map only once
map.current = new mapboxgl.Map({
container: mapContainer.current!,
style: 'mapbox://styles/mapbox/streets-v12',
center: [-122.4194, 37.7749],
zoom: 12
});
return () => {
map.current?.remove();
};
}, []);
return <div ref={mapContainer} className="w-full h-full" />;
}
Step 2: Load Data from window.openai
// Add data loading
const [data, setData] = useState<MapData | null>(null);
useEffect(() => {
const toolOutput = (window as any).openai?.toolOutput;
if (toolOutput) {
setData(toolOutput);
}
}, []);
// Update map center when data loads
useEffect(() => {
if (!map.current || !data) return;
map.current.setCenter([data.center.lng, data.center.lat]);
map.current.setZoom(14);
}, [data]);
Step 3: Add Markers
// Add markers for each place
useEffect(() => {
if (!map.current || !data) return;
// Clear existing markers
markers.current.forEach(m => m.remove());
markers.current = [];
// Add new markers
data.places.forEach(place => {
const marker = new mapboxgl.Marker()
.setLngLat([place.lng, place.lat])
.addTo(map.current!);
markers.current.push(marker);
});
}, [data]);
Step 4: Implement Clustering
import Supercluster from 'supercluster';
const cluster = useRef<Supercluster | null>(null);
// Initialize cluster
useEffect(() => {
if (!data) return;
cluster.current = new Supercluster({
radius: 60,
maxZoom: 16,
minPoints: 2
});
const features = data.places.map(place => ({
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [place.lng, place.lat]
},
properties: place
}));
cluster.current.load(features);
updateClusters();
}, [data]);
// Update clusters on map move
useEffect(() => {
if (!map.current) return;
map.current.on('moveend', updateClusters);
return () => {
map.current?.off('moveend', updateClusters);
};
}, []);
const updateClusters = () => {
// Implementation from Section 2.2
};
Step 5: Add Interactivity
// Click handlers for markers
const addPlaceMarker = (place: Place) => {
const el = document.createElement('div');
el.className = 'place-marker';
el.innerHTML = 'โ';
el.addEventListener('click', () => {
setSelectedPlace(place);
});
return new mapboxgl.Marker({ element: el })
.setLngLat([place.lng, place.lat])
.addTo(map.current!);
};
// Details pane
{selectedPlace && (
<div className="p-4 border-t">
<h3>{selectedPlace.name}</h3>
<p>{selectedPlace.address}</p>
<button onClick={() => getDirections(selectedPlace)}>
Directions
</button>
</div>
)}
Step 6: Add Search Functionality
function SearchBox() {
const [query, setQuery] = useState('');
const handleSearch = () => {
(window as any).openai?.callTool('find_nearby_places', {
query,
location: 'current location', // Or from map center
radius_miles: 1.0
});
};
return (
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search for places..."
className="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={handleSearch}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Search
</button>
</div>
);
}
3.3 Building and Deploying
Build Widget
# Build single-file widget
npm run build
# Output: dist/index.html (self-contained)
Deploy to Static Hosting
# Deploy to Vercel
vercel deploy dist
# Deploy to Netlify
netlify deploy --prod --dir=dist
# Deploy to Cloudflare Pages
wrangler pages publish dist
Configure MCP Server
# Update widget URL in .env
WIDGET_URL=https://your-app.vercel.app
Section 4: Testing Strategy
4.1 Manual Testing
Test Checklist
Map Functionality:
โก Map loads and displays correctly
โก Map centers on correct location
โก Zoom controls work
โก Pan/drag works smoothly
โก Fullscreen mode works
Markers:
โก All markers appear at correct coordinates
โก Markers cluster at low zoom levels
โก Clusters expand when clicked
โก Individual markers show on high zoom
โก Marker icons display correctly
โก Click handlers work
Data:
โก Places data loads from toolOutput
โก Place details show correctly
โก Ratings, distances display properly
โก Phone numbers, addresses correct
Interactivity:
โก Click marker โ show details
โก Click cluster โ zoom in
โก Directions button works
โก Search triggers new tool call
โก State persists across turns
Performance:
โก Map renders within 2 seconds
โก Smooth at 60 FPS
โก Handles 100+ markers
โก No memory leaks
Responsive:
โก Works in default widget size
โก Works in fullscreen
โก Mobile-friendly
โก Handles window resize
4.2 Automated Testing
Unit Tests with Vitest
// src/utils/__tests__/coordinates.test.ts
import { describe, it, expect } from 'vitest';
import { calculateDistance, getBounds } from '../coordinates';
describe('calculateDistance', () => {
it('calculates distance between two points', () => {
const sf = { lat: 37.7749, lng: -122.4194 };
const la = { lat: 34.0522, lng: -118.2437 };
const distance = calculateDistance(sf, la);
expect(distance).toBeCloseTo(347, 0); // ~347 miles
});
});
describe('getBounds', () => {
it('calculates bounding box for points', () => {
const points = [
{ lat: 37.7749, lng: -122.4194 },
{ lat: 37.8044, lng: -122.2712 },
{ lat: 37.6879, lng: -122.4702 }
];
const bounds = getBounds(points);
expect(bounds.north).toBeCloseTo(37.8044);
expect(bounds.south).toBeCloseTo(37.6879);
});
});
Integration Tests
// src/__tests__/MapWidget.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { MapWidget } from '../components/MapWidget';
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock window.openai
beforeEach(() => {
(window as any).openai = {
toolOutput: {
center: { lng: -122.4194, lat: 37.7749 },
places: [
{
id: '1',
name: 'Blue Bottle Coffee',
lat: 37.7749,
lng: -122.4194,
address: '123 Main St',
rating: 4.5
}
],
query: 'coffee',
location: 'San Francisco'
},
callTool: vi.fn(),
setWidgetState: vi.fn()
};
});
describe('MapWidget', () => {
it('renders map with data', async () => {
render(<MapWidget />);
await waitFor(() => {
expect(screen.getByText('coffee near San Francisco')).toBeInTheDocument();
});
});
it('displays place details when marker clicked', async () => {
render(<MapWidget />);
// Click marker (simulate)
const marker = document.querySelector('.place-marker');
marker?.click();
await waitFor(() => {
expect(screen.getByText('Blue Bottle Coffee')).toBeInTheDocument();
expect(screen.getByText('123 Main St')).toBeInTheDocument();
});
});
});
Section 5: Common Patterns and Best Practices
5.1 Performance Optimization
Lazy Load Map Tiles
// Only load tiles for visible area
map.current.on('idle', () => {
const bounds = map.current!.getBounds();
// Prefetch adjacent tiles
prefetchTiles(bounds);
});
Debounce Search
import { useMemo } from 'react';
import debounce from 'lodash/debounce';
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedSearch = useMemo(
() => debounce((q: string) => {
if (q.length < 3) return;
(window as any).openai?.callTool('find_nearby_places', {
query: q,
location: getCurrentLocation()
});
}, 500),
[]
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
debouncedSearch(value);
};
return <input value={query} onChange={handleChange} />;
}
Optimize Marker Icons
// Use CSS transforms instead of recreating elements
const updateMarkerPosition = (marker: mapboxgl.Marker, newCoords: [number, number]) => {
marker.setLngLat(newCoords);
// Mapbox uses CSS transforms internally - very fast
};
// Reuse marker elements
const markerCache = new Map<string, HTMLDivElement>();
const getMarkerElement = (placeId: string): HTMLDivElement => {
if (!markerCache.has(placeId)) {
const el = document.createElement('div');
el.className = 'place-marker';
markerCache.set(placeId, el);
}
return markerCache.get(placeId)!;
};
5.2 Error Handling
Graceful Degradation
// Handle geocoding failures
try {
const coords = await geocode(address);
map.current?.setCenter([coords.lng, coords.lat]);
} catch (error) {
console.error('Geocoding failed:', error);
// Fallback to approximate location or show error
showNotification('Could not find exact location. Showing nearby area.');
map.current?.setCenter([-122.4194, 37.7749]);
}
// Handle missing map token
if (!import.meta.env.VITE_MAPBOX_TOKEN) {
return (
<div className="flex items-center justify-center h-full bg-gray-100">
<div className="text-center">
<p className="text-red-500 font-bold">Map token not configured</p>
<p className="text-sm text-gray-600">
Please add VITE_MAPBOX_TOKEN to .env
</p>
</div>
</div>
);
}
Network Error Handling
const searchPlaces = async (query: string) => {
try {
setLoading(true);
setError(null);
const response = await fetch(`/api/search?q=${query}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setPlaces(data.places);
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error');
// Show user-friendly message
showNotification('Search failed. Please try again.');
} finally {
setLoading(false);
}
};
5.3 Accessibility
Keyboard Navigation
// Add keyboard support for markers
const addAccessibleMarker = (place: Place) => {
const el = document.createElement('button');
el.className = 'place-marker';
el.setAttribute('aria-label', `${place.name}, ${place.rating} stars, ${place.distance} miles away`);
el.setAttribute('role', 'button');
el.tabIndex = 0;
const handleSelect = () => {
setSelectedPlace(place);
announceToScreenReader(`Selected ${place.name}`);
};
el.addEventListener('click', handleSelect);
el.addEventListener('keypress', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect();
}
});
return new mapboxgl.Marker({ element: el })
.setLngLat([place.lng, place.lat])
.addTo(map.current!);
};
// Screen reader announcements
const announceToScreenReader = (message: string) => {
const announcement = document.createElement('div');
announcement.setAttribute('role', 'status');
announcement.setAttribute('aria-live', 'polite');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
};
Alternative Text Views
// Provide list view as alternative to map
function MapOrList() {
const [viewMode, setViewMode] = useState<'map' | 'list'>('map');
return (
<div>
<div className="flex gap-2 p-4">
<button
onClick={() => setViewMode('map')}
aria-pressed={viewMode === 'map'}
>
Map View
</button>
<button
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
>
List View
</button>
</div>
{viewMode === 'map' ? (
<MapView places={places} />
) : (
<ListView places={places} />
)}
</div>
);
}
function ListView({ places }: { places: Place[] }) {
return (
<ul className="divide-y">
{places.map(place => (
<li key={place.id} className="p-4">
<h3 className="font-bold">{place.name}</h3>
<p>{place.address}</p>
<p>โญ {place.rating} ยท {place.distance} mi</p>
</li>
))}
</ul>
);
}
Section 6: Advanced Features
6.1 Route Visualization
Display Directions on Map
interface Route {
geometry: GeoJSON.LineString;
distance: number;
duration: number;
steps: Step[];
}
const displayRoute = (route: Route) => {
// Add route line to map
if (map.current!.getSource('route')) {
(map.current!.getSource('route') as mapboxgl.GeoJSONSource).setData({
type: 'Feature',
properties: {},
geometry: route.geometry
});
} else {
map.current!.addSource('route', {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: route.geometry
}
});
map.current!.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': '#3b82f6',
'line-width': 4,
'line-opacity': 0.75
}
});
}
// Fit map to route bounds
const coordinates = route.geometry.coordinates as [number, number][];
const bounds = coordinates.reduce(
(bounds, coord) => bounds.extend(coord as mapboxgl.LngLatLike),
new mapboxgl.LngLatBounds(coordinates[0], coordinates[0])
);
map.current!.fitBounds(bounds, { padding: 50 });
};
6.2 Real-Time Location Tracking
Userโs Current Location
const enableLocationTracking = () => {
if (!navigator.geolocation) {
console.error('Geolocation not supported');
return;
}
navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
// Update user marker
if (!userMarker.current) {
userMarker.current = new mapboxgl.Marker({ color: '#3b82f6' })
.setLngLat([longitude, latitude])
.addTo(map.current!);
} else {
userMarker.current.setLngLat([longitude, latitude]);
}
// Optionally center map
map.current!.easeTo({
center: [longitude, latitude]
});
},
(error) => {
console.error('Location error:', error);
},
{
enableHighAccuracy: true,
maximumAge: 10000,
timeout: 5000
}
);
};
6.3 Custom Map Styles
Dark Mode Map
const toggleMapStyle = (theme: 'light' | 'dark') => {
const styleUrl = theme === 'dark'
? 'mapbox://styles/mapbox/dark-v11'
: 'mapbox://styles/mapbox/streets-v12';
map.current?.setStyle(styleUrl);
};
// Detect system theme
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
toggleMapStyle(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handleChange);
// Set initial style
toggleMapStyle(mediaQuery.matches ? 'dark' : 'light');
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
Section 7: Debugging and Troubleshooting
7.1 Common Issues
Map Not Rendering
Issue: Blank white/gray screen
Causes:
โโ Missing Mapbox token
โโ Invalid token
โโ Container has no height
โโ Map initialized before container exists
Solutions:
1. Check token: console.log(mapboxgl.accessToken)
2. Inspect container: console.log(mapContainer.current?.offsetHeight)
3. Ensure container has explicit height in CSS
4. Wait for container ref before initializing map
Markers Not Appearing
Issue: Map loads but no markers visible
Causes:
โโ Coordinates are [lat, lng] instead of [lng, lat]
โโ Markers outside of visible bounds
โโ Markers have opacity: 0 or display: none
โโ Z-index issues
Solutions:
1. Swap coordinate order
2. Use fitBounds() to show all markers
3. Inspect marker elements in DevTools
4. Check CSS for hidden elements
Performance Issues
Issue: Map is slow/laggy
Causes:
โโ Too many markers (>1000) without clustering
โโ Heavy marker icons (large images)
โโ Rendering on every frame
โโ Memory leaks (not removing markers)
Solutions:
1. Implement clustering (Supercluster)
2. Use CSS sprites for icons
3. Debounce updates
4. Clean up markers in useEffect cleanup
7.2 Debugging Tools
Map Inspector
// Add debug info overlay
function MapDebugOverlay() {
const [debugInfo, setDebugInfo] = useState({});
useEffect(() => {
if (!map.current) return;
const updateInfo = () => {
const center = map.current!.getCenter();
const zoom = map.current!.getZoom();
const bounds = map.current!.getBounds();
setDebugInfo({
center: `${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}`,
zoom: zoom.toFixed(2),
bounds: `${bounds.getNorth().toFixed(4)}, ${bounds.getSouth().toFixed(4)}`,
markers: markers.current.length
});
};
map.current.on('move', updateInfo);
updateInfo();
return () => map.current?.off('move', updateInfo);
}, []);
return (
<div className="absolute top-2 right-2 bg-black bg-opacity-75 text-white text-xs p-2 rounded">
<div>Center: {debugInfo.center}</div>
<div>Zoom: {debugInfo.zoom}</div>
<div>Markers: {debugInfo.markers}</div>
</div>
);
}
Section 8: Performance Benchmarks
8.1 Target Metrics
Performance Goals:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโ
โ Metric โ Target โ Maximum โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโค
โ Initial Load Time โ < 1s โ < 2s โ
โ Time to Interactive โ < 1.5s โ < 3s โ
โ FPS (with 100 markers) โ 60 โ 30 โ
โ FPS (with 1000 markers) โ 60 โ 45 โ
โ Memory Usage โ < 50MB โ < 100MB โ
โ Bundle Size โ < 500KB โ < 1MB โ
โ API Response Time โ < 500ms โ < 1s โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโ
8.2 Optimization Techniques
Code Splitting
// Lazy load Mapbox only when needed
const MapboxMap = lazy(() => import('./components/MapboxMap'));
function MapWidget() {
return (
<Suspense fallback={<MapSkeleton />}>
<MapboxMap />
</Suspense>
);
}
Bundle Size Reduction
// Use modular imports
import mapboxgl from 'mapbox-gl';
// Instead of: import * as mapboxgl from 'mapbox-gl';
// Tree-shake lodash
import debounce from 'lodash/debounce';
// Instead of: import { debounce } from 'lodash';
Section 9: Learning Milestones
Track your progress through these milestones:
Level 1: Beginner
- Map renders successfully
- Markers appear at correct locations
- Click on marker shows details
- Map centers on data location
Level 2: Intermediate
- Clustering implemented and working
- Search triggers new data load
- State persists across turns
- Fullscreen mode works
- Mobile responsive
Level 3: Advanced
- Performance optimized for 1000+ markers
- Custom marker styles by category
- Route visualization works
- Real-time location tracking
- Accessibility features implemented
Level 4: Expert
- Published working app to ChatGPT
- Handles edge cases gracefully
- Zero performance issues
- Delightful user experience
- Analytics and monitoring set up
Section 10: Real-World Examples
10.1 Coffee Shop Finder
Features:
โโ Search by neighborhood
โโ Filter by rating, price, distance
โโ Show current wait times
โโ Display menu highlights
โโ "Order ahead" button
10.2 Delivery Tracker
Features:
โโ Real-time driver location
โโ Estimated arrival time
โโ Route visualization
โโ Order status updates
โโ Contact driver button
10.3 Real Estate Search
Features:
โโ Property markers with price
โโ Cluster by price range
โโ Filter by beds/baths/sqft
โโ Show school districts
โโ Schedule showing button
โโ Save favorites
Section 11: Extension Ideas
Once youโve mastered the basics, try these extensions:
11.1 Heatmaps
// Density visualization
map.current!.addLayer({
id: 'heatmap',
type: 'heatmap',
source: 'places',
paint: {
'heatmap-weight': ['get', 'rating'],
'heatmap-intensity': 1,
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(0,0,255,0)',
0.5, 'rgb(0,255,0)',
1, 'rgb(255,0,0)'
],
'heatmap-radius': 20
}
});
11.2 3D Buildings
// Add 3D extrusions
map.current!.addLayer({
id: '3d-buildings',
source: 'composite',
'source-layer': 'building',
filter: ['==', 'extrude', 'true'],
type: 'fill-extrusion',
paint: {
'fill-extrusion-color': '#aaa',
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
'fill-extrusion-opacity': 0.6
}
});
// Enable pitch
map.current!.setPitch(45);
11.3 Offline Support
// Cache tiles for offline use
const cacheMap = async () => {
const bounds = map.current!.getBounds();
const zoom = map.current!.getZoom();
// Request tiles for offline
const tiles = getTilesForBounds(bounds, zoom);
await Promise.all(
tiles.map(tile => caches.open('map-tiles').then(cache => cache.add(tile)))
);
};
Section 12: Resources
12.1 Official Documentation
- Mapbox GL JS: https://docs.mapbox.com/mapbox-gl-js/
- Mapbox API: https://docs.mapbox.com/api/
- Supercluster: https://github.com/mapbox/supercluster
- GeoJSON Spec: https://geojson.org/
12.2 Tutorials
- Mapbox GL JS Tutorials: https://docs.mapbox.com/help/tutorials/
- Building a Store Locator: https://docs.mapbox.com/help/tutorials/building-a-store-locator/
- Add Custom Markers: https://docs.mapbox.com/mapbox-gl-js/example/
12.3 Tools
- GeoJSON.io: https://geojson.io/ - Create/edit GeoJSON
- Mapbox Studio: https://studio.mapbox.com/ - Design custom map styles
- Turf.js: https://turfjs.org/ - Geospatial analysis
12.4 Alternative Mapping Libraries
Library Comparison:
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ Library โ Size โ Performance โ Features โ
โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโค
โ Mapbox GL JS โ 268 KB โ Excellent โ Full โ
โ Leaflet โ 42 KB โ Good โ Moderate โ
โ Google Maps JS โ ~400 KB โ Excellent โ Full โ
โ Deck.gl โ 1.2 MB โ Excellent โ 3D/Advanced โ
โ OpenLayers โ 420 KB โ Good โ Full โ
โโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโ
Section 13: Summary and Next Steps
What Youโve Learned
By completing this project, you now understand:
โ Geographic fundamentals: Lat/lng coordinates, projections, precision โ Mapbox GL JS: Initialization, styles, controls, events โ Marker management: Adding, clustering, interactivity โ Geocoding: Address โ coordinates conversion โ Performance: Clustering, debouncing, lazy loading โ ChatGPT integration: toolOutput, widget state, tool calls โ Responsive design: Handling iframe constraints, fullscreen โ User experience: Click handlers, details panes, search
Next Project
Youโre ready for Project 5: Form-Based Data Entry App, which covers:
- Multi-step forms
- Validation with Zod
- Destructive actions
- Create/update/delete patterns
Further Exploration
Consider building:
- Ride-sharing app with real-time driver tracking
- Event finder with calendar integration
- Travel planner with multi-stop routing
- Field service app with job scheduling
- Augmented reality location-based game
Congratulations! You can now build sophisticated map-based ChatGPT apps that handle geographic data beautifully. Your users can find locations, get directions, and explore places through rich, interactive mapsโall within ChatGPT.
The skills youโve learned here transfer to any web mapping project, not just ChatGPT apps. Youโre well on your way to becoming a mapping expert!