-
-
Notifications
You must be signed in to change notification settings - Fork 7
Add OpenStreetMap as a Map Provider #427
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
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<L.FeatureGroup>(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: `<span>${measurement}</span>`, | ||
| }), | ||
| }).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]) | ||
|
Comment on lines
+41
to
+87
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 midpoint calculation for SuggestionImprove both correctness and performance:
const lengthKm = turf.length(geojson, { units: 'kilometers' });
const mid = turf.along(geojson, lengthKm / 2, { units: 'kilometers' });
labelPos = [mid.geometry.coordinates[1], mid.geometry.coordinates[0]];
Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
|
|
||
| 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() | ||
| } | ||
|
Comment on lines
+42
to
+110
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.
Since this is core state ( SuggestionIntroduce a small local type for the drawn feature payload and minimally type the draw event(s) you use. Example: type DrawnFeature = {
id: string;
type: 'Polygon' | 'LineString';
measurement: string;
geometry: GeoJSON.Polygon | GeoJSON.LineString;
};
const currentDrawnFeatures: DrawnFeature[] = [];
const onDrawCreated = (e: L.LeafletEvent & { layer: L.Layer }) => {
const layer = e.layer as L.Polygon | L.Polyline;
featureGroup.addLayer(layer);
updateMeasurementLabels();
};Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
|
|
||
| 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]) | ||
|
Comment on lines
+119
to
+128
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. Memory leak: measurement labels not cleaned up on unmount. The cleanup function removes the feature group and draw control, but measurement label markers stored in Proposed fix return () => {
+ // Clean up measurement labels
+ Object.values(labelsRef.current).forEach(marker => marker.remove())
+ labelsRef.current = {}
+
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)
}🤖 Prompt for AI Agents |
||
|
|
||
| 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]) | ||
|
|
||
|
Comment on lines
+150
to
+154
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.
Leaflet exposes map events like SuggestionTie Example using <MapContainer
...
whenReady={() => setIsMapLoaded(true)}
>And keep the cleanup to set it false on unmount. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion. |
||
| 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 ( | ||
| <> | ||
| <MapContainer | ||
| center={initialCenter} | ||
| zoom={initialZoom} | ||
| scrollWheelZoom={true} | ||
| className="h-full w-full" | ||
| > | ||
| <TileLayer | ||
| attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' | ||
| url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | ||
| /> | ||
| <FeatureGroup> | ||
| <DrawControl /> | ||
| </FeatureGroup> | ||
| <MapEvents onMoveEnd={handleMapMoveEnd} /> | ||
|
Comment on lines
+170
to
+183
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. Unnecessary nested
|
||
| </MapContainer> | ||
| </> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
Comment on lines
+51
to
+55
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. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes — react-leaflet v5 includes/bundles its own TypeScript definitions. You still need to install Leaflet's types (e.g. Sources:
🌐 Web query:
💡 Result: Short answer: don't install Why / action items:
If you want, tell me your package.json (react-leaflet, Remove
🤖 Prompt for AI Agents |
||
| "@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", | ||
|
|
||
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.
This file mutates global Leaflet defaults at module scope:
delete (L.Icon.Default.prototype as any)._getIconUrlL.Icon.Default.mergeOptions(...)Because
osm-map.tsxis dynamically imported, this will run each time the chunk is evaluated; in dev/HMR it may execute multiple times. It also usesas anyandrequire(...).default, which is brittle and makes the behavior harder to reason about.This should be guarded to run only once and avoid the
anyescape hatch if possible.Suggestion
Wrap the Leaflet icon patch in a one-time guard and avoid
anyby narrowing with an interface for the private field.Example:
Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.