diff --git a/bun.lock b/bun.lock index 936916d2..783dfa03 100644 --- a/bun.lock +++ b/bun.lock @@ -36,8 +36,11 @@ "@tailwindcss/typography": "^0.5.16", "@tavily/core": "^0.6.4", "@turf/turf": "^7.2.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet-draw": "^1.0.13", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", + "@types/react-leaflet": "^3.0.0", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", @@ -57,6 +60,8 @@ "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", "katex": "^0.16.22", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "lodash": "^4.17.21", "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", @@ -71,6 +76,7 @@ "react-dom": "19.1.2", "react-hook-form": "^7.56.2", "react-icons": "^5.5.0", + "react-leaflet": "^5.0.0", "react-markdown": "^9.1.0", "react-textarea-autosize": "^8.5.9", "react-toastify": "^10.0.6", @@ -555,6 +561,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="], @@ -941,6 +949,10 @@ "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + + "@types/leaflet-draw": ["@types/leaflet-draw@1.0.13", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-YU82kilOaU+wPNbqKCCDfHH3hqepN6XilrBwG/mSeZ/z4ewumaRCOah44s3FMxSu/Aa0SVa3PPJvhIZDUA09mw=="], + "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], "@types/mapbox-gl": ["@types/mapbox-gl@3.4.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg=="], @@ -967,6 +979,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-leaflet": ["@types/react-leaflet@3.0.0", "", { "dependencies": { "react-leaflet": "*" } }, "sha512-p8R9mVKbCDDqOdW+M6GyJJuFn6q+IgDFYavFiOIvaWHuOe5kIHZEtCy1pfM43JIA6JiB3D/aDoby7C51eO+XSg=="], + "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="], "@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="], @@ -1757,6 +1771,10 @@ "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + + "leaflet-draw": ["leaflet-draw@1.0.4", "", {}, "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ=="], + "lerc": ["lerc@3.0.0", "", {}, "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -2127,6 +2145,8 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], + "react-markdown": ["react-markdown@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw=="], "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], diff --git a/components/map/map-provider.tsx b/components/map/map-provider.tsx index 71877661..33f5cb54 100644 --- a/components/map/map-provider.tsx +++ b/components/map/map-provider.tsx @@ -13,6 +13,11 @@ const GoogleMapComponent = dynamic( { ssr: false, loading: () =>
} ) +const OSMMap = dynamic( + () => import('./osm-map').then(mod => mod.OSMMap), + { ssr: false, loading: () =>
} +) + export function MapProvider({ position }: { position?: { latitude: number; longitude: number; } }) { const { mapProvider } = useSettingsStore() @@ -20,6 +25,8 @@ export function MapProvider({ position }: { position?: { latitude: number; longi <> {mapProvider === 'google' ? ( + ) : mapProvider === 'osm' ? ( + ) : ( )} diff --git a/components/map/osm-map.css b/components/map/osm-map.css new file mode 100644 index 00000000..ca1c349e --- /dev/null +++ b/components/map/osm-map.css @@ -0,0 +1,11 @@ +.leaflet-measurement-label { + background: rgba(255, 255, 255, 0.8); + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: bold; + color: #333333; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + pointer-events: none; + white-space: nowrap; +} diff --git a/components/map/osm-map.tsx b/components/map/osm-map.tsx new file mode 100644 index 00000000..558cff8a --- /dev/null +++ b/components/map/osm-map.tsx @@ -0,0 +1,187 @@ +'use client' + +import { useEffect, useRef, useCallback } from 'react' +import { MapContainer, TileLayer, FeatureGroup, useMap, useMapEvents } from 'react-leaflet' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' +import 'leaflet-draw/dist/leaflet.draw.css' +import * as turf from '@turf/turf' +import { useMapData } from './map-data-context' +import { useMapLoading } from '../map-loading-context' +import './osm-map.css' + +// Leaflet's default icon path is not set up correctly in Next.js by default. +// This fix ensures that the marker icons are loaded correctly. +delete (L.Icon.Default.prototype as any)._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png').default, + iconUrl: require('leaflet/dist/images/marker-icon.png').default, + shadowUrl: require('leaflet/dist/images/marker-shadow.png').default, +}) + +// Formats the area or distance for display, consistent with other map components. +const formatMeasurement = (value: number, isArea = true) => { + if (isArea) { + return value >= 1000000 + ? `${(value / 1000000).toFixed(2)} km²` + : `${value.toFixed(2)} m²` + } else { + return value >= 1000 + ? `${(value / 1000).toFixed(2)} km` + : `${value.toFixed(0)} m` + } +} + +const DrawControl = () => { + const map = useMap() + const { setMapData } = useMapData() + const featureGroupRef = useRef(new L.FeatureGroup()) + const labelsRef = useRef<{ [id: number]: L.Marker }>({}) + + const updateMeasurementLabels = useCallback(() => { + const layers = featureGroupRef.current.getLayers() as (L.Polygon | L.Polyline)[] + const currentDrawnFeatures: any[] = [] + + // Clear existing labels + Object.values(labelsRef.current).forEach(marker => marker.remove()) + labelsRef.current = {} + + layers.forEach(layer => { + const id = L.Util.stamp(layer) + const geojson = layer.toGeoJSON() + let measurement = '' + let labelPos: L.LatLngExpression | undefined + + if (geojson.geometry.type === 'Polygon') { + const area = turf.area(geojson) + measurement = formatMeasurement(area, true) + const center = turf.centroid(geojson) + labelPos = [center.geometry.coordinates[1], center.geometry.coordinates[0]] + } else if (geojson.geometry.type === 'LineString') { + const length = turf.length(geojson, { units: 'meters' }) + measurement = formatMeasurement(length, false) + const line = geojson.geometry.coordinates + const midpoint = line[Math.floor(line.length / 2)] + labelPos = [midpoint[1], midpoint[0]] + } + + if (measurement && labelPos) { + const label = L.marker(labelPos, { + icon: L.divIcon({ + className: 'leaflet-measurement-label', + html: `${measurement}`, + }), + }).addTo(map) + labelsRef.current[id] = label + } + + currentDrawnFeatures.push({ + id: id.toString(), + type: geojson.geometry.type, + measurement, + geometry: geojson.geometry, + }); + }) + + setMapData(prev => ({ ...prev, drawnFeatures: currentDrawnFeatures })) + }, [map, setMapData]) + + useEffect(() => { + const featureGroup = featureGroupRef.current + map.addLayer(featureGroup) + + const drawControl = new L.Control.Draw({ + edit: { featureGroup }, + draw: { + polygon: {}, + polyline: {}, + rectangle: false, + circle: false, + marker: false, + circlemarker: false, + }, + }) + map.addControl(drawControl) + + const onDrawCreated = (e: any) => { + const layer = e.layer + featureGroup.addLayer(layer) + updateMeasurementLabels() + } + + const onDrawEdited = () => updateMeasurementLabels() + const onDrawDeleted = () => updateMeasurementLabels() + + map.on(L.Draw.Event.CREATED, onDrawCreated) + map.on(L.Draw.Event.EDITED, onDrawEdited) + map.on(L.Draw.Event.DELETED, onDrawDeleted) + + return () => { + map.off(L.Draw.Event.CREATED, onDrawCreated) + map.off(L.Draw.Event.EDITED, onDrawEdited) + map.off(L.Draw.Event.DELETED, onDrawDeleted) + if (map.hasLayer(featureGroup)) { + map.removeLayer(featureGroup) + } + map.removeControl(drawControl) + } + }, [map, updateMeasurementLabels]) + + return null +} + +const MapEvents = ({ onMoveEnd }: { onMoveEnd: (map: L.Map) => void }) => { + const map = useMapEvents({ + moveend: () => onMoveEnd(map), + zoomend: () => onMoveEnd(map), + }); + return null; +}; + +export function OSMMap() { + const { mapData, setMapData } = useMapData() + const { setIsMapLoaded } = useMapLoading() + + const initialCenter = mapData.cameraState + ? [mapData.cameraState.center.lat, mapData.cameraState.center.lng] as [number, number] + : ([51.505, -0.09] as [number, number]) + const initialZoom = mapData.cameraState ? mapData.cameraState.zoom : 13 + + useEffect(() => { + setIsMapLoaded(true) + return () => setIsMapLoaded(false) + }, [setIsMapLoaded]) + + const handleMapMoveEnd = useCallback((map: L.Map) => { + const center = map.getCenter() + const zoom = map.getZoom() + setMapData(prev => ({ + ...prev, + cameraState: { + ...prev.cameraState, + center: { lat: center.lat, lng: center.lng }, + zoom, + }, + })) + }, [setMapData]) + + return ( + <> + + + + + + + + + ) +} diff --git a/components/settings/components/settings.tsx b/components/settings/components/settings.tsx index 0d201916..22cc104e 100644 --- a/components/settings/components/settings.tsx +++ b/components/settings/components/settings.tsx @@ -213,6 +213,10 @@ export function Settings({ initialTab = "system-prompt" }: SettingsProps) {
+
+ + +
diff --git a/lib/agents/tools/geospatial.tsx b/lib/agents/tools/geospatial.tsx index ccff0d02..1ed4cf62 100644 --- a/lib/agents/tools/geospatial.tsx +++ b/lib/agents/tools/geospatial.tsx @@ -240,7 +240,7 @@ Uses the Mapbox Search Box Text Search API endpoint to power searching for and g const selectedModel = await getSelectedModel(); - if (selectedModel?.includes('gemini') && mapProvider === 'google') { + if (selectedModel?.includes('gemini') && (mapProvider === 'google' || mapProvider === 'osm')) { let feedbackMessage = `Processing geospatial query with Gemini...`; uiFeedbackStream.update(feedbackMessage); diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 4dc9c001..03334a50 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -export type MapProvider = 'mapbox' | 'google' +export type MapProvider = 'mapbox' | 'google' | 'osm' interface SettingsState { mapProvider: MapProvider diff --git a/package.json b/package.json index a263674e..249f375a 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,11 @@ "@tailwindcss/typography": "^0.5.16", "@tavily/core": "^0.6.4", "@turf/turf": "^7.2.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet-draw": "^1.0.13", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", + "@types/react-leaflet": "^3.0.0", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", @@ -69,6 +72,8 @@ "geotiff": "^2.1.4-beta.1", "glassmorphic": "^0.0.3", "katex": "^0.16.22", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "lodash": "^4.17.21", "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", @@ -83,6 +88,7 @@ "react-dom": "19.1.2", "react-hook-form": "^7.56.2", "react-icons": "^5.5.0", + "react-leaflet": "^5.0.0", "react-markdown": "^9.1.0", "react-textarea-autosize": "^8.5.9", "react-toastify": "^10.0.6",