diff --git a/components/chat.tsx b/components/chat.tsx index 1f398561..e4dc13a8 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -75,11 +75,14 @@ export function Chat({ id }: ChatProps) { // useEffect to call the server action when drawnFeatures changes useEffect(() => { - if (id && mapData.drawnFeatures && mapData.drawnFeatures.length > 0) { + if (id && mapData.drawnFeatures && mapData.cameraState) { console.log('Chat.tsx: drawnFeatures changed, calling updateDrawingContext', mapData.drawnFeatures); - updateDrawingContext(id, mapData.drawnFeatures); + updateDrawingContext(id, { + drawnFeatures: mapData.drawnFeatures, + cameraState: mapData.cameraState, + }); } - }, [id, mapData.drawnFeatures]); + }, [id, mapData.drawnFeatures, mapData.cameraState]); // Mobile layout if (isMobile) { diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx index 3d076cdc..cdbc5c11 100644 --- a/components/header-search-button.tsx +++ b/components/header-search-button.tsx @@ -10,6 +10,8 @@ import { AI } from '@/app/actions' import { nanoid } from 'nanoid' import { UserMessage } from './user-message' import { toast } from 'react-toastify' +import { useSettingsStore } from '@/lib/store/settings' +import { useMapData } from './map/map-data-context' // Define an interface for the actions to help TypeScript during build interface HeaderActions { @@ -18,6 +20,8 @@ interface HeaderActions { export function HeaderSearchButton() { const { map } = useMap() + const { mapProvider } = useSettingsStore() + const { mapData } = useMapData() // Cast the actions to our defined interface to avoid build errors const actions = useActions() as unknown as HeaderActions const [, setMessages] = useUIState() @@ -32,12 +36,11 @@ export function HeaderSearchButton() { }, []) const handleResolutionSearch = async () => { - if (!map) { + if (mapProvider === 'mapbox' && !map) { toast.error('Map is not available yet. Please wait for it to load.') return } if (!actions) { - // This should theoretically not happen if the component is used correctly toast.error('Search actions are not available.') return } @@ -53,10 +56,31 @@ export function HeaderSearchButton() { } ]) - const canvas = map.getCanvas() - const blob = await new Promise(resolve => { - canvas.toBlob(resolve, 'image/png') - }) + let blob: Blob | null = null; + + if (mapProvider === 'mapbox') { + const canvas = map!.getCanvas() + blob = await new Promise(resolve => { + canvas.toBlob(resolve, 'image/png') + }) + } else if (mapProvider === 'google') { + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY + if (!apiKey || !mapData.cameraState) { + toast.error('Google Maps API key or camera state is not available.') + setIsAnalyzing(false) + return + } + const { center, range } = mapData.cameraState + const zoom = Math.round(Math.log2(40000000 / (range || 1))); + + let staticMapUrl = `https://maps.googleapis.com/maps/api/staticmap?center=${center.lat},${center.lng}&zoom=${zoom}&size=640x480&maptype=satellite&key=${apiKey}`; + + const response = await fetch(staticMapUrl); + if (!response.ok) { + throw new Error('Failed to fetch static map image.'); + } + blob = await response.blob(); + } if (!blob) { throw new Error('Failed to capture map image.') diff --git a/components/map/google-geojson-layer.tsx b/components/map/google-geojson-layer.tsx new file mode 100644 index 00000000..6c516574 --- /dev/null +++ b/components/map/google-geojson-layer.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useMap } from '@vis.gl/react-google-maps'; + +interface GoogleGeoJsonLayerProps { + data: GeoJSON.FeatureCollection; +} + +export function GoogleGeoJsonLayer({ data }: GoogleGeoJsonLayerProps) { + const map = useMap(); + const layerRef = useRef(null); + + useEffect(() => { + if (!map) return; + + // Remove existing layer if it exists + if (layerRef.current) { + layerRef.current.setMap(null); + } + + // Create a new data layer + const newLayer = new google.maps.Data(); + layerRef.current = newLayer; + + // Set styles for polygons and polylines + newLayer.setStyle(feature => { + const geometryType = feature.getGeometry()?.getType(); + if (geometryType === 'Polygon') { + return { + fillColor: '#088', + fillOpacity: 0.4, + strokeColor: '#088', + strokeWeight: 2, + }; + } + if (geometryType === 'LineString') { + return { + strokeColor: '#088', + strokeWeight: 2, + }; + } + return {}; + }); + + // Add GeoJSON data to the layer + newLayer.addGeoJson(data); + + // Set the map for the new layer + newLayer.setMap(map); + + return () => { + if (newLayer) { + newLayer.setMap(null); + } + }; + }, [map, data]); + + return null; +} diff --git a/components/map/google-map.tsx b/components/map/google-map.tsx index 7e735d1d..8cf6e4f8 100644 --- a/components/map/google-map.tsx +++ b/components/map/google-map.tsx @@ -1,12 +1,13 @@ 'use client' import { APIProvider } from '@vis.gl/react-google-maps' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useToast } from '@/components/ui/hooks/use-toast' import { useMapData } from './map-data-context' import { useSettingsStore } from '@/lib/store/settings' import { useMapLoading } from '../map-loading-context'; import { Map3D } from './map-3d' +import { GoogleGeoJsonLayer } from './google-geojson-layer' export function GoogleMapComponent() { const { toast } = useToast() @@ -34,14 +35,44 @@ export function GoogleMapComponent() { }; }, [setIsMapLoaded]); + const featureCollection = useMemo(() => { + const features = mapData.drawnFeatures?.map(df => ({ + type: 'Feature' as const, + geometry: df.geometry, + properties: { + id: df.id, + measurement: df.measurement + } + })) || []; + + return { + type: 'FeatureCollection' as const, + features, + }; + }, [mapData.drawnFeatures]); + + const cameraOptions = useMemo(() => { + if (mapData.cameraState) { + const { center, zoom, pitch, bearing } = mapData.cameraState; + // Convert Mapbox zoom to Google Maps range (approximate) + const range = zoom ? 40000000 / Math.pow(2, zoom) : 20000000; + return { + center, + range, + tilt: pitch || 0, + heading: bearing || 0, + }; + } + if (mapData.targetPosition) { + return { center: mapData.targetPosition, range: 1000, tilt: 60, heading: 0 }; + } + return { center: { lat: 37.7749, lng: -122.4194 }, range: 1000, tilt: 60, heading: 0 }; + }, [mapData.cameraState, mapData.targetPosition]); + if (!apiKey) { return null } - const cameraOptions = mapData.targetPosition - ? { center: mapData.targetPosition, range: 1000, tilt: 60, heading: 0 } - : { center: { lat: 37.7749, lng: -122.4194 }, range: 1000, tilt: 60, heading: 0 }; - return ( + ) } diff --git a/components/map/map-3d.tsx b/components/map/map-3d.tsx index 986a05d7..c2e16cdc 100644 --- a/components/map/map-3d.tsx +++ b/components/map/map-3d.tsx @@ -12,6 +12,7 @@ import {useCallbackRef} from '@/lib/hooks/use-callback-ref'; import {useMap3DCameraEvents} from '@/lib/hooks/use-map-3d-camera-events'; import {useDeepCompareEffect} from '@/lib/hooks/use-deep-compare-effect'; import type {Map3DProps} from './map-3d-types'; +import { useMapData } from './map-data-context'; export const Map3D = forwardRef( ( @@ -19,11 +20,25 @@ export const Map3D = forwardRef( forwardedRef: ForwardedRef ) => { useMapsLibrary('maps3d'); + const { setMapData } = useMapData(); const [map3DElement, map3dRef] = useCallbackRef(); useMap3DCameraEvents(map3DElement, p => { + const { center, range, heading, tilt } = p.detail; + const lat = center.lat(); + const lng = center.lng(); + setMapData(prevData => ({ + ...prevData, + cameraState: { + ...prevData.cameraState, + center: { lat, lng }, + range, + heading, + tilt + } + })); if (!props.onCameraChange) return; props.onCameraChange(p); diff --git a/components/map/map-data-context.tsx b/components/map/map-data-context.tsx index 68b0754f..b96d7018 100644 --- a/components/map/map-data-context.tsx +++ b/components/map/map-data-context.tsx @@ -2,8 +2,19 @@ import React, { createContext, useContext, useState, ReactNode } from 'react'; // Define the shape of the map data you want to share +export interface CameraState { + center: { lat: number; lng: number }; + zoom?: number; + pitch?: number; + bearing?: number; + range?: number; + tilt?: number; + heading?: number; +} + export interface MapData { targetPosition?: { lat: number; lng: number } | null; // For flying to a location + cameraState?: CameraState; // For saving camera state // TODO: Add other relevant map data types later (e.g., routeGeoJSON, poiList) mapFeature?: any | null; // Generic feature from MCP hook's processLocationQuery drawnFeatures?: Array<{ // Added to store drawn features and their measurements diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index c85f700a..c4217eed 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -320,9 +320,20 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number const center = map.current.getCenter(); const zoom = map.current.getZoom(); const pitch = map.current.getPitch(); + const bearing = map.current.getBearing(); currentMapCenterRef.current = { center: [center.lng, center.lat], zoom, pitch }; + + setMapData(prevData => ({ + ...prevData, + cameraState: { + center: { lat: center.lat, lng: center.lng }, + zoom, + pitch, + bearing + } + })); } - }, []) + }, [setMapData]) // Set up idle rotation checker useEffect(() => { @@ -392,18 +403,34 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number // Initialize map (only once) useEffect(() => { if (mapContainer.current && !map.current) { + let initialCenter: [number, number] = [position?.longitude ?? 0, position?.latitude ?? 0]; let initialZoom = 2; - if (typeof window !== 'undefined' && window.innerWidth < 768) { + let initialPitch = 0; + let initialBearing = 0; + + if (mapData.cameraState) { + const { center, range, tilt, heading, zoom, pitch, bearing } = mapData.cameraState; + initialCenter = [center.lng, center.lat]; + if (zoom !== undefined) { + initialZoom = zoom; + } else if (range !== undefined) { + initialZoom = Math.log2(40000000 / range); + } + initialPitch = pitch ?? tilt ?? 0; + initialBearing = bearing ?? heading ?? 0; + } else if (typeof window !== 'undefined' && window.innerWidth < 768) { initialZoom = 1.3; } + currentMapCenterRef.current = { center: initialCenter, zoom: initialZoom, pitch: initialPitch }; + map.current = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/satellite-streets-v12', - center: currentMapCenterRef.current.center, - zoom: currentMapCenterRef.current.zoom, - pitch: currentMapCenterRef.current.pitch, - bearing: 0, + center: initialCenter, + zoom: initialZoom, + pitch: initialPitch, + bearing: initialBearing, maxZoom: 22, attributionControl: true, preserveDrawingBuffer: true diff --git a/dev_server.log b/dev_server.log index 81e1a748..e69de29b 100644 --- a/dev_server.log +++ b/dev_server.log @@ -1,14 +0,0 @@ -$ next dev --turbo - ▲ Next.js 15.3.6 (Turbopack) - - Local: http://localhost:3000 - - Network: http://192.168.0.2:3000 - - Environments: .env - - ✓ Starting... -Attention: Next.js now collects completely anonymous telemetry regarding usage. -This information is used to shape Next.js' roadmap and prioritize features. -You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL: -https://nextjs.org/telemetry - - ✓ Compiled middleware in 388ms - ✓ Ready in 1859ms diff --git a/lib/actions/chat.ts b/lib/actions/chat.ts index 6c6ece14..c257d6e8 100644 --- a/lib/actions/chat.ts +++ b/lib/actions/chat.ts @@ -162,7 +162,7 @@ export async function saveChat(chat: OldChatType, userId: string): Promise { test.beforeEach(async ({ page }) => { await page.goto('/'); - // Wait for the map to be loaded - await page.waitForSelector('.mapboxgl-canvas'); + // Wait for either the Mapbox or Google Map to be loaded + await page.waitForSelector('.mapboxgl-canvas, gmp-map-3d'); }); test('should toggle the map mode', async ({ page }) => { + const isMapbox = await page.locator('.mapboxgl-canvas').isVisible(); + if (!isMapbox) { + test.skip(true, 'Drawing mode test is only for Mapbox'); + return; + } + await page.click('[data-testid="map-toggle"]'); await page.click('[data-testid="map-mode-draw"]'); // Add an assertion here to verify that the map is in drawing mode @@ -17,19 +23,28 @@ test.describe('Map functionality', () => { }); test('should zoom in and out using map controls', async ({ page }) => { + const isMapbox = await page.locator('.mapboxgl-canvas').isVisible(); + if (!isMapbox) { + test.skip(true, 'Zoom controls test is only for Mapbox'); + return; + } + const hasMap = await page.evaluate(() => Boolean((window as any).map)); if (!hasMap) test.skip(true, 'Map instance not available on window for E2E'); const getZoom = () => page.evaluate(() => (window as any).map.getZoom()); const initialZoom = await getZoom(); + await page.click('.mapboxgl-ctrl-zoom-in'); - await page.waitForFunction(() => (window as any).map.getZoom() > initialZoom); + await page.evaluate(() => new Promise(res => (window as any).map.once('zoomend', res))); + const zoomedInZoom = await getZoom(); expect(zoomedInZoom).toBeGreaterThan(initialZoom); await page.click('.mapboxgl-ctrl-zoom-out'); - await page.waitForFunction(() => (window as any).map.getZoom() < zoomedInZoom); + await page.evaluate(() => new Promise(res => (window as any).map.once('zoomend', res))); + const zoomedOutZoom = await getZoom(); expect(zoomedOutZoom).toBeLessThan(zoomedInZoom); });