From 326418d931e087dc84173dba9f00819dd6bfcaa9 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:14:53 +0000 Subject: [PATCH 1/2] feat: Synchronize map state between Mapbox and Google Maps This commit introduces a comprehensive solution for synchronizing map state, including drawn features and camera position, between the Mapbox and Google Maps providers. Key changes include: - Extending the `MapDataContext` to include a `cameraState` field, creating a single source of truth for the map's view. - Implementing bidirectional camera state synchronization between Mapbox and Google Maps, including the necessary coordinate system conversions. - Creating a new `GoogleGeoJsonLayer` component to render GeoJSON data on the Google Map, allowing drawings made on Mapbox to be displayed on Google Maps. - Adding screenshot functionality for Google Maps using the Google Maps Static API. - Modifying the `updateDrawingContext` server action to persist both the `drawnFeatures` and `cameraState` to the database. - Making the Playwright tests in `tests/map.spec.ts` provider-aware to handle both Mapbox and Google Maps, preventing test failures when switching between providers. --- components/chat.tsx | 9 ++-- components/header-search-button.tsx | 36 +++++++++++--- components/map/google-geojson-layer.tsx | 60 ++++++++++++++++++++++++ components/map/google-map.tsx | 42 +++++++++++++++-- components/map/map-3d.tsx | 15 ++++++ components/map/map-data-context.tsx | 11 +++++ components/map/mapbox-map.tsx | 13 ++++- dev_server.log | Bin 323 -> 2332 bytes lib/actions/chat.ts | 4 +- lib/hooks/use-deep-compare-effect.ts | 2 +- playwright.config.ts | 2 +- tests/map.spec.ts | 23 +++++++-- 12 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 components/map/google-geojson-layer.tsx 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..0dae0bc6 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(() => { diff --git a/dev_server.log b/dev_server.log index 70d3b005f5baf5eb8b4831873f04981703df76e3..89c432c1a9563049a1bf282dfdcb30d097b0999b 100644 GIT binary patch literal 2332 zcmeH}O-{ow5Jp+&6yJgcNW}RmiD`+e&bFXhgq7YkQP#5Z^Z>IQaytATsC~& z2y_!v5MiC*HH!cY?>|^lQTAk!6>P>zoUMH2j&FA7o``X)H@vB6`xjg1x++N;f4J4w zRdjJNf=l5J+UzED0<(F}vhs?hEI&dQs+UHqRw!pHFroU0X8C%mn*(MgD4AQ%V+f`MQl82FzC SaI;t;LGC|13iINU-uhpFh-!lX delta 47 zcmbOubeL&^E2H5=w_qSKOAAOGO_ek?vdAswQg}3HvO-X5VoIe#W}bqPo_R4B0CSEH AEC2ui 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); }); From 07edb21be50545ccb87b02b2e7f3ab7f7456d605 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 15:32:57 +0000 Subject: [PATCH 2/2] fix: Preserve map context when switching from Google Maps to Mapbox This commit fixes a bug where the map's camera context was lost when switching from the Google Maps provider back to the Mapbox provider. The `mapbox-map.tsx` component has been updated to read the `cameraState` from the `MapDataContext` upon initialization. This ensures that the map's view (center, zoom, pitch, and bearing) is preserved when switching back to Mapbox, providing a seamless user experience. The logic to convert Google Maps' `range` to Mapbox's `zoom` has also been included. --- components/map/mapbox-map.tsx | 26 +++++++++++++++++++++----- dev_server.log | Bin 2332 -> 1524 bytes 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index 0dae0bc6..c4217eed 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -403,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 89c432c1a9563049a1bf282dfdcb30d097b0999b..7a8d97bb60bf97b28e843bebf20c4a9f6f58f1f4 100644 GIT binary patch literal 1524 zcmeHHOHRWu6l9%Kyafx8sOwLYCfTq+6&q9`-~w)AsA*zHv75pfSR#RhSa1f8)RW*e zNhwMt5bJtjS@Xs-?~P<1f)WoF5>Z00XBWjpd$4-@fJIYN2Bd<85hrew>JE6x zWy&Y1fV?3Ej$y3e3Gx77_3{a0CyMS35FarVz|l>mob&1YxMK9+LQi=byW@Jztj*(o zKdr!-Ha2#~K}{SByVS+Jp*BPkQAC0eVMMp6BUQ>mt4t_sVn9Nbo}1(3C?tu%e0y$lSz__B`*ZjRY$L@BH1pw1PGyFW@^PNH|t{+%K(MOJJ_#%0{aA- z1mJvh1AU;?*P$T9VN|`EpCDy}4oISC*ae6F`q8bg-||vUwL)$?ZO=qZf&eF?0$b0> zCmW%b_uHNDA3g#59__C_L4-HGwfznVrX7%yE%lbk@Q&aZVO>qihC4tXGiUS#aLLwj delta 83 zcmeyuJx6GQE2HT|w_qSKOAAOGO_ek=G|DaJQg}3HvO-X5VoIe#W}bqPo@p`H=0HY9 g=FJI=pO}EeB}Neich?XFeFY-}1CSC+Qxl*z0BMjJ8UO$Q