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
|