Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Comment on lines 76 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateDrawingContext is fired on any change to drawnFeatures/cameraState, but the guard is too permissive: drawnFeatures could be [] and you’ll still create a new data message (potentially on every camera move once cameraState exists). This can spam the DB/chat history and make later retrieval ambiguous.

This is a correctness/behavior issue: the diff moved from length > 0 to merely checking truthiness, which changes semantics.

Suggestion

Restore a stricter guard and/or add throttling/debouncing so you don’t persist on every minor camera update. For example:

useEffect(() => {
  if (!id) return;
  if (!mapData.cameraState) return;
  const features = mapData.drawnFeatures ?? [];
  if (features.length === 0) return;

  updateDrawingContext(id, { drawnFeatures: features, cameraState: mapData.cameraState });
}, [id, mapData.drawnFeatures, mapData.cameraState]);

If you do want to persist camera changes, add a debounce (e.g. 500–1000ms) and only write when the camera settles (provider events already expose idle/zoomend equivalents). Reply with "@CharlieHelps yes please" if you’d like me to add a commit implementing debounced persistence.


// Mobile layout
if (isMobile) {
Expand Down
36 changes: 30 additions & 6 deletions components/header-search-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Comment on lines 21 to 25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the header-search-button component
find . -name "header-search-button.tsx" -type f

Repository: QueueLab/QCX

Length of output: 93


🌐 Web query:

Google Static Maps API CORS browser fetch support client-side

💡 Result:

Short answer: You cannot reliably fetch/inspect Google Maps Static API images from browser JavaScript because Google’s Maps web-service endpoints do not send CORS response headers (so fetch/XHR will be blocked and drawing the image into a canvas will taint it). Use an tag to display the static map, or else proxy the request through your own server (or use the Maps JavaScript client APIs) if you need to fetch/inspect image bytes or avoid CORS issues. [1][2][3]

References/sources:

  • Maps Static API docs (usage for embedding images). [1]
  • Multiple issues/threads showing Google Maps web-service endpoints do not return Access‑Control‑Allow‑Origin (CORS) for browser fetches. [2]
  • Browser notes on cross‑origin images / canvas tainting (why an image without CORS headers can’t be read via canvas/fetch). [3]

🏁 Script executed:

# Once we find the file, let's examine the critical sections mentioned
cat -n components/header-search-button.tsx | head -130

Repository: QueueLab/QCX

Length of output: 5391


Fix Google provider being unintentionally disabled, prevent dangling chat messages, and resolve CORS blocking of Static Maps fetch.

Google flow doesn't require map, but both buttons are disabled when !map (Line 108, 120). Additionally, the user message is appended before Google precondition checks (Line 51–57), causing a dangling message if the fetch fails. Most critically, the browser fetch() at line 78 will fail because Google Static Maps API does not return CORS headers—this is a hard blocker. The zoom derivation at line 74 is also unbounded and can produce invalid values (>21 or <0).

Required fixes:

  1. Make button disabled state provider-aware: disabled={isAnalyzing || !actions || (mapProvider === 'mapbox' && !map)}
  2. Move Google API key / cameraState validation before setMessages()
  3. Clamp zoom to valid range: Math.min(21, Math.max(0, zoomValue))
  4. Server-side proxy for Static Maps fetch — client-side fetch cannot work due to CORS restrictions. Use an API route to proxy the request and return the image blob.
Proposed patch outline
 export function HeaderSearchButton() {
   const { map } = useMap()
   const { mapProvider } = useSettingsStore()
   const { mapData } = useMapData()
   
   const handleResolutionSearch = async () => {
     if (mapProvider === 'mapbox' && !map) {
       toast.error('Map is not available yet. Please wait for it to load.')
       return
     }
     if (!actions) {
       toast.error('Search actions are not available.')
       return
     }

     setIsAnalyzing(true)

     try {
+      // Validate preconditions BEFORE emitting user message
+      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
+        }
+      }
+
       setMessages(currentMessages => [
         ...currentMessages,
         {
           id: nanoid(),
           component: <UserMessage content={[{ type: 'text', text: 'Analyze this map view.' }]} />
         }
       ])

       let blob: Blob | null = null;

       if (mapProvider === 'mapbox') {
         const canvas = map!.getCanvas()
         blob = await new Promise<Blob | null>(resolve => {
           canvas.toBlob(resolve, 'image/png')
         })
       } else if (mapProvider === 'google') {
         const { center, range } = mapData.cameraState
-        const zoom = Math.round(Math.log2(40000000 / (range || 1)));
+        const zoomGuess = Math.round(Math.log2(40000000 / Math.max(1, range ?? 1)))
+        const zoom = Math.min(21, Math.max(0, zoomGuess))

-        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);
+        // Use server-side proxy instead of direct fetch (CORS incompatible)
+        const response = await fetch('/api/static-map', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ center, zoom })
+        })
         if (!response.ok) {
           throw new Error('Failed to fetch static map image.');
         }
         blob = await response.blob();
       }

