DebasishDhal99 commited on
Commit
a5e71eb
·
1 Parent(s): ab8ba8e

Add polygon area feature

Browse files

Measure area feature is now available on the sidebar. The actual function that calculates the area is gpt-slop, however tweaking it is much easier, it's not a bottleneck.

frontend/src/components/Map.js CHANGED
@@ -7,11 +7,12 @@ import { MapContainer, TileLayer,
7
  Popup ,
8
  useMap,
9
  Polyline,
10
- Tooltip
 
11
  } from 'react-leaflet';
12
  import L from 'leaflet';
13
  import 'leaflet/dist/leaflet.css';
14
- import generateGeodesicPoints from '../utils/mapUtils';
15
 
16
 
17
  delete L.Icon.Default.prototype._getIconUrl;
@@ -29,7 +30,7 @@ const ClickHandler = ({ onClick }) => {
29
  },
30
  });
31
  return null;
32
- };
33
 
34
  const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8004';
35
  console.log(BACKEND_URL);
@@ -56,13 +57,16 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
56
  const [geoDistance, setGeoDistance] = useState(null);
57
 
58
  const [geoSidebarOpen, setGeoSidebarOpen] = useState(false);
59
- const [geoToolMode, setGeoToolMode] = useState("menu"); // "menu" | "distance"
60
  const [geoUnit, setGeoUnit] = useState('km');
61
 
62
  const [isGeoMarkerDragging, setIsGeoMarkerDragging] = useState(false);
63
 
64
  const distanceCache = useRef({});
65
 
 
 
 
66
  const handleMouseDown = (e) => {
67
  isDragging.current = true;
68
  startX.current = e.clientX;
@@ -177,40 +181,51 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
177
  setWikiWidth(20);
178
  };
179
 
180
- const handleGeoClick = useCallback(async (lat, lon) => {
181
- const updatedPoints = [...geoPoints, { lat, lon }];
182
- if (updatedPoints.length > 2) {
183
- updatedPoints.shift(); // keep only two
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
- setGeoPoints(updatedPoints);
186
-
187
- if (updatedPoints.length === 2) {
188
- console.log("Fetching distance");
189
- try {
190
-
191
- const res = await fetch(`${BACKEND_URL}/geodistance`, {
192
- method: 'POST',
193
- headers: { 'Content-Type': 'application/json' },
194
- body: JSON.stringify({
195
- lat1: updatedPoints[0].lat,
196
- lon1: updatedPoints[0].lon,
197
- lat2: updatedPoints[1].lat,
198
- lon2: updatedPoints[1].lon,
199
- unit: geoUnit,
200
- }),
201
- });
202
- const data = await res.json();
203
- setGeoDistance(data.distance);
204
- setGeoSidebarOpen(true);
205
- console.log("Distance fetched:", data.distance);
206
- } catch (err) {
207
- console.error('Failed to fetch distance:', err);
208
- setGeoDistance(null);
209
- }
210
  }
211
- }, [geoPoints, geoUnit]);
212
 
213
- useEffect(() => {
 
 
214
  if (geoPoints.length === 2) {
215
  const cacheKey = `${geoPoints[0].lat},${geoPoints[0].lon}-${geoPoints[1].lat},${geoPoints[1].lon}-${geoUnit}`;
216
  if (distanceCache.current[cacheKey]) {
@@ -254,6 +269,17 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
254
  }
255
  }, [geoPoints, geoUnit, isGeoMarkerDragging]);
256
 
 
 
 
 
 
 
 
 
 
 
 
257
  return (
258
  <div ref={containerRef} style={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden' }}>
259
  {panelSize !== 'closed' && (
@@ -332,7 +358,7 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
332
  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
333
  url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
334
  />
335
- <ClickHandler onClick={handleGeoClick} />
336
  {markerPosition && (
337
  <Marker position={markerPosition}>
338
  {contentType === 'summary' && (
@@ -380,7 +406,7 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
380
  </Marker>
381
  ))}
382
 
383
- {/* Polyline if 2 points are selected and sidebar is open */}
384
  {geoSidebarOpen && geoToolMode === "distance" && geoPoints.length === 2 && (
385
  <Polyline
386
  key={geoPoints.map(pt => `${pt.lat},${pt.lon}`).join('-')}
@@ -414,6 +440,58 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
414
  </Polyline>
415
  )}
416
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  </MapContainer>
418
 
419
  {/* Geo Tools Button */}
@@ -490,6 +568,21 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
490
  >
491
  Measure distance
492
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  {/* Add more tool buttons here in the future */}
494
  </>
495
  )}
@@ -551,6 +644,53 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
551
  </button>
552
  </>
553
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  </div>
555
  )}
556
 
@@ -577,4 +717,5 @@ const Map = ( { onMapClick, searchQuery, contentType } ) => {
577
  );
578
  };
579
 
 
580
  export default Map;
 
7
  Popup ,
8
  useMap,
9
  Polyline,
10
+ Tooltip,
11
+ Polygon
12
  } from 'react-leaflet';
13
  import L from 'leaflet';
14
  import 'leaflet/dist/leaflet.css';
15
+ import { generateGeodesicPoints, calculatePolygonArea, getPolygonCentroid, formatArea } from '../utils/mapUtils';
16
 
17
 
18
  delete L.Icon.Default.prototype._getIconUrl;
 
30
  },
