Spaces:
Runtime error
Runtime error
Commit
·
a5e71eb
1
Parent(s):
ab8ba8e
Add polygon area feature
Browse filesMeasure 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 +178 -37
- frontend/src/utils/mapUtils.js +73 -1
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
|
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
|
181 |
-
|
182 |
-
|
183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
184 |
}
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
console.log("
|
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 |
-
|
|
|
|
|
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='© <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={
|
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='© <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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|