-  const desktopButton = (
+  const desktopButton = (
     <Button
       variant="ghost"
       size="icon"
       onClick={handleResolutionSearch}
-      disabled={isAnalyzing || !map || !actions}
+      disabled={isAnalyzing || !actions || (mapProvider === 'mapbox' && !map)}
       title="Analyze current map view"
     >
       {isAnalyzing ? (
         <div className="h-5 w-5 animate-spin rounded-full border-b-2 border-current"></div>
       ) : (
         <Search className="h-[1.2rem] w-[1.2rem]" />
       )}
     </Button>
   )

   const mobileButton = (
-    <Button variant="ghost" size="sm" onClick={handleResolutionSearch} disabled={isAnalyzing || !map || !actions}>
+    <Button variant="ghost" size="sm" onClick={handleResolutionSearch} disabled={isAnalyzing || !actions || (mapProvider === 'mapbox' && !map)}>
       <Search className="h-4 w-4 mr-2" />
       Search
     </Button>
   )
🤖 Prompt for AI Agents
In @components/header-search-button.tsx around lines 21 - 25, In
HeaderSearchButton, fix three client issues and add a server proxy: update the
disabled prop to use provider-aware logic (disabled={isAnalyzing || !actions ||
(mapProvider === 'mapbox' && !map)}) so Google buttons aren’t incorrectly
disabled; move Google API key and cameraState validation (the checks that
currently happen after setMessages) to before calling setMessages to avoid
dangling user messages on failure; clamp derived zoom using Math.min(21,
Math.max(0, zoomValue)) to keep zoom in [0,21]; and replace the direct browser
fetch of the Google Static Maps URL with a call to a new server-side API route
that proxies the Google request (server endpoint fetches the image with the API
key, returns the image blob/base64 and proper CORS headers) and update the
client code to call that route and only append messages/set state after the
proxied fetch succeeds.

const actions = useActions<typeof AI>() as unknown as HeaderActions
const [, setMessages] = useUIState<typeof AI>()
Expand All @@ -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
}
Expand All @@ -53,10 +56,31 @@ export function HeaderSearchButton() {
}
])

const canvas = map.getCanvas()
const blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve, 'image/png')
})
let blob: Blob | null = null;

if (mapProvider === 'mapbox') {
const canvas = map!.getCanvas()
blob = await new Promise<Blob | null>(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();
}
Comment on lines +59 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Google Static Maps URL construction has multiple functional issues:

  • zoom derivation uses range with a hardcoded 40000000 and log2 which is not a valid mapping between Google’s zoom levels and the 3D range (meters) used by Maps 3D. This will frequently produce unusable zoom values (too high/low) and can even go negative.
  • The URL uses maptype=satellite but omits things like scale=2 (better quality) and doesn’t encode values; center should be URL-encoded.
  • There’s no cap for Google Static Maps zoom (0–21 typically), so out-of-range will fail.

This will make screenshot capture flaky/incorrect in production.

Suggestion

Introduce a small, tested helper to map cameraState to Static Maps parameters, clamp zoom, and encode the URL. If you only have center reliably, prefer a conservative fixed zoom (or persist an explicit zoom for Google) rather than guessing from range.

Example:

const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));

function toStaticMapsUrl(center: {lat:number; lng:number}, zoom: number, apiKey: string) {
  const z = clamp(Math.round(zoom), 0, 21);
  const qs = new URLSearchParams({
    center: `${center.lat},${center.lng}`,
    zoom: String(z),
    size: '640x480',
    scale: '2',
    maptype: 'satellite',
    key: apiKey,
  });
  return `https://maps.googleapis.com/maps/api/staticmap?${qs.toString()}`;
}

