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",