Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
69 changes: 47 additions & 22 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,20 @@ 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'
import RetrieveSection from '@/components/retrieve-section'
import { VideoSearchSection } from '@/components/video-search-section'
import { MapQueryHandler } from '@/components/map/map-query-handler'

// Define the type for related queries
type RelatedQueries = {
items: { query: string }[]
}

async function submit(formData?: FormData, skip?: boolean) {
'use server'

const aiState = getMutableAIState<typeof AI>()
const uiStream = createStreamableUI()
const isGenerating = createStreamableValue(true)
Expand Down Expand Up @@ -65,10 +64,8 @@ async function submit(formData?: FormData, skip?: boolean) {

const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;

const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;

const buffer = await file.arrayBuffer();
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;

Expand All @@ -94,15 +91,14 @@ async function submit(formData?: FormData, skip?: boolean) {
{ id: nanoid(), role: 'user', content, type: 'input' }
]
});
messages.push({ role: 'user', content });

messages.push({ role: 'user', content });
const summaryStream = createStreamableValue<string>('Analyzing map view...');
const groupeId = nanoid();

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
Expand All @@ -123,6 +119,33 @@ async function submit(formData?: FormData, skip?: boolean) {
);
}

let elevationPoints = undefined;
let elevationStatistics = undefined;
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}`

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:

rg -n "gridSize" --type=ts -C4

Repository: QueueLab/QCX

Length of output: 4728


🏁 Script executed:

# Also check for gridSize in JavaScript/JSON files
rg -n "gridSize" --type=js -C4

Repository: QueueLab/QCX

Length of output: 38


🏁 Script executed:

# Search for schema validation and type definitions
rg -n "gridSize" -C5

Repository: QueueLab/QCX

Length of output: 5199


🏁 Script executed:

# Check specifically for the elevation API route
fd -type f -name "*elevation*" | head -20

Repository: QueueLab/QCX

Length of output: 226


🏁 Script executed:

# Look for schema files that might validate gridSize
fd -type f -name "*schema*" | head -20

Repository: QueueLab/QCX

Length of output: 226


🏁 Script executed:

cat -n app/api/elevation/route.ts | head -80

Repository: QueueLab/QCX

Length of output: 3936


Add gridSize ceiling and URL-encode bounds parameter to prevent runaway Mapbox API costs.

The LLM-generated gridSize in analysisResult.elevationData.gridSize is 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 without encodeURIComponent, allowing unencoded characters like {, ", : to be sent as query parameters.

🛡️ Proposed fix — cap gridSize and encode the query parameter
+ const MAX_GRID_SIZE = 50;
+ const safeGridSize = Math.min(Math.max(1, Number(analysisResult.elevationData.gridSize) || 20), MAX_GRID_SIZE);
  const elevationResponse = await fetch(
-   `${baseUrl}/api/elevation?bounds=${JSON.stringify(analysisResult.elevationData.bounds)}&gridSize=${analysisResult.elevationData.gridSize || 20}`
+   `${baseUrl}/api/elevation?bounds=${encodeURIComponent(JSON.stringify(analysisResult.elevationData.bounds))}&gridSize=${safeGridSize}`
  );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` at line 128, The URL builds the elevation call with an
unchecked analysisResult.elevationData.gridSize and an unencoded bounds JSON,
which can cause quadratic Mapbox costs and malformed query strings; clamp/ceil
gridSize at the origin (e.g., replace analysisResult.elevationData.gridSize with
a validated value using Math.floor/Math.min to enforce a MAX_GRID_SIZE and a
sensible minimum) and also validate again in the elevation API handler
(/api/elevation) to be defensive, and URL-encode the bounds parameter by
wrapping JSON.stringify(analysisResult.elevationData.bounds) with
encodeURIComponent before interpolating into the `${baseUrl}/api/elevation?...`
URL.

);
Comment on lines +128 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Building the elevation API URL by embedding raw JSON.stringify(bounds) into the query string is brittle. Characters like [ ] , and spaces can cause malformed URLs unless encoded, and large bounds strings can quickly become unwieldy.