Then use mapData.cameraState.zoom ?? DEFAULT_ZOOM and only fall back to range if you can validate the mapping. Reply with "@CharlieHelps yes please" if you’d like me to add a commit with a helper + clamping/encoding.


if (!blob) {
throw new Error('Failed to capture map image.')
Expand Down
60 changes: 60 additions & 0 deletions components/map/google-geojson-layer.tsx
Original file line number Diff line number Diff line change
@@ -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<google.maps.Data | null>(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 {};
});
Comment on lines +27 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add support for Point geometries and make colors configurable.

The styling currently only handles Polygon and LineString geometries. Point features (markers) are not styled and will use default appearance.

  1. Add Point support for complete GeoJSON rendering:
♻️ Add Point geometry styling
  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,
      };
    }
+   if (geometryType === 'Point') {
+     return {
+       icon: {
+         path: google.maps.SymbolPath.CIRCLE,
+         fillColor: '#088',
+         fillOpacity: 0.8,
+         strokeColor: '#fff',
+         strokeWeight: 2,
+         scale: 8,
+       },
+     };
+   }
    return {};
  });
  1. Make colors configurable by accepting a styleOptions prop instead of hard-coding #088:
♻️ Make colors configurable via props
  interface GoogleGeoJsonLayerProps {
    data: GeoJSON.FeatureCollection;
+   styleOptions?: {
+     fillColor?: string;
+     strokeColor?: string;
+     fillOpacity?: number;
+     strokeWeight?: number;
+   };
  }

- export function GoogleGeoJsonLayer({ data }: GoogleGeoJsonLayerProps) {
+ export function GoogleGeoJsonLayer({ 
+   data, 
+   styleOptions = {
+     fillColor: '#088',
+     strokeColor: '#088',
+     fillOpacity: 0.4,
+     strokeWeight: 2,
+   }
+ }: GoogleGeoJsonLayerProps) {
    // ... then use styleOptions in the setStyle callback
🤖 Prompt for AI Agents
In @components/map/google-geojson-layer.tsx around lines 27 - 44, The current
newLayer.setStyle callback only handles 'Polygon' and 'LineString' so Point
features render with defaults; update the component to accept a styleOptions
prop (e.g., { color, fillOpacity, strokeWeight, pointIcon }) with sensible
defaults and use those values inside newLayer.setStyle (inspect
feature.getGeometry()?.getType() as geometryType) to add a branch for 'Point'
that returns a marker/icon style (or circle marker) using styleOptions.pointIcon
or color for stroke/fill, and replace hard-coded '#088' in the Polygon and
LineString branches with styleOptions.color; ensure the prop is typed/optional
and defaults are applied when undefined.


// 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]);
Comment on lines +14 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GoogleGeoJsonLayer recreates the entire google.maps.Data layer on every data change. For frequent updates (draw edits, camera sync), this can be expensive and can also cause brief flicker.

You can keep a single Data layer instance for the component lifetime, then clear and re-add features on data changes (forEach + remove) instead of setMap(null) and new google.maps.Data() each time.

Suggestion

Create the Data layer once when map becomes available, then update its contents when data changes.

useEffect(() => {
  if (!map || layerRef.current) return;
  layerRef.current = new google.maps.Data({ map });
  layerRef.current.setStyle(/* ... */);
  return () => layerRef.current?.setMap(null);
}, [map]);

useEffect(() => {
  const layer = layerRef.current;
  if (!layer) return;
  layer.forEach(f => layer.remove(f));
  layer.addGeoJson(data);
}, [data]);

Reply with "@CharlieHelps yes please" if you’d like me to add a commit refactoring this to avoid layer churn.

Comment on lines +14 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider performance optimization for frequent data updates.

The current implementation recreates the entire Data layer on every data change (lines 18-24), which could cause performance issues if features are updated frequently.

Consider:

  1. Incremental updates: Check if features have actually changed before recreating the layer
  2. Delta updates: Add/remove only changed features instead of recreating everything
  3. Debouncing: If updates are very frequent, debounce the effect
♻️ Example with change detection
  const map = useMap();
  const layerRef = useRef<google.maps.Data | null>(null);
+ const prevDataRef = useRef<string>('');

  useEffect(() => {
    if (!map) return;

+   // Check if data actually changed
+   const dataString = JSON.stringify(data);
+   if (dataString === prevDataRef.current && layerRef.current) {
+     return;
+   }
+   prevDataRef.current = dataString;

    // Remove existing layer if it exists
    if (layerRef.current) {
      layerRef.current.setMap(null);
    }
    // ... rest of the code
  }, [map, data]);
🤖 Prompt for AI Agents
In @components/map/google-geojson-layer.tsx around lines 14 - 57, The effect
currently recreates the Google Data layer on every change of data (useEffect
with layerRef and newLayer), so refactor to create the Data layer once (on map
ready) and reuse layerRef.current thereafter, then implement incremental
updates: compare incoming data to existing features on layerRef.current (use
Data.forEach, getId()/properties or geometry hashes) to addGeoJson only new
features and remove or update changed ones instead of calling addGeoJson for
all; if updates are very frequent, wrap the update logic in a debounced handler
(e.g., debounce the effect trigger or update function) to reduce churn and avoid
repeatedly calling setMap(null)/setMap(map).


return null;
}
42 changes: 37 additions & 5 deletions components/map/google-map.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -34,21 +35,52 @@ 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]);
Comment on lines +54 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cameraOptions derivation mixes Mapbox and Google camera concepts and computes range from zoom with a magic constant. This risks unstable camera syncing (jumping/oscillation) when switching providers because Map3D also writes back range/heading/tilt into cameraState. You can end up with a loop where Mapbox→zoom→range→Map3D emits range→saved→recomputed, etc.

