mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
- Remove nested git repository from bugulma/frontend/.git - Add all frontend files to main repository tracking - Convert from separate frontend/backend repos to unified monorepo - Preserve all frontend code and development history as tracked files - Eliminate nested repository complexity for simpler development workflow This creates a proper monorepo structure with frontend and backend coexisting in the same repository for easier development and deployment.
283 lines
9.1 KiB
TypeScript
283 lines
9.1 KiB
TypeScript
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
import { GeoJSON, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
|
import MarkerClusterGroup from 'react-leaflet-markercluster';
|
|
import 'react-leaflet-markercluster/styles';
|
|
import { useMapFilter, useMapInteraction, useMapUI, useMapViewport } from '@/contexts/MapContexts.tsx';
|
|
import bugulmaGeo from '@/data/bugulmaGeometry.json';
|
|
import { useMapData } from '@/hooks/map/useMapData.ts';
|
|
import HistoricalMarkers from '@/components/map/HistoricalMarkers.tsx';
|
|
import MapControls from '@/components/map/MapControls.tsx';
|
|
import OrganizationCenterHandler from '@/components/map/OrganizationCenterHandler.tsx';
|
|
import OrganizationMarkers from '@/components/map/OrganizationMarkers.tsx';
|
|
import SymbiosisLines from '@/components/map/SymbiosisLines.tsx';
|
|
|
|
// Fix for default marker icon issue in Leaflet with webpack/vite
|
|
delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl;
|
|
L.Icon.Default.mergeOptions({
|
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
|
});
|
|
|
|
/**
|
|
* Component to handle map resize when sidebar opens/closes
|
|
* Leaflet needs to recalculate its size when container dimensions change
|
|
*/
|
|
const MapResizeHandler = () => {
|
|
const map = useMap();
|
|
const { isSidebarOpen } = useMapUI();
|
|
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Clear any pending resize
|
|
if (resizeTimeoutRef.current) {
|
|
clearTimeout(resizeTimeoutRef.current);
|
|
}
|
|
|
|
// Delay resize to allow CSS transition to complete
|
|
resizeTimeoutRef.current = setTimeout(() => {
|
|
map.invalidateSize();
|
|
}, 350); // Slightly longer than CSS transition (300ms)
|
|
|
|
return () => {
|
|
if (resizeTimeoutRef.current) {
|
|
clearTimeout(resizeTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [map, isSidebarOpen]);
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Component to sync Leaflet map view with context state
|
|
* Optimized to prevent infinite loops and unnecessary updates
|
|
*/
|
|
const MapSync = () => {
|
|
const { mapCenter, zoom, setZoom, setMapCenter } = useMapViewport();
|
|
const map = useMap();
|
|
const isUpdatingRef = useRef(false);
|
|
const lastUpdateRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
|
|
|
// Sync context state to map (only when explicitly changed, not from map events)
|
|
useEffect(() => {
|
|
// Prevent updates during user interaction
|
|
if (isUpdatingRef.current) return;
|
|
|
|
const currentCenter = map.getCenter();
|
|
const centerDiff =
|
|
Math.abs(currentCenter.lat - mapCenter[0]) + Math.abs(currentCenter.lng - mapCenter[1]);
|
|
const zoomDiff = Math.abs(map.getZoom() - zoom);
|
|
|
|
// Only update if difference is significant (prevents micro-updates)
|
|
const shouldUpdate = zoomDiff > 0.1 || centerDiff > 0.0001 || !lastUpdateRef.current;
|
|
|
|
if (shouldUpdate) {
|
|
// Check if this update would cause a loop
|
|
const lastUpdate = lastUpdateRef.current;
|
|
if (
|
|
lastUpdate &&
|
|
lastUpdate.center[0] === mapCenter[0] &&
|
|
lastUpdate.center[1] === mapCenter[1] &&
|
|
lastUpdate.zoom === zoom
|
|
) {
|
|
return;
|
|
}
|
|
|
|
isUpdatingRef.current = true;
|
|
map.setView(mapCenter, zoom, { animate: true });
|
|
lastUpdateRef.current = { center: mapCenter, zoom };
|
|
|
|
// Reset flag after animation completes
|
|
const timeoutId = setTimeout(() => {
|
|
isUpdatingRef.current = false;
|
|
}, 300);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}
|
|
}, [map, mapCenter, zoom]);
|
|
|
|
// Sync map events to context (throttled to avoid excessive updates)
|
|
useMapEvents({
|
|
moveend: () => {
|
|
if (isUpdatingRef.current) return;
|
|
const center = map.getCenter();
|
|
const newCenter: [number, number] = [center.lat, center.lng];
|
|
|
|
// Only update if center actually changed
|
|
if (
|
|
!lastUpdateRef.current ||
|
|
Math.abs(lastUpdateRef.current.center[0] - newCenter[0]) > 0.0001 ||
|
|
Math.abs(lastUpdateRef.current.center[1] - newCenter[1]) > 0.0001
|
|
) {
|
|
setMapCenter(newCenter);
|
|
}
|
|
},
|
|
zoomend: () => {
|
|
if (isUpdatingRef.current) return;
|
|
const newZoom = map.getZoom();
|
|
|
|
// Only update if zoom actually changed
|
|
if (!lastUpdateRef.current || Math.abs(lastUpdateRef.current.zoom - newZoom) > 0.1) {
|
|
setZoom(newZoom);
|
|
}
|
|
},
|
|
});
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* LeafletMap Component
|
|
*
|
|
* Renders an interactive map of Bugulma using Leaflet.
|
|
* Features:
|
|
* - Real city geometry from OSM Relation 9684457
|
|
* - Organization and historical landmark markers with clustering
|
|
* - AI-powered symbiosis connection visualization
|
|
* - Zoom and pan controls
|
|
*/
|
|
const LeafletMap = () => {
|
|
const { mapCenter, zoom } = useMapViewport();
|
|
const { selectedOrg, hoveredOrgId, selectedLandmark, hoveredLandmarkId, symbiosisResult } =
|
|
useMapInteraction();
|
|
const { historicalLandmarks } = useMapData();
|
|
const { filteredAndSortedOrgs } = useMapFilter();
|
|
const { mapViewMode } = useMapUI();
|
|
|
|
// GeoJSON style function - show only edges, no fill to avoid darkening the area
|
|
const geoJsonStyle = {
|
|
fillColor: 'transparent',
|
|
fillOpacity: 0,
|
|
color: 'hsl(var(--border))',
|
|
weight: 1.5,
|
|
opacity: 0.8,
|
|
};
|
|
|
|
// Optimize map container with performance settings
|
|
const whenCreated = useCallback((mapInstance: L.Map) => {
|
|
// Enable preferCanvas for better performance with many markers
|
|
mapInstance.options.preferCanvas = true;
|
|
// Optimize rendering
|
|
mapInstance.options.fadeAnimation = true;
|
|
mapInstance.options.zoomAnimation = true;
|
|
mapInstance.options.zoomAnimationThreshold = 4;
|
|
|
|
// Remove Leaflet attribution control (removes flag and "Leaflet" branding)
|
|
mapInstance.attributionControl.remove();
|
|
}, []);
|
|
|
|
return (
|
|
<MapContainer
|
|
center={mapCenter}
|
|
zoom={zoom}
|
|
minZoom={10}
|
|
maxZoom={18}
|
|
zoomControl={true}
|
|
scrollWheelZoom={true}
|
|
attributionControl={false}
|
|
className="bg-background h-full w-full"
|
|
preferCanvas={true}
|
|
fadeAnimation={true}
|
|
zoomAnimation={true}
|
|
zoomAnimationThreshold={4}
|
|
whenCreated={whenCreated}
|
|
>
|
|
<MapSync />
|
|
<MapResizeHandler />
|
|
<OrganizationCenterHandler />
|
|
|
|
{/* Map tile layer */}
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
{/* City boundary GeoJSON */}
|
|
{bugulmaGeo && (
|
|
<GeoJSON
|
|
data={bugulmaGeo as Parameters<typeof GeoJSON>[0]['data']}
|
|
style={geoJsonStyle}
|
|
onEachFeature={(feature, layer) => {
|
|
// Optional: Add feature interaction handlers here if needed
|
|
layer.on({
|
|
mouseover: () => {
|
|
layer.setStyle({ weight: 2, opacity: 1 });
|
|
},
|
|
mouseout: () => {
|
|
layer.setStyle({ weight: 1.5, opacity: 0.8 });
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Symbiosis connection lines */}
|
|
{mapViewMode === 'organizations' && symbiosisResult && selectedOrg && (
|
|
<SymbiosisLines
|
|
matches={symbiosisResult}
|
|
hoveredOrgId={hoveredOrgId}
|
|
selectedOrg={selectedOrg}
|
|
/>
|
|
)}
|
|
|
|
{/* Organization markers with clustering */}
|
|
{mapViewMode === 'organizations' && (
|
|
<MarkerClusterGroup
|
|
chunkedLoading={true}
|
|
chunkDelay={50}
|
|
chunkInterval={100}
|
|
maxClusterRadius={80}
|
|
spiderfyOnMaxZoom={true}
|
|
showCoverageOnHover={false}
|
|
zoomToBoundsOnClick={true}
|
|
disableClusteringAtZoom={15}
|
|
removeOutsideVisibleBounds={false}
|
|
animate={true}
|
|
animateAddingMarkers={true}
|
|
spiderfyDistanceMultiplier={2}
|
|
spiderLegPolylineOptions={{
|
|
weight: 1.5,
|
|
color: 'hsl(var(--muted-foreground))',
|
|
opacity: 0.5,
|
|
}}
|
|
iconCreateFunction={(cluster) => {
|
|
const count = cluster.getChildCount();
|
|
const size = count < 10 ? 'small' : count < 100 ? 'medium' : 'large';
|
|
const className = `marker-cluster marker-cluster-${size}`;
|
|
return L.divIcon({
|
|
html: `<div><span>${count}</span></div>`,
|
|
className,
|
|
iconSize: L.point(40, 40),
|
|
});
|
|
}}
|
|
>
|
|
<OrganizationMarkers
|
|
organizations={filteredAndSortedOrgs}
|
|
selectedOrg={selectedOrg}
|
|
hoveredOrgId={hoveredOrgId}
|
|
/>
|
|
</MarkerClusterGroup>
|
|
)}
|
|
|
|
{/* Historical landmark markers */}
|
|
{mapViewMode === 'historical' && (
|
|
<HistoricalMarkers
|
|
landmarks={historicalLandmarks}
|
|
selectedLandmark={selectedLandmark}
|
|
hoveredLandmarkId={hoveredLandmarkId}
|
|
/>
|
|
)}
|
|
|
|
{/* Map controls */}
|
|
<MapControls />
|
|
</MapContainer>
|
|
);
|
|
};
|
|
|
|
export default LeafletMap;
|