生产管理系统前端 - gis地图管理开发
This commit is contained in:
388
crop-x/src/components/shared/BaseMap.tsx
Normal file
388
crop-x/src/components/shared/BaseMap.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
Minimize,
|
||||
Layers,
|
||||
Satellite,
|
||||
Grid3x3,
|
||||
Mountain,
|
||||
MapPin,
|
||||
Ruler,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
GISMapEngine,
|
||||
MapProvider,
|
||||
MapLayer,
|
||||
Marker,
|
||||
Polygon,
|
||||
MapPosition
|
||||
} from '@/lib/gisMapEngine';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BaseMapProps {
|
||||
provider?: MapProvider;
|
||||
initialCenter?: [number, number];
|
||||
initialZoom?: number;
|
||||
initialLayer?: MapLayer;
|
||||
height?: string;
|
||||
showControls?: boolean;
|
||||
showLayerSwitcher?: boolean;
|
||||
showLegend?: boolean;
|
||||
showScale?: boolean;
|
||||
showCoordinates?: boolean;
|
||||
onMapReady?: (mapEngine: GISMapEngine) => void;
|
||||
onLayerChange?: (layer: MapLayer) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface BaseMapRef {
|
||||
getMapEngine: () => GISMapEngine | null;
|
||||
addMarker: (marker: Marker) => void;
|
||||
addPolygon: (polygon: Polygon) => void;
|
||||
setCenter: (position: MapPosition, zoom?: number) => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
}
|
||||
|
||||
export const BaseMap = forwardRef<BaseMapRef, BaseMapProps>(({
|
||||
provider = 'leaflet',
|
||||
initialCenter = [116.4074, 39.9042],
|
||||
initialZoom = 13,
|
||||
initialLayer = 'satellite',
|
||||
height = '600px',
|
||||
showControls = true,
|
||||
showLayerSwitcher = true,
|
||||
showLegend = false,
|
||||
showScale = true,
|
||||
showCoordinates = true,
|
||||
onMapReady,
|
||||
onLayerChange,
|
||||
className = '',
|
||||
}, ref) => {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mapEngineRef = useRef<GISMapEngine | null>(null);
|
||||
|
||||
const [mapLayer, setMapLayer] = useState<MapLayer>(initialLayer);
|
||||
const [zoomLevel, setZoomLevel] = useState(initialZoom);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [coordinates, setCoordinates] = useState<MapPosition>({
|
||||
lng: initialCenter[0],
|
||||
lat: initialCenter[1],
|
||||
});
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 暴露给父组件的方法
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMapEngine: () => mapEngineRef.current,
|
||||
addMarker: (marker: Marker) => {
|
||||
mapEngineRef.current?.addMarker(marker);
|
||||
},
|
||||
addPolygon: (polygon: Polygon) => {
|
||||
mapEngineRef.current?.addPolygon(polygon);
|
||||
},
|
||||
setCenter: (position: MapPosition, zoom?: number) => {
|
||||
mapEngineRef.current?.setCenter(position, zoom);
|
||||
},
|
||||
setZoom: (zoom: number) => {
|
||||
mapEngineRef.current?.setZoom(zoom);
|
||||
setZoomLevel(zoom);
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
|
||||
const initMap = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 初始化地图引擎
|
||||
const mapEngine = new GISMapEngine({
|
||||
provider,
|
||||
container: mapContainerRef.current!,
|
||||
center: initialCenter,
|
||||
zoom: initialZoom,
|
||||
layer: initialLayer,
|
||||
});
|
||||
|
||||
mapEngineRef.current = mapEngine;
|
||||
|
||||
// 通知父组件地图已就绪
|
||||
if (onMapReady) {
|
||||
onMapReady(mapEngine);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('地图初始化失败:', error);
|
||||
} finally {
|
||||
// 延迟设置加载完成,给地图渲染一些时间
|
||||
setTimeout(() => setIsLoading(false), 500);
|
||||
}
|
||||
};
|
||||
|
||||
initMap();
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
if (mapEngineRef.current) {
|
||||
mapEngineRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLayerChange = (layer: string) => {
|
||||
const newLayer = layer as MapLayer;
|
||||
setMapLayer(newLayer);
|
||||
mapEngineRef.current?.setLayer(newLayer);
|
||||
toast.success(`已切换到${getLayerName(newLayer)}`);
|
||||
|
||||
if (onLayerChange) {
|
||||
onLayerChange(newLayer);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
const newZoom = Math.min(zoomLevel + 1, 18);
|
||||
setZoomLevel(newZoom);
|
||||
mapEngineRef.current?.setZoom(newZoom);
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
const newZoom = Math.max(zoomLevel - 1, 1);
|
||||
setZoomLevel(newZoom);
|
||||
mapEngineRef.current?.setZoom(newZoom);
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
const getLayerName = (layer: MapLayer): string => {
|
||||
const names: Record<MapLayer, string> = {
|
||||
satellite: '卫星影像',
|
||||
street: '电子地图',
|
||||
terrain: '地形图',
|
||||
hybrid: '混合图层',
|
||||
};
|
||||
return names[layer];
|
||||
};
|
||||
|
||||
const getLayerIcon = (layer: MapLayer) => {
|
||||
switch (layer) {
|
||||
case 'satellite': return <Satellite className="w-4 h-4" />;
|
||||
case 'street': return <Grid3x3 className="w-4 h-4" />;
|
||||
case 'terrain': return <Mountain className="w-4 h-4" />;
|
||||
case 'hybrid': return <Layers className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<Card className={`overflow-hidden ${isFullscreen ? 'fixed inset-0 z-50' : ''}`}>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="relative w-full"
|
||||
style={{ height: isFullscreen ? '100vh' : height }}
|
||||
>
|
||||
{/* 加载提示 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mb-4"></div>
|
||||
<p className="text-sm text-muted-foreground">正在加载地图...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="absolute top-4 left-4 right-4 z-10 flex items-center justify-between gap-2">
|
||||
{/* 图层指示器 */}
|
||||
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
{getLayerIcon(mapLayer)}
|
||||
<span className="text-sm">{getLayerName(mapLayer)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 右侧控制按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 图层切换 */}
|
||||
{showLayerSwitcher && (
|
||||
<Select value={mapLayer} onValueChange={handleLayerChange}>
|
||||
<SelectTrigger className="w-40 bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="satellite">
|
||||
<div className="flex items-center gap-2">
|
||||
<Satellite className="w-4 h-4" />
|
||||
卫星影像
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="street">
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="w-4 h-4" />
|
||||
电子地图
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="terrain">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" />
|
||||
地形图
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="hybrid">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
混合图层
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 测距工具 */}
|
||||
{showControls && (
|
||||
<Button
|
||||
variant={measuring ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setMeasuring(!measuring)}
|
||||
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||
>
|
||||
<Ruler className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 全屏切换 */}
|
||||
{showControls && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFullscreen}
|
||||
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 全屏模式关闭按钮 */}
|
||||
{isFullscreen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFullscreen}
|
||||
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缩放控制 */}
|
||||
{showControls && (
|
||||
<div className="absolute top-20 right-4 z-10">
|
||||
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoomLevel >= 18}
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="text-center text-xs text-muted-foreground py-1 px-2">
|
||||
{zoomLevel}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomLevel <= 1}
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 比例尺 */}
|
||||
{showScale && (
|
||||
<div className="absolute bottom-4 left-4 z-10">
|
||||
<Card className="p-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-0.5 bg-black dark:bg-white"></div>
|
||||
<span className="text-xs">
|
||||
{Math.round(500 / Math.pow(2, zoomLevel - 13))}m
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 坐标显示 */}
|
||||
{showCoordinates && (
|
||||
<div className="absolute bottom-4 right-4 z-10">
|
||||
<Card className="px-3 py-2 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{coordinates.lat.toFixed(4)}°N, {coordinates.lng.toFixed(4)}°E
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图例 */}
|
||||
{showLegend && (
|
||||
<div className="absolute top-20 left-4 z-10">
|
||||
<Card className="p-3 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<h4 className="text-sm mb-2">图例</h4>
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-green-500 bg-opacity-30 border-2 border-green-500 rounded"></div>
|
||||
<span>露地种植</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-500 bg-opacity-30 border-2 border-blue-500 rounded"></div>
|
||||
<span>大棚种植</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-orange-500 bg-opacity-30 border-2 border-orange-500 rounded"></div>
|
||||
<span>果园</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 测距提示 */}
|
||||
{measuring && (
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-10">
|
||||
<Card className="p-4 shadow-lg bg-white/95 dark:bg-gray-800/95 backdrop-blur">
|
||||
<div className="text-center">
|
||||
<Ruler className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
||||
<p className="text-sm">点击地图开始测距</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
单击添加节点,双击结束
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BaseMap.displayName = 'BaseMap';
|
||||
Reference in New Issue
Block a user