31
  });
32
  return null;
33
+ };
34
 
35
  const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8004';
36
  console.log(BACKEND_URL);
 
57
  const [geoDistance, setGeoDistance] = useState(null);
58
 
59
  const [geoSidebarOpen, setGeoSidebarOpen] = useState(false);
60
+ const [geoToolMode, setGeoToolMode] = useState("menu"); // "menu" | "distance" | "area"
61
  const [geoUnit, setGeoUnit] = useState('km');
62
 
63
  const [isGeoMarkerDragging, setIsGeoMarkerDragging] = useState(false);
64
 
65
  const distanceCache = useRef({});
66
 
67
+ const [areaPoints, setAreaPoints] = useState([]);
68
+ const [polygonArea, setPolygonArea] = useState(null);
69
+
70
  const handleMouseDown = (e) => {
71
  isDragging.current = true;
72
  startX.current = e.clientX;
 
181
  setWikiWidth(20);
182
  };
183
 
184
+ const handleMapClick = useCallback(async (lat, lon) => {
185
+ if (geoToolMode === "distance") {
186
+ const updatedPoints = [...geoPoints, { lat, lon }];
187
+ if (updatedPoints.length > 2) {
188
+ updatedPoints.shift(); // keep only two
189
+ }
190
+ setGeoPoints(updatedPoints);
191
+
192
+ if (updatedPoints.length === 2) {
193
+ console.log("Fetching distance");
194
+ try {
195
+
196
+ const res = await fetch(`${BACKEND_URL}/geodistance`, {
197
+ method: 'POST',
198
+ headers: { 'Content-Type': 'application/json' },
199
+ body: JSON.stringify({
200
+ lat1: updatedPoints[0].lat,
201
+ lon1: updatedPoints[0].lon,
202
+ lat2: updatedPoints[1].lat,
203
+ lon2: updatedPoints[1].lon,
204
+ unit: geoUnit,
205
+ }),
206
+ });
207
+ const data = await res.json();
208
+ setGeoDistance(data.distance);
209
+ setGeoSidebarOpen(true);
210
+ console.log("Distance fetched:", data.distance);
211
+ } catch (err) {
212
+ console.error('Failed to fetch distance:', err);
213
+ setGeoDistance(null);
214
+ }
215
+ }
216
+ } else if (geoToolMode === "area") {
217
+ const updated = [...areaPoints, [lat, lon]];
218
+ setAreaPoints(updated);
219
  }
220
+
221
+ else {
222
+ // setMarkerPosition([lat, lon]);
223
+ console.log("Invalid tool mode:", geoToolMode);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
 
225
 
226
+ }, [geoToolMode, geoPoints, geoUnit, areaPoints]);
227
+
228
+ useEffect(() => {
229
  if (geoPoints.length === 2) {
230
  const cacheKey = `${geoPoints[0].lat},${geoPoints[0].lon}-${geoPoints[1].lat},${geoPoints[1].lon}-${geoUnit}`;
231
  if (distanceCache.current[cacheKey]) {
 
269
  }
270
  }, [geoPoints, geoUnit, isGeoMarkerDragging]);
271
 
272
+ useEffect(() => {
273
+ if (geoToolMode === "area" && areaPoints.length >= 3) {
274
+ // Just ensuring that the polygon is closed (first == last)
275
+ const closed = [...areaPoints, areaPoints[0]];
276
+ const area = calculatePolygonArea(closed.map(([lat, lon]) => [lon, lat])); // [lon, lat] for GeoJSON unfortunately
277
+ setPolygonArea(area);
278
+ } else {
279
+ setPolygonArea(null);
280
+ }
281
+ }, [geoToolMode, areaPoints]);
282
+
283
  return (
284
  <div ref={containerRef} style={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden' }}>
285
  {panelSize !== 'closed' && (
 
358
  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
359
  url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
360
  />
361
+ <ClickHandler onClick={handleMapClick} />
362
  {markerPosition && (
363
  <Marker position={markerPosition}>
364
  {contentType === 'summary' && (
 
406
  </Marker>
407
  ))}
408
 
409
+ {/* Polyline if 2 points are selected and sidebar is open, simple enough */}
410
  {geoSidebarOpen && geoToolMode === "distance" && geoPoints.length === 2 && (
411
  <Polyline
412
  key={geoPoints.map(pt => `${pt.lat},${pt.lon}`).join('-')}
 
440
  </Polyline>
441
  )}
442
 
443
+ {/* Area tools */}
444
+ {geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 2 && (
445
+ <Polyline
446
+ positions={areaPoints}
447
+ pathOptions={{ color: '#1976d2', weight: 3, dashArray: '4 6' }}
448
+ />
449
+ )}
450
+ {geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 3 && (
451
+ <>
452
+ <Polygon
453
+ positions={areaPoints}
454
+ pathOptions={{ color: '#1976d2', fillColor: '#1976d2', fillOpacity: 0.2 }}
455
+ />
456
+ {/* Area label at centroid */}
457
+ <Marker
458
+ position={getPolygonCentroid(areaPoints)}
459
+ interactive={false}
460
+ icon={L.divIcon({
461
+ className: 'area-label',
462
+ html: polygonArea !== null
463
+ ? `<div style="background:rgba(255,255,255,0.8);padding:2px 6px;border-radius:4px;color:#1976d2;font-weight:600;">${formatArea(polygonArea)}</div>`
464
+ : '',
465
+ iconSize: [100, 24],
466
+ iconAnchor: [50, 12]
467
+ })}
468
+ />
469
+ </>
470
+ )}
471
+ {geoSidebarOpen && geoToolMode === "area" && areaPoints.map((pt, idx) => (
472
+ <Marker
473
+ key={`area-${idx}`}
474
+ position={[pt[0], pt[1]]}
475
+ draggable={true}
476
+ eventHandlers={{
477
+ dragstart: () => {
478
+ setIsGeoMarkerDragging(true);
479
+ },
480
+ dragend: (e) => {
481
+ const { lat, lng } = e.target.getLatLng();
482
+ const updated = [...areaPoints];
483
+ updated[idx] = [lat, lng];
484
+ setAreaPoints(updated);
485
+ setIsGeoMarkerDragging(false);
486
+ }
487
+ }}
488
+ >
489
+ <Popup>
490
+ Point {idx + 1}: {pt[0].toFixed(4)}, {pt[1].toFixed(4)}
491
+ </Popup>
492
+ </Marker>
493
+ ))}
494
+
495
  </MapContainer>
496
 
497
  {/* Geo Tools Button */}
 
568
  >
569
  Measure distance
570
  </button>
571
+ <button
572
+ style={{
573
+ marginTop: 16,
574
+ padding: '10px 0',
575
+ borderRadius: 4,
576
+ border: '1px solid #1976d2',
577
+ background: '#1976d2',
578
+ color: 'white',
579
+ fontWeight: 500,
580
+ cursor: 'pointer'
581
+ }}
582
+ onClick={() => setGeoToolMode("area")}
583
+ >
584
+ Measure area
585
+ </button>
586
  {/* Add more tool buttons here in the future */}
587
  </>
588
  )}
 
644
  </button>
645
  </>
646
  )}
647
+
648
+ {geoToolMode === "area" && (
649
+ <>
650
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
651
+ <strong>Area</strong>
652
+ <button
653
+ onClick={() => {
654
+ setGeoToolMode("menu");
655
+ setAreaPoints([]);
656
+ setPolygonArea(null);
657
+ }}
658
+ style={{
659
+ background: 'none',
660
+ border: 'none',
661
+ fontSize: 18,
662
+ cursor: 'pointer',
663
+ color: '#888'
664
+ }}
665
+ title="Back"
666
+ >←</button>
667
+ </div>
668
+ {polygonArea !== null && (
669
+ <div style={{ fontSize: 20, fontWeight: 600, color: '#1976d2' }}>
670
+ {formatArea(polygonArea)}
671
+ </div>
672
+ )}
673
+ <button
674
+ onClick={() => {
675
+ setGeoToolMode("menu");
676
+ setAreaPoints([]);
677
+ setPolygonArea(null);
678
+ }}
679
+ style={{
680
+ marginTop: 8,
681
+ padding: '6px 0',
682
+ borderRadius: 4,
683
+ border: '1px solid #1976d2',
684
+ background: '#1976d2',
685
+ color: 'white',
686
+ fontWeight: 500,
687
+ cursor: 'pointer'
688
+ }}
689
+ >
690
+ Clear & Back
691
+ </button>
692
+ </>
693
+ )}
694
  </div>
695
  )}
696
 
 
717
  );