At minimum, store a provider-normalized camera model and convert at the boundary, or track which provider last authored the camera state to avoid feedback loops.

Suggestion

Introduce a normalized camera state schema (e.g., always store center + zoom + bearing + pitch), and in Google Map compute range from zoom only for the view component (do not persist range). Alternatively, persist both but add source: 'mapbox' | 'google' and only convert when consuming from the other provider.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit implementing source tagging and boundary conversions to prevent feedback loops.

Comment on lines +54 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Mapbox zoom level to altitude meters conversion formula

💡 Result:

Short answer — two steps:

  1. Meters per pixel at latitude φ and zoom z:
    resolution = 156543.03392804062 * cos(φ * π/180) / 2^z. [1][2]

  2. Convert that to camera altitude (meters) for a perspective camera with viewport height H (pixels) and vertical field‑of‑view fov (radians):
    altitude = (0.5 * H * resolution) / tan(fov/2).

Combine (substituting resolution):
altitude = (0.5 * H * 156543.03392804062 * cos(φ * π/180) / 2^z) / tan(fov/2).

Notes/sources:

  • Mapbox/tiles zoom ↔ meters-per-pixel and zoom glossary. [1]
  • standard Mercator resolution formula (156543.0339 * cos φ / 2^z). [2]
  • Mapbox SDK provides MGLAltitudeForZoomLevel / MGLZoomLevelForAltitude; deck.gl / WebMercatorViewport describe altitude↔fov/viewport relations. Use those helpers when available. [3][4]

References:
[1] Mapbox zoom-level docs.
[2] Microsoft/Bing maps resolution formula.
[3] Mapbox SDK docs (MGLAltitudeForZoomLevel).
[4] deck.gl / WebMercatorViewport (camera altitude ⇄ fov/viewport).


🌐 Web query:

Google Maps 3D camera range altitude zoom level documentation

💡 Result:

Quick references from Google's docs:

  • "Range" = camera distance (meters) between camera and map center; 0 m up to 63,170,000 m (max). See Maps 3D camera controls (Android/iOS/JS). (developers.google.com)
  • Map center can include altitude (LatLngAltitude, meters above ground); default altitude = 63,170,000 m. (developers.google.com)
  • Zoom level (2D APIs / SDK) is a separate concept: zoom 0 = whole world ≈256dp wide; each +1 doubles scale (zoom 15 ≈ streets, 20 ≈ buildings). Use getMinimumZoomLevel()/getMaximumZoomLevel() for device-specific limits. (developers.google.com)
  • Camera restrictions / bounds, tilt, heading, and examples for setting range/altitude are shown in the Maps JavaScript 3D camera examples. (developers.google.com)

