Spaces:
Runtime error
Runtime error
import React, { useState, useEffect, useRef, | |
useCallback | |
} from 'react'; | |
import { MapContainer, TileLayer, | |
useMapEvents, | |
Marker, | |
Popup , | |
useMap, | |
Polyline, | |
Tooltip, | |
Polygon, | |
GeoJSON, | |
ScaleControl | |
} from 'react-leaflet'; | |
import L from 'leaflet'; | |
import 'leaflet/dist/leaflet.css'; | |
import { generateGeodesicPoints, calculatePolygonArea, getPolygonCentroid, formatArea, formatPerimeter } from '../utils/mapUtils'; | |
delete L.Icon.Default.prototype._getIconUrl; | |
L.Icon.Default.mergeOptions({ | |
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', | |
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', | |
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', | |
}); | |
const maxExplorationLimit = 50; // kilometers, the maximum amount user can select to explore. | |
const ClickHandler = ({ onClick }) => { | |
useMapEvents({ | |
click(e) { | |
const { lat, lng } = e.latlng; | |
onClick(lat, lng); | |
}, | |
}); | |
return null; | |
}; | |
const rawUrl = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8004'; | |
const BACKEND_URL = rawUrl.replace(/\/$/, ''); | |
// console.log(BACKEND_URL); | |
const ResizeHandler = ({ trigger }) => { | |
const map = useMap(); | |
useEffect(() => { | |
map.invalidateSize(); | |
}, [trigger, map]); | |
return null; | |
}; | |
const WikiMap = ( { onMapClick, searchQuery, contentType, setSearchQuery, setSubmittedQuery } ) => { | |
const [baseLayer, setBaseLayer] = useState("base"); // "base" | "satellite" | |
const [markerPosition, setMarkerPosition] = useState(null); | |
const [wikiContent, setWikiContent] = useState(null); | |
const [panelSize, setPanelSize] = useState('half'); | |
const [wikiWidth, setWikiWidth] = useState(20); | |
const [iframeSrc, setIframeSrc] = useState(''); | |
const isDragging = useRef(false); | |
const startX = useRef(0); | |
const startWidth = useRef(0); | |
const containerRef = useRef(null); | |
const [geoPoints, setGeoPoints] = useState([]); | |
const [geoDistance, setGeoDistance] = useState(null); | |
const [geoSidebarOpen, setGeoSidebarOpen] = useState(false); | |
const [geoToolMode, setGeoToolMode] = useState("menu"); // "menu" | "distance" | "area" | |
const [geoUnit, setGeoUnit] = useState('km'); | |
const [isGeoMarkerDragging, setIsGeoMarkerDragging] = useState(false); | |
const distanceCache = useRef({}); | |
const [areaPoints, setAreaPoints] = useState([]); | |
const [polygonArea, setPolygonArea] = useState(null); | |
const [areaUnit, setAreaUnit] = useState('sqm'); // 'sqm', 'sqkm', 'ha', 'acres', 'sqmi' | |
const [numberFormat, setNumberFormat] = useState('normal'); // 'normal' | 'scientific' | |
const [polygonPerimeter, setPolygonPerimeter] = useState(null); | |
const [viewPanelOpen, setViewPanelOpen] = useState(true); | |
const [countryBorders, setCountryBorders] = useState(null); | |
const [explorationMode, setExplorationMode] = useState(false); | |
const [explorationRadius, setExplorationRadius] = useState(10); | |
const [explorationLimit, setExplorationLimit] = useState(10); | |
const [explorationMarkers, setExplorationMarkers] = useState([]); | |
const [explorationSidebarOpen, setExplorationSidebarOpen] = useState(false); | |
const [shouldZoom, setShouldZoom] = useState(false); | |
const [zoomDelaySeconds, setZoomDelaySeconds] = useState(3); // Default zoom delay in seconds | |
// Using CenterMap component to handle centering (for summary/full apis) and for zooming (for wiki/nearby api) | |
const CenterMap = ({ position, coordinates, shouldZoom, setShouldZoom, zoomDelaySeconds }) => { | |
const map = useMap(); | |
useEffect(() => { | |
if (position && Array.isArray(position) && position.length === 2) { | |
map.setView(position, map.getZoom()); | |
} | |
}, [map, position]); | |
useEffect(() => { | |
if (coordinates && Array.isArray(coordinates) && coordinates.length > 1 && shouldZoom) { | |
const bounds = L.latLngBounds(coordinates); | |
console.log("Delay:", zoomDelaySeconds); | |
if (zoomDelaySeconds > 0) { | |
map.flyToBounds(bounds, { | |
padding: [50, 50], | |
maxZoom: 16, | |
duration: zoomDelaySeconds | |
}); | |
} else { | |
map.fitBounds(bounds, { | |
padding: [50, 50], | |
maxZoom: 16 | |
}); | |
} | |
setShouldZoom(false); | |
} | |
}, [coordinates, map, shouldZoom, setShouldZoom, zoomDelaySeconds]); | |
return null; | |
}; | |
const handleMouseDown = (e) => { | |
isDragging.current = true; | |
startX.current = e.clientX; | |
startWidth.current = wikiWidth; | |
document.body.style.cursor = 'col-resize'; | |
document.body.style.userSelect = 'none'; | |
}; | |
const handleMouseMove = (e) => { | |
if (!isDragging.current || !containerRef.current) return; | |
const containerWidth = containerRef.current.offsetWidth; | |
const deltaX = e.clientX - startX.current; | |
const newWidth = Math.max(20, Math.min(80, startWidth.current + (deltaX / containerWidth * 100))); | |
setWikiWidth(newWidth); | |
}; | |
const handleMouseUp = () => { | |
isDragging.current = false; | |
document.body.style.cursor = ''; | |
document.body.style.userSelect = ''; | |
} | |
useEffect(() => { | |
document.addEventListener('mousemove', handleMouseMove); | |
document.addEventListener('mouseup', handleMouseUp); | |
return () => { | |
document.removeEventListener('mousemove', handleMouseMove); | |
document.removeEventListener('mouseup', handleMouseUp); | |
}; | |
}, []); | |
const fetchWiki = useCallback(async (pageName) => { | |
try{ | |
let endpoint; | |
if (contentType === 'summary') { | |
endpoint = `${BACKEND_URL}/wiki/search/summary/${pageName}`; | |
} | |
else if (contentType === 'full') { | |
endpoint = `${BACKEND_URL}/wiki/search/full/${pageName}`; | |
} | |
else { | |
console.log("Invalid content type:", contentType); | |
setWikiContent(null); | |
return; | |
} | |
const res = await fetch(endpoint); | |
const data = await res.json(); | |
if (contentType === 'summary') { | |
setWikiContent(data); | |
if (data?.latitude && data?.longitude) { | |
setMarkerPosition([data.latitude, data.longitude]); | |
} | |
} else if (contentType === 'full') { | |
setWikiContent({ | |
title: data.title, | |
content: data.content | |
}); | |
const htmlContent = ` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<style> | |
body { font-family: Arial, sans-serif; padding: 20px; } | |
img { max-width: 100%; } | |
</style> | |
<base href="https://en.wikipedia.org">" | |
<!-- The upper line is added so that relative links in the Wikipedia content work correctly. --> | |
</head> | |
<body> | |
${data.content} | |
</body> | |
</html> | |
`; | |
const blob = new Blob([htmlContent], { type: 'text/html' }); | |
const blobUrl = URL.createObjectURL(blob); | |
setIframeSrc(blobUrl); | |
if (data?.latitude && data?.longitude) { | |
setMarkerPosition([data.latitude, data.longitude]); | |
} | |
} | |
else { | |
console.log("Invalid content type:", contentType); | |
setWikiContent(null); | |
} | |
} catch (error) { | |
console.error("Error fetching Wikipedia content:", error); | |
} | |
}, [contentType]); | |
useEffect(() => { | |
if (searchQuery) { | |
fetchWiki(searchQuery); | |
} | |
}, [searchQuery, fetchWiki]); | |
const togglePanel = () => { | |
setPanelSize(prev => { | |
if (prev === 'half') return 'half'; | |
if (prev === 'full') return 'half'; | |
return 'half'; | |
}); | |
setWikiWidth(20); | |
}; | |
const handleExplorationClick = useCallback(async (lat, lon) => { | |
try{ | |
const res = await fetch(`${BACKEND_URL}/wiki/nearby`, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
lat: lat, | |
lon: lon, | |
radius: explorationRadius*1000, | |
limit: explorationLimit | |
}), | |
}); | |
if (res.ok) { | |
const data = await res.json(); | |
const markers = data.pages.map(page => ({ | |
position: [page.lat, page.lon], | |
title: page.title, | |
distance: page.dist | |
})); | |
// setExplorationMarkers(markers); | |
// Now adding the main clicked point | |
setExplorationMarkers([ | |
{ | |
position: [lat, lon], | |
title: 'Clicked Location', | |
distance: 0, | |
isClickedPoint: true | |
}, | |
...markers | |
]); | |
setShouldZoom(true); | |
console.log(`Found ${markers.length} nearby pages`); // Only backend results. | |
} else { | |
console.error('Failed to fetch nearby pages'); | |
} | |
} catch (err) { | |
console.error('Error fetching nearby pages:', err); | |
} | |
}, [explorationRadius, explorationLimit, setExplorationMarkers, setShouldZoom]); | |
const handleDistanceClick = useCallback(async (lat, lon) => { | |
const updatedPoints = [...geoPoints, { lat, lon }]; | |
if (updatedPoints.length > 2) { | |
updatedPoints.shift(); // keep only two | |
} | |
setGeoPoints(updatedPoints); | |
if (updatedPoints.length === 2) { | |
console.log("Fetching distance"); | |
try { | |
const res = await fetch(`${BACKEND_URL}/geodistance`, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
lat1: updatedPoints[0].lat, | |
lon1: updatedPoints[0].lon, | |
lat2: updatedPoints[1].lat, | |
lon2: updatedPoints[1].lon, | |
unit: geoUnit, | |
}), | |
}); | |
const data = await res.json(); | |
setGeoDistance(data.distance); | |
setGeoSidebarOpen(true); | |
console.log("Distance fetched:", data.distance); | |
} catch (err) { | |
console.error('Failed to fetch distance:', err); | |
setGeoDistance(null); | |
} | |
} | |
}, [geoPoints, geoUnit, setGeoPoints, setGeoDistance, setGeoSidebarOpen]); | |
const handleAreaClick = useCallback((lat, lon) => { | |
const updated = [...areaPoints, [lat, lon]]; | |
setAreaPoints(updated); | |
}, [areaPoints, setAreaPoints]); | |
const handleMapClick = useCallback(async (lat, lon) => { | |
if (explorationMode) { | |
await handleExplorationClick(lat, lon); | |
} else if (geoToolMode === "distance") { | |
await handleDistanceClick(lat, lon); | |
} else if (geoToolMode === "area") { | |
handleAreaClick(lat, lon); | |
} | |
else { | |
console.log("Invalid tool mode:", geoToolMode); | |
} | |
}, [explorationMode, geoToolMode, handleExplorationClick, handleDistanceClick, handleAreaClick]); | |
useEffect(() => { | |
if (geoPoints.length === 2) { | |
const cacheKey = `${geoPoints[0].lat},${geoPoints[0].lon}-${geoPoints[1].lat},${geoPoints[1].lon}-${geoUnit}`; | |
if (distanceCache.current[cacheKey]) { | |
setGeoDistance(distanceCache.current[cacheKey]); | |
console.log("Using cached distance:", distanceCache.current[cacheKey].toFixed(3)); | |
return; | |
} | |
const fetchDistance = async () => { | |
try{ | |
const res = await fetch(`${BACKEND_URL}/geodistance`, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
lat1: geoPoints[0].lat, | |
lon1: geoPoints[0].lon, | |
lat2: geoPoints[1].lat, | |
lon2: geoPoints[1].lon, | |
unit: geoUnit | |
}), | |
}); | |
const data = await res.json(); | |
setGeoDistance(data.distance); | |
distanceCache.current[cacheKey] = data.distance; // Setting up the cache here, forgot it in first attempt. | |
console.log("Using normal distance method:", data.distance.toFixed(3)); | |
} | |
catch (err) { | |
console.error('Failed to fetch distance:', err); | |
setGeoDistance(null); | |
} | |
}; | |
if (isGeoMarkerDragging){ | |
const timeoutId = setTimeout(() => { | |
fetchDistance(); | |
}, 100); // 100ms timeout, before every backend call during dragging | |
return () => clearTimeout(timeoutId); | |
} | |
fetchDistance(); | |
} | |
}, [geoPoints, geoUnit, isGeoMarkerDragging]); | |
useEffect(() => { | |
if (geoToolMode === "area" && areaPoints.length >= 3) { | |
// Just ensuring that the polygon is closed (first == last) | |
const closed = [...areaPoints, areaPoints[0]]; | |
const {area, perimeter} = calculatePolygonArea(closed); // This took me a while to figure out, it should be just (lat, lon), not (lon, lat) | |
setPolygonArea(area); | |
setPolygonPerimeter(perimeter); | |
} else { | |
setPolygonArea(null); | |
setPolygonPerimeter(null); | |
} | |
}, [geoToolMode, areaPoints]); | |
useEffect(() => { | |
if (!countryBorders) { | |
fetch('/data/countryBordersCondensed.json') | |
.then(res => res.json()) | |
.then(data => setCountryBorders(data)) | |
.catch(err => console.error("Failed to load country borders:", err)); | |
} | |
}, [countryBorders]); | |
const wrapCount = 3; | |
const equatorLines = []; | |
for (let i = -wrapCount; i <= wrapCount; i++) { | |
const offset = i * 360; | |
equatorLines.push([ | |
[0, -180 + offset], | |
[0, 180 + offset], | |
]); | |
} | |
const cancerLat = 23.4366; | |
const capricornLat = -23.4366; | |
const generateWrappedLine = (latitude) => { | |
const lines = []; | |
for (let i = -wrapCount; i <= wrapCount; i++) { | |
const offset = i * 360; | |
lines.push([ | |
[latitude, -180 + offset], | |
[latitude, 180 + offset], | |
]); | |
} | |
return lines; | |
}; | |
const tropicOfCancerLines = generateWrappedLine(cancerLat); | |
const tropicOfCapricornLines = generateWrappedLine(capricornLat); | |
const generateLongitudeLines = (interval = 30, wraps = 1) => { | |
const lines = []; | |
for (let lon = -180; lon <= 180; lon += interval) { | |
for (let w = -wraps; w <= wraps; w++) { | |
const wrappedLon = lon + (360 * w); | |
lines.push([ | |
[-90, wrappedLon], | |
[90, wrappedLon] | |
]); | |
} | |
} | |
return lines; | |
}; | |
return ( | |
<div ref={containerRef} style={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden' }}> | |
{panelSize !== 'closed' && ( | |
<> | |
<div style={{ | |
width: `${wikiWidth}%`, | |
height: '100%', | |
overflow: 'auto', | |
padding: '20px', | |
backgroundColor: 'white', | |
boxShadow: '2px 0 5px rgba(0,0,0,0.1)', | |
zIndex: 1000, | |
flexShrink: 0 | |
}}> | |
<div style={{ marginBottom: '20px' }}> | |
<h2 style={{ margin: 2}}>{wikiContent?.title || 'Search for a location'}</h2> | |
{markerPosition && ( | |
<button | |
onClick={() => { | |
setMarkerPosition(null); | |
setWikiContent(null); | |
setIframeSrc(''); | |
setSearchQuery(''); | |
setSubmittedQuery(''); | |
}} | |
style={{ | |
padding: '3px 8px', | |
background: '#e53935', | |
color: 'white', | |
border: 'none', | |
borderRadius: 4, | |
cursor: 'pointer', | |
fontWeight: 500, | |
}} | |
> | |
Remove Marker | |
</button> | |
)} | |
</div> | |
{wikiContent ? ( | |
<div> | |
{contentType === 'full' ? ( | |
<iframe | |
src={iframeSrc} | |
style={{ | |
width: '100%', | |
height: 'calc(100vh - 100px)', | |
border: 'none' | |
}} | |
title="Wikipedia Page" | |
/> | |
) : ( | |
<p>{wikiContent.content}</p> | |
)} | |
</div> | |
) : ( | |
<p>Search for a location to see Wikipedia content</p> | |
)} | |
</div> | |
<div | |
onMouseDown={handleMouseDown} | |
style={{ | |
width: '8px', | |
height: '100%', | |
backgroundColor: '#f0f0f0', | |
cursor: 'col-resize', | |
position: 'relative', | |
zIndex: 1001, | |
display: 'flex', | |
alignItems: 'center', | |
justifyContent: 'center', | |
flexShrink: 0 | |
}} | |
> | |
<div style={{ | |
width: '2px', | |
height: '40px', | |
backgroundColor: '#ccc', | |
borderRadius: '1px' | |
}} /> | |
</div> | |
</> | |
)} | |
<div style={{ | |
flex: 1, | |
height: '100%', | |
position: 'relative', | |
minWidth: 0, | |
overflow: 'hidden' | |
}}> | |
{/* View radio group with minimize/restore */} | |
{viewPanelOpen ? ( | |
<div style={{ | |
position: 'absolute', | |
top: 12, | |
left: 48, // moved right | |
zIndex: 1200, | |
background: 'white', | |
borderRadius: 8, | |
boxShadow: '0 2px 8px rgba(12, 12, 12, 0.08)', | |
padding: '6px 9px 6px 9px', | |
display: 'flex', | |
flexDirection: 'column', | |
gap: 0, | |
minWidth: 100, | |
transition: 'all 0.3s ease-in-out', | |
}}> | |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> | |
<span style={{ fontWeight: 600, fontSize: 16, color: '#333' }}>Map View</span> | |
<button | |
onClick={() => setViewPanelOpen(false)} | |
style={{ | |
background: 'none', | |
border: 'none', | |
top: 12, | |
left: 48, | |
fontSize: 18, | |
cursor: 'pointer', | |
color: '#888', | |
marginLeft: 12, | |
lineHeight: 1, | |
}} | |
title="Minimize" | |
>–</button> | |
</div> | |
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
<input | |
type="radio" | |
name="view" | |
value="base" | |
checked={baseLayer === "base"} | |
onChange={() => setBaseLayer("base")} | |
/> | |
Base | |
</label> | |
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}> | |
<input | |
type="radio" | |
name="view" | |
value="satellite" | |
checked={baseLayer === "satellite"} | |
onChange={() => setBaseLayer("satellite")} | |
/> | |
Satellite | |
</label> | |
</div> | |
) : ( | |
<button | |
onClick={() => setViewPanelOpen(true)} | |
style={{ | |
position: 'absolute', | |
top: 16, | |
left: 50, | |
zIndex: 1200, | |
background: 'white', | |
borderRadius: 8, | |
boxShadow: '0 2px 8px rgba(0,0,0,0.08)', | |
padding: '8px 14px', | |
border: '1px solid #eee', | |
cursor: 'pointer', | |
fontWeight: 600, | |
}} | |
title="Show View Options" | |
> | |
View + | |
</button> | |
)} | |
<MapContainer | |
center={markerPosition || [0, 0]} // Default center if no marker position | |
zoom={2.5} //Originally 2 | |
style={{ height: '100%', width: '100%' }} | |
minZoom={2} | |
// maxZoom={5} | |
maxBounds={[ | |
[-90, -180], | |
[90, 180] | |
]} | |
maxBoundsViscosity={1.0} // This completely prevents panning outside the horizonta/vertical bounds of the map. Avoids showing ugly section of the maps. | |
> | |
<ScaleControl position="bottomright" imperial={true} /> | |
<ResizeHandler trigger={wikiWidth} /> | |
<CenterMap | |
position={markerPosition} | |
coordinates={explorationMarkers.map((marker) => marker.position)} | |
shouldZoom={shouldZoom} | |
setShouldZoom={setShouldZoom} | |
zoomDelaySeconds={zoomDelaySeconds} | |
/> | |
{baseLayer === "satellite" && ( | |
<> | |
<TileLayer | |
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" | |
attribution='© <a href="https://www.esri.com/">Esri</a> & contributors' | |
/> | |
<TileLayer | |
url="https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}.png" | |
attribution='© CartoDB' | |
/> | |
{countryBorders && ( | |
<GeoJSON | |
data={countryBorders} | |
style={{ | |
color: '#ffff99', | |
weight: 1.5, | |
opacity: 0.8, | |
fillOpacity: 0.1, | |
}} | |
/> | |
)} | |
</> | |
)} | |
{baseLayer === "base" && ( | |
<TileLayer | |
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" | |
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' | |
/> | |
)} | |
{/* Tropics and Equator Lines */} | |
<> | |
{tropicOfCancerLines.map((line, idx) => ( | |
<Polyline | |
key={`cancer-${idx}`} | |
positions={line} | |
pathOptions={{ | |
color: 'gray', | |
weight: 1, | |
dashArray: '4, 4', | |
interactive: false, | |
}} | |
/> | |
))} | |
{tropicOfCapricornLines.map((line, idx) => ( | |
<Polyline | |
key={`capricorn-${idx}`} | |
positions={line} | |
pathOptions={{ | |
color: 'gray', | |
weight: 1, | |
dashArray: '4, 4', | |
interactive: false, | |
}} | |
/> | |
))} | |
{generateLongitudeLines(30, 1).map((line, index) => ( | |
<Polyline | |
key={`lon-line-${index}`} | |
positions={line} | |
pathOptions={{ | |
color: '#aaa', | |
dashArray: '4, 4', | |
weight: 1, | |
interactive: false, | |
}} | |
/> | |
))} | |
</> | |
{/* Equator Lines */} | |
{equatorLines.map((line, idx) => ( | |
<Polyline | |
key={`equator-${idx}`} | |
positions={line} | |
pathOptions={{ | |
color: 'gray', | |
weight: 1.5, | |
dashArray: '6, 6', | |
interactive: false, | |
}} | |
/> | |
))} | |
<ClickHandler onClick={handleMapClick} /> | |
{markerPosition && ( | |
<Marker position={markerPosition}> | |
{contentType === 'summary' && ( | |
<Popup minWidth={250}> | |
{wikiContent ? ( | |
<> | |
<strong>{wikiContent.title}</strong><br /> | |
<p style={{ fontSize: '12px' }}>{wikiContent.content}</p> | |
</> | |
) : ( | |
"Search for a location to see information" | |
)} | |
</Popup> | |
)} | |
</Marker> | |
)} | |
{/* Exploration Mode Markers */} | |
{explorationMode && explorationMarkers.map((marker, index) => ( | |
<Marker | |
key={`exploration-${index}`} | |
position={marker.position} | |
icon={marker.isClickedPoint ? new L.Icon({ | |
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', | |
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', | |
iconSize: [25, 41], | |
iconAnchor: [12, 41], | |
popupAnchor: [1, -34], | |
shadowSize: [41, 41] | |
}) : new L.Icon({ | |
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png', | |
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', | |
iconSize: [25, 41], | |
iconAnchor: [12, 41], | |
popupAnchor: [1, -34], | |
shadowSize: [41, 41] | |
})} | |
> | |
<Popup> | |
<div> | |
<strong>{marker.title}</strong><br /> | |
{!marker.isClickedPoint && ( | |
<small>Distance: {marker.distance.toFixed(1)}m</small> | |
)} | |
{marker.isClickedPoint && ( | |
<small>Pos: {marker.position[0].toFixed(4)}, {marker.position[1].toFixed(4)}</small> | |
)} | |
</div> | |
</Popup> | |
</Marker> | |
))} | |
{/* Only show geodistance markers/polyline if sidebar is open */} | |
{geoSidebarOpen && geoToolMode === "distance" && geoPoints.map((pt, index) => ( | |
<Marker key={`geo-${index}`} | |
position={[pt.lat, pt.lon]} | |
draggable={true} | |
eventHandlers={{ | |
dragstart: () => { | |
setIsGeoMarkerDragging(true); | |
}, | |
drag: (e) => { | |
const { lat, lng } = e.target.getLatLng(); | |
const updated = [...geoPoints]; | |
updated[index] = { lat, lon: lng }; | |
setGeoPoints(updated); // The distance function will be continioust triggered throughout the dragging journey | |
} , | |
dragend: (e) => { | |
const { lat, lng } = e.target.getLatLng(); | |
const updated = [...geoPoints]; | |
updated[index] = { lat, lon: lng }; | |
setGeoPoints(updated); // Triggering the distance fetch via useEffect | |
setIsGeoMarkerDragging(false); | |
} | |
}} | |
> | |
<Popup> | |
Point {index + 1}: {pt.lat.toFixed(4)}, {pt.lon.toFixed(4)} | |
</Popup> | |
</Marker> | |
))} | |
{/* Polyline if 2 points are selected and sidebar is open, simple enough */} | |
{geoSidebarOpen && geoToolMode === "distance" && geoPoints.length === 2 && ( | |
<Polyline | |
key={geoPoints.map(pt => `${pt.lat},${pt.lon}`).join('-')} | |
positions={generateGeodesicPoints( | |
geoPoints[0].lat, geoPoints[0].lon, | |
geoPoints[1].lat, geoPoints[1].lon | |
)} | |
pathOptions={{ color: '#1976d2', weight: 4 }} | |
> | |
{geoDistance !== null && ( | |
<Tooltip | |
direction="center" | |
permanent | |
offset={[0, 0]} | |
opacity={1} | |
className="distance-tooltip" | |
> | |
<span style={{ | |
color: '#1976d2', | |
fontWeight: 600, | |
fontSize: '15px', | |
background: 'none', | |
border: 'none', | |
boxShadow: 'none', | |
padding: 0 | |
}}> | |
{geoDistance !== null | |
? (numberFormat === 'scientific' | |
? geoDistance.toExponential(2) | |
: geoDistance.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }) | |
) + ' ' + geoUnit | |
: ''} | |
</span> | |
</Tooltip> | |
)} | |
</Polyline> | |
)} | |
{/* Area tools */} | |
{geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 2 && ( | |
<Polyline | |
positions={areaPoints} | |
pathOptions={{ color: '#1976d2', weight: 3, dashArray: '4 6' }} | |
/> | |
)} | |
{geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 3 && ( | |
<> | |
<Polygon | |
positions={areaPoints} | |
pathOptions={{ color: '#1976d2', fillColor: '#1976d2', fillOpacity: 0.2 }} | |
/> | |
{/* Area label at centroid */} | |
<Marker | |
position={getPolygonCentroid(areaPoints)} | |
interactive={false} | |
icon={L.divIcon({ | |
className: 'area-label', | |
html: polygonArea !== null | |
? `<div style="background:rgba(255,255,255,0.8);padding:2px 6px;border-radius:4px;color:#1976d2;font-weight:600;">${formatArea(polygonArea, areaUnit, numberFormat)}</div>` | |
: '', | |
iconSize: [100, 24], | |
iconAnchor: [50, 12] | |
})} | |
/> | |
</> | |
)} | |
{geoSidebarOpen && geoToolMode === "area" && areaPoints.map((pt, idx) => ( | |
<Marker | |
key={`area-${idx}`} | |
position={[pt[0], pt[1]]} | |
draggable={true} | |
eventHandlers={{ | |
dragstart: () => { | |
setIsGeoMarkerDragging(true); | |
}, | |
dragend: (e) => { | |
const { lat, lng } = e.target.getLatLng(); | |
const updated = [...areaPoints]; | |
updated[idx] = [lat, lng]; | |
setAreaPoints(updated); | |
setIsGeoMarkerDragging(false); | |
} | |
}} | |
> | |
<Popup> | |
Point {idx + 1}: {pt[0].toFixed(4)}, {pt[1].toFixed(4)} | |
</Popup> | |
</Marker> | |
))} | |
</MapContainer> | |
{/* Geo Tools Button */} | |
{!geoSidebarOpen && ( | |
<button | |
onClick={() => setGeoSidebarOpen(true)} | |
style={{ | |
position: 'absolute', | |
top: 12, | |
right: 12, | |
zIndex: 1000, | |
padding: '6px 12px', | |
backgroundColor: '#1976d2', | |
color: 'white', | |
border: 'none', | |
borderRadius: 4, | |
cursor: 'pointer' | |
}} | |
> | |
Geo Tools | |
</button> | |
)} | |
{/* Exploration Mode Button */} | |
{!explorationSidebarOpen && !geoSidebarOpen && ( | |
<button | |
onClick={() => setExplorationSidebarOpen(true)} | |
style={{ | |
position: 'absolute', | |
top: 50, // Position below Geo Tools button | |
right: 12, | |
zIndex: 1000, | |
padding: '6px 12px', | |
backgroundColor: '#4caf50', | |
color: 'white', | |
border: 'none', | |
borderRadius: 4, | |
cursor: 'pointer' | |
}} | |
> | |
Exploration | |
</button> | |
)} | |
{/* Exploration Mode Button - when Geo Tools sidebar is open */} | |
{!explorationSidebarOpen && geoSidebarOpen && ( | |
<button | |
onClick={() => setExplorationSidebarOpen(true)} | |
style={{ | |
position: 'fixed', | |
top: 320, // Position below Geo Tools sidebar | |
right: 24, | |
zIndex: 2000, | |
padding: '6px 12px', | |
backgroundColor: '#4caf50', | |
color: 'white', | |
border: 'none', | |
borderRadius: 4, | |
cursor: 'pointer' | |
}} | |
> | |
Exploration | |
</button> | |
)} | |
{/* Exploration Sidebar */} | |
{explorationSidebarOpen && ( | |
<div style={{ | |
position: 'fixed', | |
top: geoSidebarOpen ? 320 : 24, // Position below Geo Tools sidebar if open | |
right: 24, | |
width: 280, | |
background: 'white', | |
borderRadius: 10, | |
boxShadow: '0 2px 12px rgba(0,0,0,0.12)', | |
zIndex: 2000, | |
padding: 20, | |
display: 'flex', | |
flexDirection: 'column', | |
gap: 16, | |
border: '1px solid #eee' | |
}}> | |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
<strong>Exploration Mode</strong> | |
<button | |
onClick={() => { | |
setExplorationSidebarOpen(false); | |
// setExplorationMode(false); // even with exp. sidebar closed, you can do exploration normally. | |
// setExplorationMarkers([]); | |
setShouldZoom(false); // If this line is removed, it map automatically zooms after re-opening/closing the exp. sidebar | |
}} | |
style={{ | |
background: 'none', | |
border: 'none', | |
fontSize: 18, | |
cursor: 'pointer', | |
color: '#888' | |
}} | |
title="Close" | |
>×</button> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginBottom: 8, display: 'block' }}> | |
Search Radius (km): | |
</label> | |
<input | |
type="range" | |
min="1" | |
max={maxExplorationLimit} | |
value={explorationRadius} | |
onChange={(e) => setExplorationRadius(parseInt(e.target.value))} | |
style={{ width: '100%' }} | |
/> | |
<div style={{ | |
display: 'flex', | |
alignItems: 'center', | |
gap: '8px', | |
marginTop: 4, | |
justifyContent: 'center' | |
}}> | |
<input | |
type="number" | |
min="1" | |
max={maxExplorationLimit} | |
value={explorationRadius} | |
onChange={(e) => { | |
const value = parseInt(e.target.value); | |
if (value >= 1 && value <= maxExplorationLimit) { | |
setExplorationRadius(value); | |
} | |
}} | |
style={{ | |
width: '80px', | |
padding: '4px 8px', | |
border: '1px solid #ccc', | |
borderRadius: '4px', | |
textAlign: 'center' | |
}} | |
/> | |
<span>km</span> | |
</div> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginBottom: 8, display: 'block' }}> | |
Number of Results: | |
</label> | |
<input | |
type="range" | |
min="1" | |
max="50" | |
step="1" | |
value={explorationLimit} | |
onChange={(e) => setExplorationLimit(parseInt(e.target.value))} | |
style={{ width: '100%' }} | |
/> | |
<div style={{ textAlign: 'center', marginTop: 4 }}> | |
{explorationLimit} results | |
</div> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginBottom: 8, display: 'block' }}> | |
Zoom Delay (seconds): | |
</label> | |
<input | |
type="range" | |
min="0" | |
max="5" | |
step="1" | |
value={zoomDelaySeconds} | |
onChange={(e) => setZoomDelaySeconds(parseInt(e.target.value))} | |
style={{ width: '100%' }} | |
/> | |
<div style={{ textAlign: 'center', marginTop: 4 }}> | |
{zoomDelaySeconds} sec. zoom duration | |
</div> | |
</div> | |
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> | |
<input | |
type="checkbox" | |
id="explorationMode" | |
checked={explorationMode} | |
onChange={(e) => setExplorationMode(e.target.checked)} | |
/> | |
<label htmlFor="explorationMode" style={{ fontWeight: 500 }}> | |
Enable Exploration Mode | |
</label> | |
</div> | |
{explorationMode && ( | |
<div style={{ | |
padding: '8px 12px', | |
backgroundColor: '#e8f5e8', | |
borderRadius: 4, | |
fontSize: '14px', | |
color: '#2e7d32' | |
}}> | |
✓ Click anywhere on the map to find nearby Wikipedia pages | |
</div> | |
)} | |
{explorationMarkers.length > 0 && ( | |
<div style={{ | |
padding: '8px 12px', | |
backgroundColor: '#e3f2fd', | |
borderRadius: 4, | |
fontSize: '14px', | |
color: '#1976d2' | |
}}> | |
Found <span style={{ fontWeight: 'bold' }}>{explorationMarkers.length-1}</span> nearby pages | |
</div> | |
)} | |
<button | |
onClick={() => { | |
setExplorationMarkers([]); | |
}} | |
style={{ | |
padding: '6px 0', | |
borderRadius: 4, | |
border: '1px solid #f44336', | |
background: '#f44336', | |
color: 'white', | |
fontWeight: 500, | |
cursor: 'pointer' | |
}} | |
> | |
Clear Markers | |
</button> | |
</div> | |
)} | |
{/* Geo Sidebar - Keep as is */} | |
{geoSidebarOpen && ( | |
<div style={{ | |
position: 'fixed', | |
top: 24, | |
right: 24, | |
width: 280, | |
background: 'white', | |
borderRadius: 10, | |
boxShadow: '0 2px 12px rgba(0,0,0,0.12)', | |
zIndex: 2000, | |
padding: 20, | |
display: 'flex', | |
flexDirection: 'column', | |
gap: 16, | |
border: '1px solid #eee' | |
}}> | |
{geoToolMode === "menu" && ( | |
<> | |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
<strong>Geo Tools</strong> | |
<button | |
onClick={() => { | |
setGeoSidebarOpen(false); | |
setGeoPoints([]); | |
setGeoDistance(null); | |
setGeoToolMode("menu"); | |
}} | |
style={{ | |
background: 'none', | |
border: 'none', | |
fontSize: 18, | |
cursor: 'pointer', | |
color: '#888' | |
}} | |
title="Close" | |
>×</button> | |
</div> | |
<button | |
style={{ | |
marginTop: 16, | |
padding: '10px 0', | |
borderRadius: 4, | |
border: '1px solid #1976d2', | |
background: '#1976d2', | |
color: 'white', | |
fontWeight: 500, | |
cursor: 'pointer' | |
}} | |
onClick={() => setGeoToolMode("distance")} | |
> | |
Measure distance | |
</button> | |
<button | |
style={{ | |
marginTop: 16, | |
padding: '10px 0', | |
borderRadius: 4, | |
border: '1px solid #1976d2', | |
background: '#1976d2', | |
color: 'white', | |
fontWeight: 500, | |
cursor: 'pointer' | |
}} | |
onClick={() => setGeoToolMode("area")} | |
> | |
Measure area | |
</button> | |
{/* Add more tool buttons here in the future */} | |
</> | |
)} | |
{geoToolMode === "distance" && ( | |
<> | |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
<strong>Geodistance</strong> | |
<button | |
onClick={() => { | |
setGeoToolMode("menu"); | |
setGeoPoints([]); | |
setGeoDistance(null); | |
}} | |
style={{ | |
background: 'none', | |
border: 'none', | |
fontSize: 18, | |
cursor: 'pointer', | |
color: '#888' | |
}} | |
title="Back" | |
>←</button> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginRight: 8 }}>Unit:</label> | |
<select | |
value={geoUnit} | |
onChange={e => setGeoUnit(e.target.value)} | |
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} | |
> | |
<option value="km">Kilometers</option> | |
<option value="mi">Miles</option> | |
</select> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginRight: 8 }}>Number format:</label> | |
<select | |
value={numberFormat} | |
onChange={e => setNumberFormat(e.target.value)} | |
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} | |
> | |
<option value="normal">Normal</option> | |
<option value="scientific">Scientific</option> | |
</select> | |
</div> | |
{geoDistance !== null && ( | |
<div style={{ fontSize: 20, fontWeight: 600, color: '#1976d2' }}> | |
{numberFormat === 'scientific' | |
? geoDistance.toExponential(2) | |
: geoDistance.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }) | |
} | |
</div> | |
)} | |
<button | |
onClick={() => { | |
setGeoToolMode("menu"); | |
setGeoPoints([]); | |
setGeoDistance(null); | |
}} | |
style={{ | |
marginTop: 8, | |
padding: '6px 0', | |
borderRadius: 4, | |
border: '1px solid #1976d2', | |
background: '#1976d2', | |
color: 'white', | |
fontWeight: 500, | |
cursor: 'pointer' | |
}} | |
> | |
Clear & Back | |
</button> | |
</> | |
)} | |
{geoToolMode === "area" && ( | |
<> | |
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
<strong>Area</strong> | |
<button | |
onClick={() => { | |
setGeoToolMode("menu"); | |
setAreaPoints([]); | |
setPolygonArea(null); | |
setPolygonPerimeter(null); | |
}} | |
style={{ | |
background: 'none', | |
border: 'none', | |
fontSize: 18, | |
cursor: 'pointer', | |
color: '#888' | |
}} | |
title="Back" | |
>←</button> | |
</div> | |
{polygonArea !== null && ( | |
<div style={{ fontSize: 20, fontWeight: 600, color: '#1976d2' }}> | |
{formatArea(polygonArea, areaUnit, numberFormat)} | |
</div> | |
)} | |
{polygonPerimeter !== null && ( | |
<div style={{ fontSize: 16, color: '#555' }}> | |
Perimeter: {formatPerimeter(polygonPerimeter, areaUnit, numberFormat)} | |
</div> | |
)} | |
<button | |
onClick={() => { | |
setGeoToolMode("menu"); | |
setAreaPoints([]); | |
setPolygonArea(null); | |
setPolygonPerimeter(null); | |
}} | |
style={{ | |
marginTop: 8, | |
padding: '6px 0', | |
borderRadius: 4, | |
border: '1px solid #1976d2', | |
background: '#1976d2', | |
color: 'white', | |
fontWeight: 500, | |
cursor: 'pointer' | |
}} | |
> | |
Clear & Back | |
</button> | |
<div> | |
<label style={{ fontWeight: 500, marginRight: 8 }}>Unit:</label> | |
<select | |
value={areaUnit} | |
onChange={e => setAreaUnit(e.target.value)} | |
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} | |
> | |
<option value="m2">m²</option> | |
<option value="km2">km²</option> | |
<option value="ha">ha</option> | |
<option value="mi2">mi²</option> | |
<option value="acres">acres</option> | |
<option value="sqft">ft²</option> | |
</select> | |
</div> | |
<div> | |
<label style={{ fontWeight: 500, marginRight: 8 }}>Number format:</label> | |
<select | |
value={numberFormat} | |
onChange={e => setNumberFormat(e.target.value)} | |
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }} | |
> | |
<option value="normal">Normal</option> | |
<option value="scientific">Scientific</option> | |
</select> | |
</div> | |
</> | |
)} | |
</div> | |
)} | |
{panelSize === 'closed' && ( | |
<button | |
onClick={togglePanel} | |
style={{ | |
position: 'absolute', | |
top: '10px', | |
left: '10px', | |
zIndex: 1000, | |
padding: '5px 10px', | |
backgroundColor: 'white', | |
border: '1px solid #ccc', | |
borderRadius: '4px', | |
cursor: 'pointer' | |
}} | |
> | |
Show Wikipedia | |
</button> | |
)} | |
</div> | |
</div> | |
); | |
}; | |
export default WikiMap; |