718
  };
719
 
720
+
721
  export default Map;
frontend/src/utils/mapUtils.js CHANGED
@@ -1,3 +1,4 @@
 
1
  // Haversine-based geodesic interpolator
2
  function generateGeodesicPoints(lat1, lon1, lat2, lon2, numPoints = 512) {
3
  /**
@@ -44,4 +45,75 @@ function generateGeodesicPoints(lat1, lon1, lat2, lon2, numPoints = 512) {
44
  return points;
45
  }
46
 
47
- export default generateGeodesicPoints;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
  // Haversine-based geodesic interpolator
3
  function generateGeodesicPoints(lat1, lon1, lat2, lon2, numPoints = 512) {
4
  /**
 
45
  return points;
46
  }
47
 
48
+
49
+
50
+ /**
51
+ * Calculate the area enclosed by coordinates using simplified Karney method
52
+ * @param {Array<Array<number>>} coordinates - Array of [lat, lon] pairs in decimal degrees
53
+ * @returns {number} Area in square meters
54
+ */
55
+ function calculatePolygonArea(coordinates) {
56
+ if (!coordinates || coordinates.length < 3) {
57
+ throw new Error('At least 3 coordinates are required');
58
+ }
59
+
60
+ // WGS84 ellipsoid parameters
61
+ const a = 6378137.0; // Semi-major axis (meters)
62
+ const f = 1 / 298.257223563; // Flattening
63
+ const e2 = f * (2 - f); // First eccentricity squared
64
+
65
+ // Ensure polygon is closed
66
+ const coords = [...coordinates];
67
+ if (coords[0][0] !== coords[coords.length - 1][0] ||
68
+ coords[0][1] !== coords[coords.length - 1][1]) {
69
+ coords.push(coords[0]);
70
+ }
71
+
72
+ let area = 0;
73
+ const n = coords.length - 1;
74
+
75
+ // Calculate area using simplified geodesic excess method
76
+ for (let i = 0; i < n; i++) {
77
+ const [lat1, lon1] = coords[i];
78
+ const [lat2, lon2] = coords[i + 1];
79
+
80
+ // Convert to radians
81
+ const phi1 = lat1 * Math.PI / 180;
82
+ const phi2 = lat2 * Math.PI / 180;
83
+ let dL = (lon2 - lon1) * Math.PI / 180;
84
+
85
+ // Normalize longitude difference
86
+ while (dL > Math.PI) dL -= 2 * Math.PI;
87
+ while (dL < -Math.PI) dL += 2 * Math.PI;
88
+
89
+ // Geodesic excess contribution
90
+ const E = 2 * Math.atan2(
91
+ Math.tan(dL / 2) * (Math.sin(phi1) + Math.sin(phi2)),
92
+ 2 + Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dL)
93
+ );
94
+
95
+ area += E;
96
+ }
97
+
98
+ // Convert to actual area using ellipsoid parameters
99
+ const ellipsoidArea = Math.abs(area) * (a * a / 2) * (1 - e2);
100
+
101
+ return ellipsoidArea;
102
+ }
103
+
104
+
105
+ function getPolygonCentroid(points) {
106
+ // Simple centroid calculation for small polygons
107
+ let x = 0, y = 0, n = points.length;
108
+ points.forEach(([lat, lon]) => { x += lat; y += lon; });
109
+ return [x / n, y / n];
110
+ }
111
+
112
+ function formatArea(area) {
113
+ if (area > 1e6) return (area / 1e6).toFixed(2) + ' km²';
114
+ if (area > 1e4) return (area / 1e4).toFixed(2) + ' ha';
115
+ return area.toFixed(2) + ' m²';
116
+ }
117
+
118
+ export {generateGeodesicPoints, calculatePolygonArea, getPolygonCentroid, formatArea};
119
+ // calculatePolygonArea