If you want direct code samples or a specific platform (JavaScript, Android, iOS), tell me which and I’ll pull the exact snippet.

Citations:


🌐 Web query:

Mapbox zoom to Google Maps camera distance conversion

💡 Result:

Short recipe + formulas.

  1. Meters per pixel at latitude (web‑mercator):
    MPP = 156543.03392 * cos(lat * π/180) / 2^zoom.
    (If you use Mapbox GL JS 512px tiles, Mapbox shows values offset by +1 zoom vs 256px tiles—adjust accordingly.) [1][2]

  2. Convert MPP → camera range/altitude (distance between camera and look‑at point).
    Let H = map viewport height in pixels, fovy = camera vertical field‑of‑view in radians.
    The visible half‑height in meters at the center = (H/2) * MPP.
    For a pinhole camera model: range ≈ ((H/2) * MPP) / tan(fovy/2).

    So putting it together:
    range ≈ ( (H/2) * 156543.03392 * cos(lat * π/180) / 2^zoom ) / tan(fovy/2).

    (Some SDKs call this value altitude, range or camera distance; Mapbox/math.gl and Google Maps 3D use compatible web‑mercator math and convert between fovy ↔ altitude.) [3][4]

Notes:

  • You must supply latitude, viewport height (pixels) and the camera fovy (or use the SDK’s default fovy) to get an absolute distance.
  • Mapbox GL’s internal “altitude ↔ zoom” uses a specific default fovy; if you want exact parity, use the SDK’s conversion helpers (e.g. WebMercatorViewport/math.gl). [3][1]

Sources: Mapbox zoom & meters/pixel docs; web‑mercator meters‑per‑pixel formula; WebMercatorViewport / fovy↔altitude docs; Google Maps camera docs. [1][2][3][4]


The zoom-to-range conversion is fundamentally inaccurate and missing critical parameters.

The formula 40000000 / Math.pow(2, zoom) is a severe oversimplification. The correct conversion requires three additional factors: latitude-dependent adjustment (cos(latitude)), viewport height in pixels, and camera field-of-view angle.

The accurate formula is:

range ≈ ((H/2) * 156543.03392 * cos(lat * π/180) / 2^zoom) / tan(fov/2)

Where:

  • H = viewport height in pixels
  • lat = map latitude
  • fov = camera vertical field of view (radians)
  • 156543.03392 = correct Mercator constant (not 40000000)

The current hardcoded constant is ~255× too high, and ignoring latitude causes the range to vary incorrectly across the map. This produces incorrect camera positioning, especially at high latitudes. Use a proper SDK conversion helper (e.g., WebMercatorViewport or similar utilities) or implement the complete formula accounting for viewport dimensions and camera parameters.


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 (
<APIProvider apiKey={apiKey} version="alpha">
<Map3D
style={{ width: '100%', height: '100%' }}
cameraOptions={cameraOptions}
mode="SATELLITE"
/>
<GoogleGeoJsonLayer data={featureCollection} />
</APIProvider>
)
}
15 changes: 15 additions & 0 deletions components/map/map-3d.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,33 @@ 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(
(
props: Map3DProps,
forwardedRef: ForwardedRef<google.maps.maps3d.Map3DElement | null>
) => {
useMapsLibrary('maps3d');
const { setMapData } = useMapData();

const [map3DElement, map3dRef] =
useCallbackRef<google.maps.maps3d.Map3DElement>();

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
}
}));
Comment on lines 26 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Map3D writes cameraState using Google-flavored keys (range, tilt, heading), while Mapbox writes Mapbox-flavored keys (zoom, pitch, bearing). Mixing both into one interface makes it easy to end up with partially-populated state and incorrect conversions.

Also, the update currently spreads ...prevData.cameraState which can carry incompatible fields across providers (e.g., keeping an old zoom while updating range), which can break downstream logic that assumes one source of truth.

Suggestion

Normalize cameraState to a single canonical representation (recommended: {center, zoom, bearing, pitch} like Mapbox) and derive provider-specific fields at the edges. For Google Maps 3D events, compute an equivalent zoom (or store both but namespaced):

cameraState: {
  provider: 'google',
  center: {lat, lng},
  google: { range, heading, tilt },
  // optionally derived canonical values
}

