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
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:

  1. Ride-sharing app with real-time driver tracking
  2. Event finder with calendar integration
  3. Travel planner with multi-stop routing
  4. Field service app with job scheduling
  5. 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!