-
-
Notifications
You must be signed in to change notification settings - Fork 8
Implement Elevation Heat Map for Resolution Search #544
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ import { BotMessage } from '@/components/message' | |
| import { SearchSection } from '@/components/search-section' | ||
| import SearchRelated from '@/components/search-related' | ||
| import { GeoJsonLayer } from '@/components/map/geojson-layer' | ||
| import { ElevationHeatmapLayer } from '@/components/map/elevation-heatmap-layer' | ||
| import { ResolutionCarousel } from '@/components/resolution-carousel' | ||
| import { ResolutionImage } from '@/components/resolution-image' | ||
| import { CopilotDisplay } from '@/components/copilot-display' | ||
|
|
@@ -123,6 +124,36 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| ); | ||
| } | ||
|
|
||
| // Handle elevation heat map if requested | ||
| if (analysisResult.elevationData?.requested && analysisResult.elevationData.bounds) { | ||
| try { | ||
| const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; | ||
| const elevationResponse = await fetch( | ||
| `${baseUrl}/api/elevation?bounds=${JSON.stringify(analysisResult.elevationData.bounds)}&gridSize=${analysisResult.elevationData.gridSize || 20}` | ||
| ); | ||
|
Comment on lines
+128
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Building the elevation API URL by embedding raw Also, using Prefer calling the route via a relative URL from server code and encode query params explicitly. SuggestionUse a relative URL and const params = new URLSearchParams({
bounds: JSON.stringify(analysisResult.elevationData.bounds),
gridSize: String(analysisResult.elevationData.gridSize ?? 20),
});
const elevationResponse = await fetch(`/api/elevation?${params.toString()}`);If you need absolute URLs (e.g., for non-Next fetch contexts), derive it from Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||
|
|
||
| if (elevationResponse.ok) { | ||
| const elevationData = await elevationResponse.json(); | ||
|
|
||
| if (elevationData.points && elevationData.points.length > 0) { | ||
| (analysisResult as any).elevationPoints = elevationData.points; | ||
| (analysisResult as any).elevationStatistics = elevationData.statistics; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
|
Comment on lines
+137
to
+140
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This introduces Since the PR already extends the structured schema with SuggestionDefine a typed shape for the analysis payload you persist (e.g., type ResolutionSearchResult = {
// existing fields...
elevationPoints?: ElevationPoint[];
elevationStatistics?: ElevationStatistics;
};
const result: ResolutionSearchResult = analysisResult;
result.elevationPoints = elevationData.points;
result.elevationStatistics = elevationData.statistics;Then persist Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||
| uiStream.append( | ||
| <ElevationHeatmapLayer | ||
| id={groupeId} | ||
| points={elevationData.points} | ||
| statistics={elevationData.statistics} | ||
| /> | ||
| ); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error('Error fetching elevation data:', error); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
|
|
||
| messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); | ||
|
|
||
| const sanitizedMessages: CoreMessage[] = messages.map((m: any) => { | ||
|
|
@@ -173,7 +204,9 @@ async function submit(formData?: FormData, skip?: boolean) { | |
| ...analysisResult, | ||
| image: dataUrl, | ||
| mapboxImage: mapboxDataUrl, | ||
| googleImage: googleDataUrl | ||
| googleImage: googleDataUrl, | ||
| elevationPoints: (analysisResult as any).elevationPoints, | ||
| elevationStatistics: (analysisResult as any).elevationStatistics | ||
| }), | ||
| type: 'resolution_search_result' | ||
| }, | ||
|
|
@@ -745,6 +778,8 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { | |
| const image = analysisResult.image as string; | ||
| const mapboxImage = analysisResult.mapboxImage as string; | ||
| const googleImage = analysisResult.googleImage as string; | ||
| const elevationPoints = analysisResult.elevationPoints; | ||
| const elevationStatistics = analysisResult.elevationStatistics; | ||
|
|
||
| return { | ||
| id, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,92 @@ | ||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||
| import { generateCoordinateGrid, decodeMapboxTerrainRGB, getElevationStats } from '@/lib/utils/elevation'; | ||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Leaving this mismatch increases confusion and makes it harder to reason about correctness. SuggestionEither remove If keeping Tilequery/contours, tighten naming/comments so the API clearly returns contour-based elevations. Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||||||||||||||||||||||||||||||||
| export const runtime = 'edge'; | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛠️ Proposed fix-export const runtime = 'edge';
+// Use default Node.js runtime for long-running Mapbox fan-out requests🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| export async function GET(req: NextRequest) { | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| const { searchParams } = new URL(req.url); | ||||||||||||||||||||||||||||||||
| const boundsParam = searchParams.get('bounds'); | ||||||||||||||||||||||||||||||||
| const gridSizeParam = searchParams.get('gridSize'); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if (!boundsParam) { | ||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||
| { error: 'Missing required parameter: bounds' }, | ||||||||||||||||||||||||||||||||
| { status: 400 } | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const bounds = JSON.parse(boundsParam) as [number, number, number, number]; // [west, south, east, north] | ||||||||||||||||||||||||||||||||
| const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20; | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If Proposed fix+ let bounds: [number, number, number, number];
+ try {
+ bounds = JSON.parse(boundsParam);
+ } catch {
+ return NextResponse.json({ error: 'Invalid bounds format' }, { status: 400 });
+ }
+ if (!Array.isArray(bounds) || bounds.length !== 4 || bounds.some(v => typeof v !== 'number' || !isFinite(v))) {
+ return NextResponse.json({ error: 'bounds must be an array of 4 finite numbers [west, south, east, north]' }, { status: 400 });
+ }
- const bounds = JSON.parse(boundsParam) as [number, number, number, number];
- const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20;
+ const gridSize = gridSizeParam ? parseInt(gridSizeParam, 10) : 20;
+ if (!Number.isFinite(gridSize) || gridSize < 2 || gridSize > 50) {
+ return NextResponse.json({ error: 'gridSize must be an integer between 2 and 50' }, { status: 400 });
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Separately, SuggestionTreat bad inputs as 400s and clamp let bounds: [number, number, number, number];
try {
const parsed = JSON.parse(boundsParam);
if (!Array.isArray(parsed) || parsed.length !== 4 || parsed.some(n => typeof n !== 'number')) {
return NextResponse.json({ error: 'Invalid bounds' }, { status: 400 });
}
bounds = parsed as [number, number, number, number];
} catch {
return NextResponse.json({ error: 'Invalid bounds JSON' }, { status: 400 });
}
const requestedGridSize = gridSizeParam ? Number.parseInt(gridSizeParam, 10) : 20;
const gridSize = Number.isFinite(requestedGridSize)
? Math.min(50, Math.max(5, requestedGridSize))
: 20;Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||||||||||||||||||||||||||||||||
| // Generate grid points | ||||||||||||||||||||||||||||||||
| // generateCoordinateGrid expects bounds: [west, south, east, north] | ||||||||||||||||||||||||||||||||
| // But check the implementation I just wrote: | ||||||||||||||||||||||||||||||||
| // export function generateCoordinateGrid(bounds: [number, number, number, number], gridSize: number = 20) | ||||||||||||||||||||||||||||||||
| // inside: const [west, south, east, north] = bounds; | ||||||||||||||||||||||||||||||||
| // Implementation seems consistent. | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const points = generateCoordinateGrid(bounds, gridSize); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Fetch elevation data | ||||||||||||||||||||||||||||||||
| const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; | ||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||
| if (!mapboxToken) { | ||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||
| { error: 'Mapbox access token not configured' }, | ||||||||||||||||||||||||||||||||
| { status: 500 } | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Mapbox Tilequery API allows multiple coordinates? No, it's one by one or comma separated for features, but for terrain-rgb tilequery usually returns value at point. | ||||||||||||||||||||||||||||||||
| // The user snippet did fetch per point. I will do parallel fetch with limit. | ||||||||||||||||||||||||||||||||
| // Since runtime is edge, we can do parallel fetches. | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Batching strategy: Mapbox Tilequery API supports multiple queries? | ||||||||||||||||||||||||||||||||
| // "Retrieve features from vector tiles based on a longitude and latitude." | ||||||||||||||||||||||||||||||||
| // It doesn't support batch points in one request. We must make N requests. | ||||||||||||||||||||||||||||||||
| // N = 20*20 = 400 requests. This might be too many for browser fetch limit or edge limit. | ||||||||||||||||||||||||||||||||
| // But let's try. Maybe reduce grid size default if needed. 400 is a lot. | ||||||||||||||||||||||||||||||||
| // User snippet uses 20x20. | ||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| // Limit concurrency to avoid hitting rate limits or fetch errors | ||||||||||||||||||||||||||||||||
| const batchSize = 10; | ||||||||||||||||||||||||||||||||
| const results = []; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| for (let i = 0; i < points.length; i += batchSize) { | ||||||||||||||||||||||||||||||||
| const batch = points.slice(i, i + batchSize); | ||||||||||||||||||||||||||||||||
| const batchPromises = batch.map(async (point) => { | ||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||
| const url = `https://api.mapbox.com/v4/mapbox.mapbox-terrain-v2/tilequery/${point.lng},${point.lat}.json?layers=contour&limit=1&access_token=${mapboxToken}`; | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const response = await fetch(url); | ||||||||||||||||||||||||||||||||
| if (!response.ok) return { ...point, elevation: 0 }; | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Failed/rate-limited requests silently default to Line 62 defaults non-OK responses to Either mark failed points distinctly (e.g., Proposed fix- if (!response.ok) return { ...point, elevation: 0 };
+ if (!response.ok) return { ...point, elevation: null };
// ...
} catch (e) {
console.error(e);
- return { ...point, elevation: 0 };
+ return { ...point, elevation: null };
}
// ...
- const validPoints = results.filter(p => p.elevation !== undefined);
+ const validPoints = results.filter(p => p.elevation !== null && p.elevation !== undefined);Also applies to: 77-77 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const data = await response.json(); | ||||||||||||||||||||||||||||||||
| const elevation = data.features && data.features.length > 0 ? data.features[0].properties?.ele : 0; | ||||||||||||||||||||||||||||||||
| return { ...point, elevation }; | ||||||||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||||||||
| console.error(e); | ||||||||||||||||||||||||||||||||
| return { ...point, elevation: 0 }; | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const batchResults = await Promise.all(batchPromises); | ||||||||||||||||||||||||||||||||
| results.push(...batchResults); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const validPoints = results.filter(p => p.elevation !== undefined); | ||||||||||||||||||||||||||||||||
| const statistics = getElevationStats(validPoints); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This route makes up to You need either a different data source (e.g., raster SuggestionMinimum viable mitigation:
Example adjustment: if (!response.ok) return { ...point, elevation: null };
// ...
const elevation = /* extracted */;
return { ...point, elevation: typeof elevation === 'number' ? elevation : null };
const validPoints = results.filter(p => typeof p.elevation === 'number');Longer-term: consider sampling Mapbox Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||||||||||||||||||||||||||||||||
| return NextResponse.json({ | ||||||||||||||||||||||||||||||||
| points: validPoints, | ||||||||||||||||||||||||||||||||
| statistics | ||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||
| console.error('Error fetching elevation data:', error); | ||||||||||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||||||||||
| { error: 'Failed to fetch elevation data', details: error instanceof Error ? error.message : 'Unknown error' }, | ||||||||||||||||||||||||||||||||
| { status: 500 } | ||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,215 @@ | ||||||||||||||||||||||||||||||||||||||||||
| 'use client' | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useRef } from 'react' | ||||||||||||||||||||||||||||||||||||||||||
| import mapboxgl from 'mapbox-gl' | ||||||||||||||||||||||||||||||||||||||||||
| import { useMap } from './map-context' | ||||||||||||||||||||||||||||||||||||||||||
| import { getElevationStats } from '@/lib/utils/elevation' | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| interface ElevationPoint { | ||||||||||||||||||||||||||||||||||||||||||
| lat: number; | ||||||||||||||||||||||||||||||||||||||||||
| lng: number; | ||||||||||||||||||||||||||||||||||||||||||
| elevation: number; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Duplicate The same interface is already exported from Proposed fix-import { getElevationStats } from '@/lib/utils/elevation'
-
-interface ElevationPoint {
- lat: number;
- lng: number;
- elevation: number;
-}
+import { getElevationStats, type ElevationPoint } from '@/lib/utils/elevation'📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| interface ElevationHeatmapLayerProps { | ||||||||||||||||||||||||||||||||||||||||||
| id?: string; | ||||||||||||||||||||||||||||||||||||||||||
| points: ElevationPoint[]; | ||||||||||||||||||||||||||||||||||||||||||
| statistics?: { | ||||||||||||||||||||||||||||||||||||||||||
| min: number; | ||||||||||||||||||||||||||||||||||||||||||
| max: number; | ||||||||||||||||||||||||||||||||||||||||||
| average: number; | ||||||||||||||||||||||||||||||||||||||||||
| count: number; | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export function ElevationHeatmapLayer({ id = 'elevation', points, statistics }: ElevationHeatmapLayerProps) { | ||||||||||||||||||||||||||||||||||||||||||
| const { map } = useMap() | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+25
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Effect dependency on
Also applies to: 212-212 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| const sourceId = `elevation-heatmap-source-${id}` | ||||||||||||||||||||||||||||||||||||||||||
| const heatmapLayerId = `elevation-heatmap-layer-${id}` | ||||||||||||||||||||||||||||||||||||||||||
| const pointsLayerId = `elevation-points-layer-${id}` | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Keep track of layers added to remove them cleanly | ||||||||||||||||||||||||||||||||||||||||||
| const layersAdded = useRef(false); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!map || !points || points.length === 0) return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Calculate stats if not provided | ||||||||||||||||||||||||||||||||||||||||||
| const stats = statistics || getElevationStats(points); | ||||||||||||||||||||||||||||||||||||||||||
| const { min, max } = stats; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Normalize elevation for heat map intensity (0-1 scale) | ||||||||||||||||||||||||||||||||||||||||||
| const intensity = (val: number) => (max === min ? 0.5 : (val - min) / (max - min)); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Convert points to GeoJSON | ||||||||||||||||||||||||||||||||||||||||||
| const geojson: GeoJSON.FeatureCollection = { | ||||||||||||||||||||||||||||||||||||||||||
| type: 'FeatureCollection', | ||||||||||||||||||||||||||||||||||||||||||
| features: points.map(point => ({ | ||||||||||||||||||||||||||||||||||||||||||
| type: 'Feature', | ||||||||||||||||||||||||||||||||||||||||||
| geometry: { | ||||||||||||||||||||||||||||||||||||||||||
| type: 'Point', | ||||||||||||||||||||||||||||||||||||||||||
| coordinates: [point.lng, point.lat] | ||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||
| properties: { | ||||||||||||||||||||||||||||||||||||||||||
| elevation: point.elevation, | ||||||||||||||||||||||||||||||||||||||||||
| intensity: intensity(point.elevation) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| })) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const addLayers = () => { | ||||||||||||||||||||||||||||||||||||||||||
| if (map.getSource(sourceId)) { | ||||||||||||||||||||||||||||||||||||||||||
| // Update data if source exists | ||||||||||||||||||||||||||||||||||||||||||
| (map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData(geojson); | ||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||
| // Add the data source | ||||||||||||||||||||||||||||||||||||||||||
| map.addSource(sourceId, { | ||||||||||||||||||||||||||||||||||||||||||
| type: 'geojson', | ||||||||||||||||||||||||||||||||||||||||||
| data: geojson | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Add heatmap layer if not exists | ||||||||||||||||||||||||||||||||||||||||||
| if (!map.getLayer(heatmapLayerId)) { | ||||||||||||||||||||||||||||||||||||||||||
| map.addLayer({ | ||||||||||||||||||||||||||||||||||||||||||
| id: heatmapLayerId, | ||||||||||||||||||||||||||||||||||||||||||
| type: 'heatmap', | ||||||||||||||||||||||||||||||||||||||||||
| source: sourceId, | ||||||||||||||||||||||||||||||||||||||||||
| maxzoom: 15, | ||||||||||||||||||||||||||||||||||||||||||
| paint: { | ||||||||||||||||||||||||||||||||||||||||||
| // Increase weight based on elevation intensity | ||||||||||||||||||||||||||||||||||||||||||
| 'heatmap-weight': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['get', 'intensity'], | ||||||||||||||||||||||||||||||||||||||||||
| 0, 0, | ||||||||||||||||||||||||||||||||||||||||||
| 1, 1 | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| // Increase intensity as zoom increases | ||||||||||||||||||||||||||||||||||||||||||
| 'heatmap-intensity': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['zoom'], | ||||||||||||||||||||||||||||||||||||||||||
| 0, 1, | ||||||||||||||||||||||||||||||||||||||||||
| 15, 3 | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| // Color ramp for the heatmap (elevation-based proxy) | ||||||||||||||||||||||||||||||||||||||||||
| // Blue (low) -> Green (mid) -> Yellow (high) -> Red (very high) | ||||||||||||||||||||||||||||||||||||||||||
| 'heatmap-color': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['heatmap-density'], | ||||||||||||||||||||||||||||||||||||||||||
| 0, 'rgba(33,102,172,0)', | ||||||||||||||||||||||||||||||||||||||||||
| 0.2, 'rgb(103,169,207)', | ||||||||||||||||||||||||||||||||||||||||||
| 0.4, 'rgb(209,229,240)', | ||||||||||||||||||||||||||||||||||||||||||
| 0.6, 'rgb(253,219,199)', | ||||||||||||||||||||||||||||||||||||||||||
| 0.8, 'rgb(239,138,98)', | ||||||||||||||||||||||||||||||||||||||||||
| 1, 'rgb(178,24,43)' | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| // Adjust heatmap radius by zoom level | ||||||||||||||||||||||||||||||||||||||||||
| 'heatmap-radius': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['zoom'], | ||||||||||||||||||||||||||||||||||||||||||
| 0, 2, | ||||||||||||||||||||||||||||||||||||||||||
| 15, 20 | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| // Opacity | ||||||||||||||||||||||||||||||||||||||||||
| 'heatmap-opacity': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['zoom'], | ||||||||||||||||||||||||||||||||||||||||||
| 7, 0.7, | ||||||||||||||||||||||||||||||||||||||||||
| 15, 0.5 | ||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| layersAdded.current = true; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Add circle layer for high zoom levels if not exists | ||||||||||||||||||||||||||||||||||||||||||
| if (!map.getLayer(pointsLayerId)) { | ||||||||||||||||||||||||||||||||||||||||||
| map.addLayer({ | ||||||||||||||||||||||||||||||||||||||||||
| id: pointsLayerId, | ||||||||||||||||||||||||||||||||||||||||||
| type: 'circle', | ||||||||||||||||||||||||||||||||||||||||||
| source: sourceId, | ||||||||||||||||||||||||||||||||||||||||||
| minzoom: 14, | ||||||||||||||||||||||||||||||||||||||||||
| paint: { | ||||||||||||||||||||||||||||||||||||||||||
| 'circle-radius': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['zoom'], | ||||||||||||||||||||||||||||||||||||||||||
| 14, 3, | ||||||||||||||||||||||||||||||||||||||||||
| 22, 8 | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| 'circle-color': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['get', 'intensity'], | ||||||||||||||||||||||||||||||||||||||||||
| 0, '#2166ac', | ||||||||||||||||||||||||||||||||||||||||||
| 0.25, '#67a9cf', | ||||||||||||||||||||||||||||||||||||||||||
| 0.5, '#f7f7f7', | ||||||||||||||||||||||||||||||||||||||||||
| 0.75, '#ef8a62', | ||||||||||||||||||||||||||||||||||||||||||
| 1, '#b2182b' | ||||||||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||||||||
| 'circle-stroke-color': 'white', | ||||||||||||||||||||||||||||||||||||||||||
| 'circle-stroke-width': 1, | ||||||||||||||||||||||||||||||||||||||||||
| 'circle-opacity': [ | ||||||||||||||||||||||||||||||||||||||||||
| 'interpolate', | ||||||||||||||||||||||||||||||||||||||||||
| ['linear'], | ||||||||||||||||||||||||||||||||||||||||||
| ['zoom'], | ||||||||||||||||||||||||||||||||||||||||||
| 14, 0, | ||||||||||||||||||||||||||||||||||||||||||
| 15, 0.8 | ||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| layersAdded.current = true; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Popup logic | ||||||||||||||||||||||||||||||||||||||||||
| map.on('click', pointsLayerId, (e) => { | ||||||||||||||||||||||||||||||||||||||||||
| if (!e.features || e.features.length === 0) return | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const feature = e.features[0] | ||||||||||||||||||||||||||||||||||||||||||
| const elevation = feature.properties?.elevation | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (elevation !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||
| new mapboxgl.Popup() | ||||||||||||||||||||||||||||||||||||||||||
| .setLngLat(e.lngLat) | ||||||||||||||||||||||||||||||||||||||||||
| .setHTML(`<strong>Elevation:</strong> ${elevation.toFixed(1)}m`) | ||||||||||||||||||||||||||||||||||||||||||
| .addTo(map) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+173
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The popup HTML is built via Prefer SuggestionUse new mapboxgl.Popup()
.setLngLat(e.lngLat)
.setText(`Elevation: ${Number(elevation).toFixed(1)}m`)
.addTo(map);Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Cursor logic | ||||||||||||||||||||||||||||||||||||||||||
| map.on('mouseenter', pointsLayerId, () => { | ||||||||||||||||||||||||||||||||||||||||||
| map.getCanvas().style.cursor = 'pointer' | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| map.on('mouseleave', pointsLayerId, () => { | ||||||||||||||||||||||||||||||||||||||||||
| map.getCanvas().style.cursor = '' | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+169
to
+191
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Event listeners are never removed — memory/handler leak on every effect re-run.
Store the handler references and call Proposed fix sketch+ const handlersRef = useRef<{
+ click?: (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => void;
+ mouseenter?: () => void;
+ mouseleave?: () => void;
+ }>({});
// ... inside addLayers, after adding the points layer:
- map.on('click', pointsLayerId, (e) => { ... })
- map.on('mouseenter', pointsLayerId, () => { ... })
- map.on('mouseleave', pointsLayerId, () => { ... })
+ const onClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
+ if (!e.features || e.features.length === 0) return
+ const feature = e.features[0]
+ const elevation = feature.properties?.elevation
+ if (elevation !== undefined) {
+ new mapboxgl.Popup()
+ .setLngLat(e.lngLat)
+ .setHTML(`<strong>Elevation:</strong> ${elevation.toFixed(1)}m`)
+ .addTo(map)
+ }
+ }
+ const onMouseEnter = () => { map.getCanvas().style.cursor = 'pointer' }
+ const onMouseLeave = () => { map.getCanvas().style.cursor = '' }
+ handlersRef.current = { click: onClick, mouseenter: onMouseEnter, mouseleave: onMouseLeave }
+ map.on('click', pointsLayerId, onClick)
+ map.on('mouseenter', pointsLayerId, onMouseEnter)
+ map.on('mouseleave', pointsLayerId, onMouseLeave)
// ... in cleanup:
+ if (handlersRef.current.click) map.off('click', pointsLayerId, handlersRef.current.click)
+ if (handlersRef.current.mouseenter) map.off('mouseenter', pointsLayerId, handlersRef.current.mouseenter)
+ if (handlersRef.current.mouseleave) map.off('mouseleave', pointsLayerId, handlersRef.current.mouseleave)🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (map.isStyleLoaded()) { | ||||||||||||||||||||||||||||||||||||||||||
| addLayers() | ||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||
| map.on('load', addLayers) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+194
to
+198
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the map style hasn't loaded when the effect runs, Proposed fix if (map.isStyleLoaded()) {
addLayers()
} else {
map.on('load', addLayers)
}
return () => {
+ map.off('load', addLayers)
if (map.isStyleLoaded()) {
if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId)
if (map.getLayer(heatmapLayerId)) map.removeLayer(heatmapLayerId)
if (map.getSource(sourceId)) map.removeSource(sourceId)
}
layersAdded.current = false;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Cleanup handled by return function of useEffect | ||||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||||
| // We don't remove source immediately to avoid flickering if component re-renders quickly | ||||||||||||||||||||||||||||||||||||||||||
| // But for clean unmount we should. | ||||||||||||||||||||||||||||||||||||||||||
| // However, usually we want layers to persist until explicitly removed or component unmounted. | ||||||||||||||||||||||||||||||||||||||||||
| if (map.isStyleLoaded()) { | ||||||||||||||||||||||||||||||||||||||||||
| if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId) | ||||||||||||||||||||||||||||||||||||||||||
| if (map.getLayer(heatmapLayerId)) map.removeLayer(heatmapLayerId) | ||||||||||||||||||||||||||||||||||||||||||
| if (map.getSource(sourceId)) map.removeSource(sourceId) | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+168
to
+209
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Event handlers are attached with Also, when SuggestionStore handler functions and unregister them in cleanup, and also unregister the const onClick = (e: mapboxgl.MapLayerMouseEvent) => { /* ... */ };
const onEnter = () => { map.getCanvas().style.cursor = 'pointer'; };
const onLeave = () => { map.getCanvas().style.cursor = ''; };
const onLoad = () => addLayers();
if (map.isStyleLoaded()) addLayers();
else map.on('load', onLoad);
map.on('click', pointsLayerId, onClick);
map.on('mouseenter', pointsLayerId, onEnter);
map.on('mouseleave', pointsLayerId, onLeave);
return () => {
map.off('load', onLoad);
map.off('click', pointsLayerId, onClick);
map.off('mouseenter', pointsLayerId, onEnter);
map.off('mouseleave', pointsLayerId, onLeave);
// then remove layers/sources
};Reply with "@CharlieHelps yes please" if you’d like me to add a commit with this suggestion. |
||||||||||||||||||||||||||||||||||||||||||
| layersAdded.current = false; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| }, [map, id, points, statistics]) // Re-run if points change | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,7 +28,12 @@ const resolutionSearchSchema = z.object({ | |||||||||||||||||||||
| cogInfo: z.object({ | ||||||||||||||||||||||
| applicable: z.boolean(), | ||||||||||||||||||||||
| description: z.string().optional() | ||||||||||||||||||||||
| }).optional().describe('Information about whether Cloud Optimized GeoTIFF (COG) data is applicable or available for this area.') | ||||||||||||||||||||||
| }).optional().describe('Information about whether Cloud Optimized GeoTIFF (COG) data is applicable or available for this area.'), | ||||||||||||||||||||||
| elevationData: z.object({ | ||||||||||||||||||||||
| requested: z.boolean().describe('Whether elevation heat map was requested or appropriate for this analysis'), | ||||||||||||||||||||||
| bounds: z.array(z.number()).optional().describe('Bounding box for elevation query [west, south, east, north]'), | ||||||||||||||||||||||
| gridSize: z.number().optional().describe('Grid resolution for elevation sampling'), | ||||||||||||||||||||||
| }).optional().describe('Elevation heat map configuration for the analyzed area'), | ||||||||||||||||||||||
|
Comment on lines
+32
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Proposed fix: constrain the schema elevationData: z.object({
requested: z.boolean().describe('Whether elevation heat map was requested or appropriate for this analysis'),
- bounds: z.array(z.number()).optional().describe('Bounding box for elevation query [west, south, east, north]'),
- gridSize: z.number().optional().describe('Grid resolution for elevation sampling'),
+ bounds: z.array(z.number()).length(4).optional().describe('Bounding box for elevation query [west, south, east, north]'),
+ gridSize: z.number().int().min(2).max(50).optional().describe('Grid resolution for elevation sampling (2-50)'),
}).optional().describe('Elevation heat map configuration for the analyzed area'),📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export interface DrawnFeature { | ||||||||||||||||||||||
|
|
@@ -66,7 +71,8 @@ Your analysis should be comprehensive and include the following components: | |||||||||||||||||||||
| 2. **Points of Interest (POI):** Detect and name any significant landmarks, infrastructure (e.g., bridges, major roads), or notable buildings. | ||||||||||||||||||||||
| 3. **Coordinate Extraction:** If possible, confirm or refine the geocoordinates (latitude/longitude) of the center of the image. | ||||||||||||||||||||||
| 4. **COG Applicability:** Determine if this location would benefit from Cloud Optimized GeoTIFF (COG) analysis for high-precision temporal or spectral data. | ||||||||||||||||||||||
| 5. **Structured Output:** Return your findings in a structured JSON format. The output must include a 'summary' (a detailed text description of your analysis) and a 'geoJson' object. | ||||||||||||||||||||||
| 5. **Elevation Heat Map:** If the user has drawn polygons or if terrain analysis would be valuable, indicate that elevation data should be fetched by setting elevationData.requested to true and providing appropriate bounds. | ||||||||||||||||||||||
| 6. **Structured Output:** Return your findings in a structured JSON format. The output must include a 'summary' (a detailed text description of your analysis) and a 'geoJson' object. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Your analysis should be based solely on the visual information in the image and your general knowledge. Do not attempt to access external websites or perform web searches. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg -n "gridSize" --type=ts -C4Repository: QueueLab/QCX
Length of output: 4728
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 38
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 5199
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 226
🏁 Script executed:
Repository: QueueLab/QCX
Length of output: 226
🏁 Script executed:
cat -n app/api/elevation/route.ts | head -80Repository: QueueLab/QCX
Length of output: 3936
Add gridSize ceiling and URL-encode bounds parameter to prevent runaway Mapbox API costs.
The LLM-generated
gridSizeinanalysisResult.elevationData.gridSizeis forwarded unchecked across three files with no validation layer. Since the elevation API generates gridSize² coordinate points and makes one Mapbox Tilequery API call per point, an uncapped gridSize creates quadratic cost: gridSize=100 → 10,000 calls; gridSize=500 → 250,000 calls.Additionally,
JSON.stringify(bounds)is embedded in the URL withoutencodeURIComponent, allowing unencoded characters like{,",:to be sent as query parameters.🛡️ Proposed fix — cap gridSize and encode the query parameter
🤖 Prompt for AI Agents