At minimum: stop spreading the previous camera state across provider updates; overwrite it explicitly per provider update to avoid stale fields.

Reply with "@CharlieHelps yes please" if you’d like me to add a commit that introduces a canonical camera model + provider adapters.

if (!props.onCameraChange) return;

props.onCameraChange(p);
Expand Down
11 changes: 11 additions & 0 deletions components/map/map-data-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 33 additions & 6 deletions components/map/mapbox-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 0 additions & 14 deletions dev_server.log
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export async function saveChat(chat: OldChatType, userId: string): Promise<strin
// return null;
// }

export async function updateDrawingContext(chatId: string, drawnFeatures: any[]) {
export async function updateDrawingContext(chatId: string, contextData: { drawnFeatures: any[], cameraState: any }) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Improve type safety for contextData parameter.

The function signature uses any for both drawnFeatures and cameraState, which loses type safety and makes the contract unclear.

Define proper types for better type safety:

♻️ Define proper interfaces

In a shared types file (e.g., lib/types/map.ts):

export interface DrawnFeature {
  id: string;
  geometry: GeoJSON.Geometry;
  measurement?: {
    length?: number;
    area?: number;
  };
}

export interface CameraState {
  center: { lat: number; lng: number };
  zoom?: number;
  pitch?: number;
  bearing?: number;
}

export interface DrawingContextData {
  drawnFeatures: DrawnFeature[];
  cameraState: CameraState;
}

Then update the function signature:

- export async function updateDrawingContext(chatId: string, contextData: { drawnFeatures: any[], cameraState: any }) {
+ export async function updateDrawingContext(chatId: string, contextData: DrawingContextData) {
🤖 Prompt for AI Agents
In @lib/actions/chat.ts at line 165, Replace the loose any types in
updateDrawingContext with well-defined interfaces: create types DrawnFeature,
CameraState and DrawingContextData (e.g., in lib/types/map.ts) and update the
function signature of updateDrawingContext(chatId: string, contextData:
DrawingContextData) so drawnFeatures is DrawnFeature[] and cameraState is
CameraState; ensure any internal uses (e.g., accessing feature.id, geometry,
measurement and cameraState.center/zoom/pitch/bearing) conform to the new
interfaces and update imports to reference the new types.

'use server';
console.log('[Action] updateDrawingContext called for chatId:', chatId);

Expand All @@ -178,7 +178,7 @@ export async function updateDrawingContext(chatId: string, drawnFeatures: any[])
// id: `drawnData-${Date.now().toString()}`, // Let DB generate UUID
userId: userId,
role: 'data' as 'user' | 'assistant' | 'system' | 'tool' | 'data', // Cast 'data' if not in standard roles
content: JSON.stringify(drawnFeatures), // Store features as stringified JSON
content: JSON.stringify(contextData), // Store both features and camera state as stringified JSON
// type: 'drawing_context', // This field is not in the Drizzle 'messages' schema.
// If `type` is important, the schema needs to be updated or content needs to reflect it.
// For now, we'll assume 'content' holds the necessary info and role='data' signifies it.
Expand Down
2 changes: 1 addition & 1 deletion lib/hooks/use-deep-compare-effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ export function useDeepCompareEffect(
currentDependenciesRef.current = dependencies;
}

useEffect(callback, [currentDependenciesRef.current]);
useEffect(callback, [currentDependenciesRef.current, callback]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n lib/hooks/use-deep-compare-effect.ts

Repository: QueueLab/QCX

Length of output: 842


🏁 Script executed:

rg -n "useDeepCompareEffect" --type ts --type tsx -C 2

Repository: QueueLab/QCX

Length of output: 83


🏁 Script executed:

rg -n "useDeepCompareEffect" -C 2

Repository: QueueLab/QCX

Length of output: 1418


🏁 Script executed:

web_search

Repository: QueueLab/QCX

Length of output: 106


🏁 Script executed:

cat -n components/map/map-3d.tsx | head -70 | tail -30

Repository: QueueLab/QCX

Length of output: 1097


🏁 Script executed:

rg -A 5 "useDeepCompareEffect" components/map/map-3d.tsx

Repository: QueueLab/QCX

Length of output: 663


🏁 Script executed:

rg -B 2 -A 8 "useDeepCompareEffect" --type ts

Repository: QueueLab/QCX

Length of output: 2574


🏁 Script executed:

web_search

Repository: QueueLab/QCX

Length of output: 106


🏁 Script executed:

cat -n components/map/map-3d.tsx | sed -n '40,80p'

Repository: QueueLab/QCX

Length of output: 1455


🏁 Script executed:

rg -B 10 "useDeepCompareEffect" components/map/map-3d.tsx | grep -E "(useCallback|useDeepCompareEffect|\(\)|=>)"

Repository: QueueLab/QCX

Length of output: 256


🏁 Script executed:

cat -n lib/hooks/use-callback-ref.ts

Repository: QueueLab/QCX

Length of output: 658


Hook implementation defeats its optimization purpose with inline callbacks.

The deep comparison logic is negated because the effect dependency array includes callback, which changes on every render in this component. Both usages pass inline arrow functions (() => { ... }) rather than memoized callbacks, causing the effect to re-run every render regardless of whether dependencies have deeply changed.

Additionally, currentDependenciesRef.current in the dependency array doesn't behave as intended—React tracks the ref object by reference (which never changes), not its .current property value. The ref mutation is invisible to React's dependency system.

The correct implementation uses a counter signal that increments when dependencies change:

🔧 Proposed fix using signal pattern
 export function useDeepCompareEffect(
   callback: React.EffectCallback,
   dependencies: unknown[]
 ) {
-  const currentDependenciesRef = useRef<unknown[]>(undefined);
+  const currentDependenciesRef = useRef<unknown[]>();
+  const signalRef = useRef(0);
 
   if (!isEqual(currentDependenciesRef.current, dependencies)) {
     currentDependenciesRef.current = dependencies;
+    signalRef.current += 1;
   }
 
-  useEffect(callback, [currentDependenciesRef.current, callback]);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(callback, [signalRef.current]);
 }

The eslint-disable comment is necessary because the signal ensures the effect runs with the latest callback closure when dependencies change deeply, making explicit callback inclusion redundant.

}
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
baseURL: 'http://localhost:3003',
trace: 'on-first-retry',
},
Comment on lines 9 to 13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing Playwright baseURL to http://localhost:3003 hardcodes an environment assumption into the repo config and will break local runs/CI where the app still runs on 3000.

This should be driven by an env var with a sensible default.

Suggestion

Use process.env.PLAYWRIGHT_BASE_URL (or BASE_URL) with a fallback:

use: {
  baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
  trace: 'on-first-retry',
},

Reply with "@CharlieHelps yes please" if you’d like me to add a commit updating the config + CI docs/env.

Comment on lines 10 to 13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for port references across the repository
rg -n --hidden -S "3000|3003|PORT=|playwright_base_url|PLAYWRIGHT_BASE_URL|next dev|--port" . 2>/dev/null | head -50

Repository: QueueLab/QCX

Length of output: 45050


🏁 Script executed:

#!/bin/bash
# Check package.json scripts
cat -n package.json 2>/dev/null | grep -E "(dev|start|build|playwright)" -A 2 -B 2

Repository: QueueLab/QCX

Length of output: 968


🏁 Script executed:

#!/bin/bash
# Find and check next.config files
fd -e "next.config" -e ".env*" -e "playwright.config.ts" --type f

Repository: QueueLab/QCX

Length of output: 38


Use environment-driven configuration to make Playwright baseURL portable across different dev setups.

Currently baseURL is hardcoded to 3003, which happens to work because port 3000 is occupied in this environment. However, if port 3000 becomes available, the Next.js app will start there while Playwright attempts 3003, causing test failures. Additionally, next.config.mjs lists only http://localhost:3000 in allowedOrigins, creating a mismatch.

Proposed patch
 export default defineConfig({
   testDir: './tests',
@@
   reporter: 'html',
   use: {
-    baseURL: 'http://localhost:3003',
+    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
     trace: 'on-first-retry',
   },
@@
-  /* webServer: {
+  /* webServer: {
     command: process.env.CI ? 'npm run build && npm run start' : 'npm run dev',
-    url: 'http://localhost:3000',
+    url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
     reuseExistingServer: !process.env.CI,
     timeout: 600000,
   }, */
 });

Also applies to: 36-41

projects: [
Expand Down
Loading