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 = `
"
${data.content}
`;
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 (
{panelSize !== 'closed' && (
<>
{wikiContent?.title || 'Search for a location'}
{markerPosition && (
)}
{wikiContent ? (
{contentType === 'full' ? (
) : (
{wikiContent.content}
)}
) : (
Search for a location to see Wikipedia content
)}
>
)}
{/* View radio group with minimize/restore */}
{viewPanelOpen ? (
) : (
)}
marker.position)}
shouldZoom={shouldZoom}
setShouldZoom={setShouldZoom}
zoomDelaySeconds={zoomDelaySeconds}
/>
{baseLayer === "satellite" && (
<>
{countryBorders && (
)}
>
)}
{baseLayer === "base" && (
)}
{/* Tropics and Equator Lines */}
<>
{tropicOfCancerLines.map((line, idx) => (
))}
{tropicOfCapricornLines.map((line, idx) => (
))}
{generateLongitudeLines(30, 1).map((line, index) => (
))}
>
{/* Equator Lines */}
{equatorLines.map((line, idx) => (
))}
{markerPosition && (
{contentType === 'summary' && (
{wikiContent ? (
<>
{wikiContent.title}
{wikiContent.content}
>
) : (
"Search for a location to see information"
)}
)}
)}
{/* Exploration Mode Markers */}
{explorationMode && explorationMarkers.map((marker, index) => (
{marker.title}
{!marker.isClickedPoint && (
Distance: {marker.distance.toFixed(1)}m
)}
{marker.isClickedPoint && (
Pos: {marker.position[0].toFixed(4)}, {marker.position[1].toFixed(4)}
)}
))}
{/* Only show geodistance markers/polyline if sidebar is open */}
{geoSidebarOpen && geoToolMode === "distance" && geoPoints.map((pt, index) => (
{
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);
}
}}
>
Point {index + 1}: {pt.lat.toFixed(4)}, {pt.lon.toFixed(4)}
))}
{/* Polyline if 2 points are selected and sidebar is open, simple enough */}
{geoSidebarOpen && geoToolMode === "distance" && geoPoints.length === 2 && (
`${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 && (
{geoDistance !== null
? (numberFormat === 'scientific'
? geoDistance.toExponential(2)
: geoDistance.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })
) + ' ' + geoUnit
: ''}
)}
)}
{/* Area tools */}
{geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 2 && (
)}
{geoSidebarOpen && geoToolMode === "area" && areaPoints.length >= 3 && (
<>
{/* Area label at centroid */}
${formatArea(polygonArea, areaUnit, numberFormat)}`
: '',
iconSize: [100, 24],
iconAnchor: [50, 12]
})}
/>
>
)}
{geoSidebarOpen && geoToolMode === "area" && areaPoints.map((pt, idx) => (
{
setIsGeoMarkerDragging(true);
},
dragend: (e) => {
const { lat, lng } = e.target.getLatLng();
const updated = [...areaPoints];
updated[idx] = [lat, lng];
setAreaPoints(updated);
setIsGeoMarkerDragging(false);
}
}}
>
Point {idx + 1}: {pt[0].toFixed(4)}, {pt[1].toFixed(4)}
))}
{/* Geo Tools Button */}
{!geoSidebarOpen && (
)}
{/* Exploration Mode Button */}
{!explorationSidebarOpen && !geoSidebarOpen && (
)}
{/* Exploration Mode Button - when Geo Tools sidebar is open */}
{!explorationSidebarOpen && geoSidebarOpen && (
)}
{/* Exploration Sidebar */}
{explorationSidebarOpen && (
Exploration Mode
setExplorationMode(e.target.checked)}
/>
{explorationMode && (
✓ Click anywhere on the map to find nearby Wikipedia pages
)}
{explorationMarkers.length > 0 && (
Found {explorationMarkers.length-1} nearby pages
)}
)}
{/* Geo Sidebar - Keep as is */}
{geoSidebarOpen && (
{geoToolMode === "menu" && (
<>
Geo Tools
{/* Add more tool buttons here in the future */}
>
)}
{geoToolMode === "distance" && (
<>
Geodistance
{geoDistance !== null && (
{numberFormat === 'scientific'
? geoDistance.toExponential(2)
: geoDistance.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 })
}
)}
>
)}
{geoToolMode === "area" && (
<>
Area
{polygonArea !== null && (
{formatArea(polygonArea, areaUnit, numberFormat)}
)}
{polygonPerimeter !== null && (
Perimeter: {formatPerimeter(polygonPerimeter, areaUnit, numberFormat)}
)}
>
)}
)}
{panelSize === 'closed' && (
)}
);
};
export default WikiMap;