-
-
Notifications
You must be signed in to change notification settings - Fork 6
Re-implementation of PR #533 (Full Features) #542
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 all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import * as turf from '@turf/turf'; | ||
|
|
||
| /** | ||
| * GET /api/elevation | ||
| * | ||
| * Fetches elevation data for a grid of points within the given bounds. | ||
| * Uses Mapbox Terrain vector tileset to get elevation values. | ||
| */ | ||
| export async function GET(req: NextRequest) { | ||
| try { | ||
| const { searchParams } = new URL(req.url); | ||
| const boundsParam = searchParams.get('bounds'); | ||
| const gridSizeParam = searchParams.get('gridSize'); | ||
| const geometryParam = searchParams.get('geometry'); | ||
|
|
||
| if (!boundsParam) { | ||
| return NextResponse.json( | ||
| { error: 'Missing required parameter: bounds' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| let bounds; | ||
| try { | ||
| bounds = JSON.parse(boundsParam); | ||
| } catch (e) { | ||
| return NextResponse.json({ error: 'Invalid bounds parameter' }, { status: 400 }); | ||
| } | ||
|
|
||
| const gridSize = gridSizeParam ? parseInt(gridSizeParam) : 20; | ||
| const geometry = geometryParam ? JSON.parse(geometryParam) : null; | ||
|
|
||
| const [west, south, east, north] = bounds; | ||
|
|
||
| const points: Array<{ lng: number; lat: number; elevation: number | null }> = []; | ||
| const lonStep = (east - west) / gridSize; | ||
| const latStep = (north - south) / gridSize; | ||
|
|
||
| const polygon = geometry ? turf.polygon(geometry.coordinates) : null; | ||
|
|
||
| for (let i = 0; i <= gridSize; i++) { | ||
| for (let j = 0; j <= gridSize; j++) { | ||
| const lng = west + (lonStep * i); | ||
| const lat = south + (latStep * j); | ||
|
|
||
| if (polygon) { | ||
| const point = turf.point([lng, lat]); | ||
| if (!turf.booleanPointInPolygon(point, polygon)) { | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| points.push({ lng, lat, elevation: null }); | ||
| } | ||
| } | ||
|
|
||
| const token = process.env.MAPBOX_ACCESS_TOKEN || process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; | ||
|
|
||
| // Fetch elevation data using Mapbox Terrain vector tiles (v2) | ||
| // This tileset contains contour lines with 'ele' property | ||
| const elevationPromises = points.map(async (point) => { | ||
| try { | ||
| const response = await fetch( | ||
| `https://api.mapbox.com/v4/mapbox.mapbox-terrain-v2/tilequery/${point.lng},${point.lat}.json?access_token=${token}` | ||
| ); | ||
|
Comment on lines
+58
to
+66
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 access token is read from env, but if it’s missing/empty the code still issues requests like Additionally, you’re interpolating SuggestionAdd an early guard:
Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this guard + safer URL construction. |
||
|
|
||
| if (response.ok) { | ||
| const data = await response.json(); | ||
| if (data.features && data.features.length > 0) { | ||
| // Find the feature with the highest elevation in this point's vicinity | ||
| // or just take the first one if it has 'ele' | ||
| const elevations = data.features | ||
| .map((f: any) => f.properties.ele) | ||
| .filter((e: any) => e !== undefined); | ||
|
|
||
| if (elevations.length > 0) { | ||
| point.elevation = Math.max(...elevations); | ||
| } | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error(`Error fetching elevation for ${point.lng},${point.lat}:`, error); | ||
| } | ||
| return point; | ||
| }); | ||
|
|
||
| const elevationData = await Promise.all(elevationPromises); | ||
| const validPoints = elevationData.filter(p => p.elevation !== null); | ||
|
|
||
| if (validPoints.length === 0) { | ||
| return NextResponse.json({ | ||
| success: true, | ||
| points: [], | ||
| statistics: { min: 0, max: 0, average: 0, count: 0 }, | ||
| bounds, | ||
| gridSize, | ||
| }); | ||
| } | ||
|
|
||
| const elevations = validPoints.map(p => p.elevation as number); | ||
| const minElevation = Math.min(...elevations); | ||
| const maxElevation = Math.max(...elevations); | ||
| const avgElevation = elevations.reduce((sum, e) => sum + e, 0) / elevations.length; | ||
|
|
||
| return NextResponse.json({ | ||
| success: true, | ||
| points: validPoints, | ||
| statistics: { | ||
| min: minElevation, | ||
| max: maxElevation, | ||
| average: Math.round(avgElevation * 10) / 10, | ||
| count: validPoints.length, | ||
| }, | ||
| bounds, | ||
| gridSize, | ||
| }); | ||
|
|
||
|
Comment on lines
+36
to
+118
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.
Also, SuggestionConsider switching to a raster-based approach (Mapbox Terrain-RGB tiles) or otherwise drastically reducing external calls:
Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing caps + concurrency limiting + basic caching hooks. |
||
| } 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 } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| try { | ||
| const body = await req.json(); | ||
| const { features, gridSize = 20 } = body; | ||
|
|
||
| if (!features || !Array.isArray(features)) { | ||
| return NextResponse.json( | ||
| { error: 'Missing or invalid features array' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const results = await Promise.all( | ||
| features.map(async (feature: any) => { | ||
| if (feature.geometry.type !== 'Polygon') { | ||
| return null; | ||
| } | ||
|
|
||
| const bbox = turf.bbox(feature); | ||
| const params = new URLSearchParams({ | ||
| bounds: JSON.stringify(bbox), | ||
| gridSize: gridSize.toString(), | ||
| geometry: JSON.stringify(feature.geometry), | ||
| }); | ||
|
|
||
| const url = new URL(`/api/elevation?${params}`, req.url); | ||
| const response = await GET(new NextRequest(url)); | ||
| return await response.json(); | ||
| }) | ||
| ); | ||
|
Comment on lines
+131
to
+160
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. In
Prefer extracting the elevation computation into a shared function and calling it from both handlers. SuggestionRefactor to a shared internal function, e.g. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this refactor. |
||
|
|
||
| return NextResponse.json({ | ||
| success: true, | ||
| results: results.filter(r => r !== null), | ||
| }); | ||
|
|
||
| } catch (error) { | ||
| console.error('Error in batch elevation query:', error); | ||
| return NextResponse.json( | ||
| { | ||
| error: 'Failed to process batch elevation query', | ||
| details: error instanceof Error ? error.message : 'Unknown error' | ||
| }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' | |
| import { Search } from 'lucide-react' | ||
| import { useMap } from './map/map-context' | ||
| import { useActions, useUIState } from 'ai/rsc' | ||
| import { AI } from '@/app/actions' | ||
|
|
||
| import { nanoid } from '@/lib/utils' | ||
| import { UserMessage } from './user-message' | ||
| import { toast } from 'sonner' | ||
|
|
@@ -24,8 +24,8 @@ export function HeaderSearchButton() { | |
| const { mapProvider } = useSettingsStore() | ||
| const { mapData } = useMapData() | ||
| // Cast the actions to our defined interface to avoid build errors | ||
| const actions = useActions<typeof AI>() as unknown as HeaderActions | ||
| const [, setMessages] = useUIState<typeof AI>() | ||
| const actions = useActions<any>() as unknown as HeaderActions | ||
| const [, setMessages] = useUIState<any>() | ||
| const [isAnalyzing, setIsAnalyzing] = useState(false) | ||
|
Comment on lines
24
to
29
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. Several client components replaced Even if Given this PR already exports SuggestionAvoid
Reply with "@CharlieHelps yes please" if you'd like me to add a commit that fixes these to use proper generics (type-only imports to avoid runtime cycles). |
||
| const [desktopPortal, setDesktopPortal] = useState<HTMLElement | null>(null) | ||
| const [mobilePortal, setMobilePortal] = useState<HTMLElement | null>(null) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, useState } from 'react' | ||
| import { useMap } from './map-context' | ||
|
|
||
| interface ElevationPoint { | ||
| lng: number; | ||
| lat: number; | ||
| elevation: number; | ||
| } | ||
|
|
||
| interface ElevationHeatmapLayerProps { | ||
| id: string; | ||
| points: ElevationPoint[]; | ||
| statistics?: { | ||
| min: number; | ||
| max: number; | ||
| average: number; | ||
| count: number; | ||
| }; | ||
| } | ||
|
|
||
| export function ElevationHeatmapLayer({ id, points, statistics }: ElevationHeatmapLayerProps) { | ||
| const { map } = useMap() | ||
| const [mapboxgl, setMapboxgl] = useState<any>(null) | ||
|
|
||
| useEffect(() => { | ||
| import('mapbox-gl').then(mod => { | ||
| setMapboxgl(mod.default) | ||
| }) | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (!map || !points || points.length === 0 || !mapboxgl) return | ||
|
|
||
| const sourceId = `elevation-heatmap-source-${id}` | ||
| const heatmapLayerId = `elevation-heatmap-layer-${id}` | ||
| const pointsLayerId = `elevation-points-layer-${id}` | ||
|
|
||
| const geojson: any = { | ||
| type: 'FeatureCollection', | ||
| features: points.map(point => ({ | ||
| type: 'Feature', | ||
| geometry: { | ||
| type: 'Point', | ||
| coordinates: [point.lng, point.lat] | ||
| }, | ||
| properties: { | ||
| elevation: point.elevation, | ||
| intensity: statistics && statistics.max !== statistics.min | ||
| ? (point.elevation - statistics.min) / (statistics.max - statistics.min) | ||
| : 0.5 | ||
| } | ||
| })) | ||
| } | ||
|
|
||
| const onMapLoad = () => { | ||
| if (!map.getSource(sourceId)) { | ||
| map.addSource(sourceId, { | ||
| type: 'geojson', | ||
| data: geojson | ||
| }) | ||
|
|
||
| map.addLayer({ | ||
| id: heatmapLayerId, | ||
| type: 'heatmap', | ||
| source: sourceId, | ||
| paint: { | ||
| 'heatmap-weight': ['interpolate', ['linear'], ['get', 'intensity'], 0, 0, 1, 1], | ||
| 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3], | ||
| '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)' | ||
| ], | ||
| 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 15, 20], | ||
| 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.7, 15, 0.5] | ||
| } | ||
| }) | ||
|
|
||
| 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] | ||
| } | ||
| }) | ||
|
|
||
| const clickHandler = (e: any) => { | ||
| if (!e.features || e.features.length === 0) return | ||
| const elevation = e.features[0].properties?.elevation | ||
| if (elevation !== undefined) { | ||
| new mapboxgl.Popup() | ||
| .setLngLat(e.lngLat) | ||
| .setHTML(`<strong>Elevation:</strong> ${elevation}m`) | ||
| .addTo(map) | ||
| } | ||
| } | ||
|
|
||
| map.on('click', pointsLayerId, clickHandler) | ||
| map.on('mouseenter', pointsLayerId, () => { map.getCanvas().style.cursor = 'pointer' }) | ||
| map.on('mouseleave', pointsLayerId, () => { map.getCanvas().style.cursor = '' }) | ||
| } | ||
| } | ||
|
|
||
| if (map.isStyleLoaded()) { | ||
| onMapLoad() | ||
| } else { | ||
| map.once('load', onMapLoad) | ||
| } | ||
|
|
||
| return () => { | ||
| if (map) { | ||
| if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId) | ||
| if (map.getLayer(heatmapLayerId)) map.removeLayer(heatmapLayerId) | ||
| if (map.getSource(sourceId)) map.removeSource(sourceId) | ||
| } | ||
| } | ||
|
Comment on lines
+57
to
+139
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.
Also, the handler functions are created inside the effect, but never deregistered via SuggestionStore handler references and remove them during cleanup:
Reply with "@CharlieHelps yes please" if you'd like me to add a commit that adds proper event cleanup and updates existing sources on re-render. |
||
| }, [map, id, points, statistics, mapboxgl]) | ||
|
|
||
| return null | ||
| } | ||
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.
geometryisJSON.parse(geometryParam)without a try/catch. A malformedgeometryquery will throw and result in a 500 instead of a controlled 400, unlikeboundswhich is validated.Similarly,
gridSizeisn’t validated forNaN, negative, or huge values (which directly impacts request fan-out).Suggestion
Validate inputs consistently:
geometryparsing in try/catch and return 400 on failure.gridSize: default to 20 whenNaN, clamp to a maximum, and require>= 1.boundsis an array of 4 finite numbers andwest<east,south<north.Reply with "@CharlieHelps yes please" if you'd like me to add a commit with robust validation + clamping.