Also, using NEXT_PUBLIC_BASE_URL from server code is a smell (it’s intended for client exposure) and the hard-coded http://localhost:3000 fallback can break in production/serverless where the canonical host is not known at build time.

Prefer calling the route via a relative URL from server code and encode query params explicitly.

Suggestion

Use a relative URL and URLSearchParams to ensure proper encoding and avoid env-coupled base URLs:

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 req.headers server-side instead of NEXT_PUBLIC_*.

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) {
elevationPoints = elevationData.points;
elevationStatistics = elevationData.statistics;
uiStream.append(
<ElevationHeatmapLayer
id={groupeId}
points={elevationData.points}
statistics={elevationData.statistics}
/>
);
}
}
} catch (error) {
console.error('Error fetching elevation data:', error);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });

const sanitizedMessages: CoreMessage[] = messages.map((m: any) => {
Expand All @@ -147,7 +170,9 @@ async function submit(formData?: FormData, skip?: boolean) {
}
return m
});

const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);

uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
Expand All @@ -173,7 +198,9 @@ async function submit(formData?: FormData, skip?: boolean) {
...analysisResult,
image: dataUrl,
mapboxImage: mapboxDataUrl,
googleImage: googleDataUrl
googleImage: googleDataUrl,
elevationPoints,
elevationStatistics
}),
type: 'resolution_search_result'
},
Expand Down Expand Up @@ -231,7 +258,7 @@ async function submit(formData?: FormData, skip?: boolean) {
const definition = userInput.toLowerCase().trim() === 'what is a planet computer?'
? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)`
: `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`;

const content = JSON.stringify(Object.fromEntries(formData!));
const type = 'input';
const groupeId = nanoid();
Expand Down Expand Up @@ -434,6 +461,7 @@ async function submit(formData?: FormData, skip?: boolean) {
let toolOutputs: ToolResultPart[] = []
let errorOccurred = false
const streamText = createStreamableValue<string>()

uiStream.update(<Spinner />)

while (
Expand Down Expand Up @@ -504,9 +532,7 @@ async function submit(formData?: FormData, skip?: boolean) {
<FollowupPanel />
</Section>
)

await new Promise(resolve => setTimeout(resolve, 500))

aiState.done({
...aiState.get(),
messages: [
Expand Down Expand Up @@ -549,9 +575,7 @@ async function submit(formData?: FormData, skip?: boolean) {

async function clearChat() {
'use server'

const aiState = getMutableAIState<typeof AI>()

aiState.done({
chatId: nanoid(),
messages: []
Expand Down Expand Up @@ -589,10 +613,12 @@ export const AI = createAI<AIState, UIState>({
'use server'

const aiState = getAIState() as AIState

if (aiState) {
const uiState = getUIStateFromAIState(aiState)
return uiState
}

return initialUIState
},
onSetAIState: async ({ state }) => {
Expand All @@ -605,8 +631,8 @@ export const AI = createAI<AIState, UIState>({
const { chatId, messages } = state
const createdAt = new Date()
const path = `/search/${chatId}`

let title = 'Untitled Chat'

if (messages.length > 0) {
const firstMessageContent = messages[0].content
if (typeof firstMessageContent === 'string') {
Expand Down Expand Up @@ -655,13 +681,15 @@ export const AI = createAI<AIState, UIState>({
title,
messages: updatedMessages
}

await saveChat(chat, actualUserId)
}
},
})

export const getUIStateFromAIState = (aiState: AIState): UIState => {
const chatId = aiState.chatId
const isSharePage = aiState.isSharePage

return aiState.messages
.map((message, index) => {
const { role, content, id, type, name } = message
Expand All @@ -687,6 +715,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
} catch (e) {
messageContent = content
}

return {
id,
component: (
Expand Down Expand Up @@ -741,24 +770,20 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
}
case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
const mapboxImage = analysisResult.mapboxImage as string;
const googleImage = analysisResult.googleImage as string;

return {
id,
component: (
<>
<Section title="response">
<ResolutionCarousel
mapboxImage={mapboxImage}
googleImage={googleImage}
initialImage={image}
/>
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
</Section>
)
}
Comment on lines 774 to 797

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

ElevationHeatmapLayer (and GeoJsonLayer) absent from the chat-history restoration path.

elevationPoints and elevationStatistics are serialised into the persisted AI state at lines 202–203, but the resolution_search_result restoration branch (lines 771–788) only reconstructs ResolutionCarousel. After a page refresh or when loading chat history, the elevation heatmap data is parsed at line 772 and then silently discarded — the heatmap is never re-rendered.

The same gap applies to geoJson: according to the AI summary, the GeoJsonLayer was previously rendered in this path and has been dropped.

🐛 Proposed fix — render layers from persisted state
 case 'resolution_search_result': {
   const analysisResult = JSON.parse(content as string);
   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;
+  const geoJson = analysisResult.geoJson;

   return {
     id,
     component: (
       <Section title="response">
         <ResolutionCarousel
           mapboxImage={mapboxImage}
           googleImage={googleImage}
           initialImage={image}
         />
+        {geoJson && (
+          <GeoJsonLayer id={id} data={geoJson as FeatureCollection} />
+        )}
+        {elevationPoints && elevationPoints.length > 0 && (
+          <ElevationHeatmapLayer
+            id={id}
+            points={elevationPoints}
+            statistics={elevationStatistics}
+          />
+        )}
       </Section>
     )
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/actions.tsx` around lines 771 - 788, The restoration branch for the
'resolution_search_result' case currently parses analysisResult but only
recreates ResolutionCarousel; update this branch to also reconstruct and include
the ElevationHeatmapLayer (using analysisResult.elevationPoints and
analysisResult.elevationStatistics) and the GeoJsonLayer (using
analysisResult.geoJson) alongside the existing ResolutionCarousel inside the
returned Section so persisted heatmap and geojson layers are re-rendered after
reload; locate the analysisResult variable and add the ElevationHeatmapLayer and
GeoJsonLayer components (or their wrapper components) with props wired to
elevationPoints, elevationStatistics, and geoJson respectively.

}
Expand All @@ -776,7 +801,6 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
) {
const mapUrl = toolOutput.mcp_response?.mapUrl;
const placeName = toolOutput.mcp_response?.location?.place_name;

return {
id,
component: (
Expand All @@ -799,6 +823,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
JSON.stringify(toolOutput)
)
searchResults.done(JSON.stringify(toolOutput))

switch (name) {
case 'search':
return {
Expand Down
92 changes: 92 additions & 0 deletions app/api/elevation/route.ts
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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Comment on lines +1 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

decodeMapboxTerrainRGB is imported but unused in this route. More importantly, this route uses the contour layer’s ele property, not Terrain-RGB decoding. Either remove the unused import, or (preferably) switch to an approach consistent with the intended Terrain-RGB decoding utility.

Leaving this mismatch increases confusion and makes it harder to reason about correctness.

Suggestion

Either remove decodeMapboxTerrainRGB from the imports or implement the Terrain-RGB sampling path and use the decoder.

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';

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

runtime = 'edge' is ill-suited for a route that fans out to 441+ sequential HTTP calls.

fetchElevationData processes up to 441 Mapbox Tilequery calls in sequential batches. Edge functions must begin sending a response within 25 seconds, and Vercel explicitly recommends migrating from edge to Node.js for improved performance and reliability. Fluid Compute (Node.js runtime) supports maximum durations up to 800 seconds on Pro/Enterprise, while Fluid Compute is currently only supported by the Node.js and Python runtimes. This route has no edge-specific requirements (no geo-routing, no sub-millisecond header inspection), so the edge constraint only adds risk.

🛠️ Proposed fix
-export const runtime = 'edge';
+// Use default Node.js runtime for long-running Mapbox fan-out requests
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/elevation/route.ts` at line 4, The route currently exports runtime =
'edge' which is unsuitable because fetchElevationData performs up to 441
sequential Mapbox Tilequery calls and can exceed edge function time limits;
change the runtime to a Node.js server runtime by removing or replacing the
export of runtime = 'edge' so the route runs on the Node.js environment (e.g.,
remove the export or set runtime to a Node-compatible value per your platform),
ensuring fetchElevationData and the route handler execute under the Node.js
runtime to allow longer execution times and improved reliability.


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;

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

JSON.parse on untrusted query param without targeted error handling; no validation on parsed values.

If boundsParam is malformed JSON, the outer catch returns a generic 500 instead of a 400. Additionally, neither bounds nor gridSize are validated after parsing — a gridSize of 0 causes division-by-zero in generateCoordinateGrid, and a very large value generates an enormous number of outbound HTTP requests (e.g., gridSize=100 → 10,201 Mapbox API calls).

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const bounds = JSON.parse(boundsParam) as [number, number, number, number]; // [west, south, east, north]
const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20;
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 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 });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/elevation/route.ts` around lines 19 - 20, Parse boundsParam with a
targeted try/catch and return a 400 for malformed JSON, then validate the parsed
bounds array (ensure it's an array of four finite numbers in
[west,south,east,north] and west<east, south<north) before using it; parse
gridSizeParam into an integer and enforce a sane range (e.g., >0 and a max cap
like 50) or reject with 400 to avoid division-by-zero and massive request
fan-out; apply these checks in the same request handler that reads boundsParam
and gridSizeParam and before calling generateCoordinateGrid so invalid input is
rejected early.


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bounds is parsed via JSON.parse(boundsParam) without validation. If a client sends invalid JSON, you’ll throw into the outer catch and return a 500, but this is a client error and should return 400.

Separately, gridSize is not clamped. Large values can explode request count ((gridSize+1)^2) and generate hundreds/thousands of external calls, which is a DoS vector against your own Edge function and your Mapbox quota.

Suggestion

Treat bad inputs as 400s and clamp gridSize to a sane range:

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;
Comment thread
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.
Comment thread
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 };

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

Failed/rate-limited requests silently default to elevation: 0, corrupting heatmap data.

Line 62 defaults non-OK responses to elevation: 0, and line 77's filter (p.elevation !== undefined) is a no-op since every point always has an elevation value. This means HTTP failures or rate-limit responses produce sea-level elevation values that are indistinguishable from real data, misleading users.

Either mark failed points distinctly (e.g., elevation: null) and filter them out, or propagate the error.

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
Verify each finding against the current code and only fix it if needed.

In `@app/api/elevation/route.ts` at line 62, The code currently sets failed/non-OK
fetches to elevation: 0 which corrupts results; change the non-OK branch that
returns { ...point, elevation: 0 } to return { ...point, elevation: null }
instead, and update the later filter from p.elevation !== undefined to
explicitly exclude null (e.g., p.elevation !== null or p.elevation != null
depending on desired semantics) so rate-limited/failed points are omitted from
the heatmap; ensure any downstream code that assumes a numeric elevation handles
nullable elevations or throws/propagates errors as appropriate (references: the
response variable used in the fetch branch and the filter using p.elevation !==
undefined).


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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const validPoints = results.filter(p => p.elevation !== undefined);
const statistics = getElevationStats(validPoints);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This route makes up to (gridSize+1)^2 Tilequery requests (default 441) per call. Even with batching at 10 concurrency, that’s still extremely heavy for an Edge function and likely to hit Mapbox rate limits / timeouts. Returning elevation: 0 on failures also silently pollutes stats and the heat map.

You need either a different data source (e.g., raster terrain-rgb tiles sampled client/server-side) or a strategy that drastically reduces request count (adaptive sampling, smaller default, early exit, caching). At minimum, distinguish failed samples from real 0m elevation and exclude them from stats/visualization.

Suggestion

Minimum viable mitigation:

  1. Mark failures as null instead of 0 and filter them out of stats.
  2. Lower default gridSize (e.g., 10) and enforce max.
  3. Add caching headers and/or server-side caching (KV/Upstash) keyed by bounds+gridSize.

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 terrain-rgb raster tiles (1 request per tile, many points per tile) instead of Tilequery per point.

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 }
);
}
}
Loading