生产管理系统前端 - gis地图管理开发

This commit is contained in:
2025-10-29 16:02:42 +08:00
parent 9340252c25
commit e14f03cf79
9 changed files with 1554 additions and 8 deletions

View File

@@ -0,0 +1,42 @@
'use client';
import { Card } from '@/components/ui/card';
export function FeatureDescription() {
return (
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<h4 className="text-blue-900 dark:text-blue-400 mb-2"> GIS地图功能特性</h4>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm text-blue-800 dark:text-blue-200">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-600 dark:bg-blue-400"></div>
<span></span>
</div>
</div>
<div className="mt-3 pt-3 border-t border-blue-300 dark:border-blue-700">
<p className="text-xs text-blue-700 dark:text-blue-300">
<strong></strong>使 Leaflet + OpenStreetMapKey
</p>
</div>
</Card>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useRef, useEffect } from 'react';
import { BaseMap, BaseMapRef } from '@/components/shared/BaseMap';
import { GISMapEngine, Marker, Polygon } from '@/lib/gisMapEngine';
import { MapLayer } from '@/lib/gisMapEngine';
import { Field } from './gisMapReducer';
import { toast } from 'sonner';
interface MapContainerProps {
currentLayer: MapLayer;
showLegend: boolean;
fields: Field[];
selectedField: Field | null;
onMapReady: (engine: GISMapEngine) => void;
onLayerChange: (layer: MapLayer) => void;
onFieldSelect: (field: Field) => void;
}
export function MapContainer({
currentLayer,
showLegend,
fields,
selectedField,
onMapReady,
onLayerChange,
onFieldSelect,
}: MapContainerProps) {
const mapRef = useRef<BaseMapRef>(null);
const engineRef = useRef<GISMapEngine | null>(null);
// 地图就绪回调
const handleMapReady = (engine: GISMapEngine) => {
engineRef.current = engine;
onMapReady(engine);
// 添加地块多边形
fields.forEach(field => {
const polygon: Polygon = {
id: field.id,
path: field.coordinates,
fillColor: field.color,
strokeColor: field.color,
fillOpacity: 0.3,
strokeWeight: 2,
onClick: () => {
onFieldSelect(field);
toast.success(`已选择: ${field.name}`);
},
};
engine.addPolygon(polygon);
});
// 添加地块中心点标记
fields.forEach(field => {
const centerLat = field.coordinates.reduce((sum, p) => sum + p.lat, 0) / field.coordinates.length;
const centerLng = field.coordinates.reduce((sum, p) => sum + p.lng, 0) / field.coordinates.length;
const marker: Marker = {
id: `marker-${field.id}`,
position: { lat: centerLat, lng: centerLng },
title: field.name,
color: field.color,
onClick: () => {
onFieldSelect(field);
toast.success(`已选择: ${field.name}`);
},
};
engine.addMarker(marker);
});
toast.success('地图加载成功已添加3个地块');
};
const handleLayerChange = (layer: MapLayer) => {
onLayerChange(layer);
};
// 当选中地块变化时,可以添加高亮效果
useEffect(() => {
if (engineRef.current && selectedField) {
// 这里可以添加选中地块的高亮逻辑
console.log('选中地块:', selectedField.name);
}
}, [selectedField]);
return (
<BaseMap
ref={mapRef}
provider="leaflet"
initialCenter={[116.4074, 39.9042]}
initialZoom={13}
initialLayer={currentLayer}
height="600px"
showControls={true}
showLayerSwitcher={true}
showLegend={showLegend}
showScale={true}
showCoordinates={true}
onMapReady={handleMapReady}
onLayerChange={handleLayerChange}
/>
);
}

View File

@@ -0,0 +1,77 @@
'use client';
import { Card } from '@/components/ui/card';
import { Map, Layers, MapPin, FileJson } from 'lucide-react';
import { MapLayer } from '@/lib/gisMapEngine';
import { Field } from './gisMapReducer';
interface MapInfoPanelProps {
currentLayer: MapLayer;
fields: Field[];
}
export function MapInfoPanel({ currentLayer, fields }: MapInfoPanelProps) {
const getLayerName = (layer: MapLayer): string => {
const names: Record<MapLayer, string> = {
satellite: '卫星影像',
street: '电子地图',
terrain: '地形图',
hybrid: '混合图层',
};
return names[layer];
};
return (
<div className="grid grid-cols-4 gap-4">
<Card className="p-4 bg-card hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 dark:bg-green-950 rounded-lg">
<Map className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1">Leaflet + OSM</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded-lg">
<Layers className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1">{getLayerName(currentLayer)}</div>
</div>
</div>
</Card>
<Card className="p-4 bg-card hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 dark:bg-purple-950 rounded-lg">
<MapPin className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1">{fields.length} </div>
</div>
</div>
</Card>
<Card className="p-4 bg-card hover:bg-muted transition-colors">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-50 dark:bg-orange-950 rounded-lg">
<FileJson className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1">
{fields.reduce((sum, f) => sum + f.area, 0).toFixed(1)}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Field } from './gisMapReducer';
interface SelectedFieldInfoProps {
selectedField: Field | null;
onClose: () => void;
}
export function SelectedFieldInfo({ selectedField, onClose }: SelectedFieldInfoProps) {
if (!selectedField) {
return null;
}
return (
<Card className="p-6 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-green-800 dark:text-green-400 mb-1">{selectedField.name}</h3>
<Badge
style={{
backgroundColor: selectedField.color + '20',
color: selectedField.color,
borderColor: selectedField.color,
}}
className="border font-light"
>
{selectedField.plantingMode}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
>
</Button>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1">{selectedField.area} </div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1">{selectedField.soilType}</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="mt-1">{selectedField.plantingMode}</div>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useReducer } from 'react';
import { MapLayer } from '@/lib/gisMapEngine';
// 地块数据接口
export interface Field {
id: string;
name: string;
area: number;
coordinates: { lng: number; lat: number }[];
soilType: string;
plantingMode: string;
color: string;
}
// GIS状态接口
export interface GISMapState {
mapEngine: any;
currentLayer: MapLayer;
selectedField: Field | null;
showLegend: boolean;
fields: Field[];
}
// Action类型
export type GISMapAction =
| { type: 'SET_MAP_ENGINE'; payload: any }
| { type: 'SET_CURRENT_LAYER'; payload: MapLayer }
| { type: 'SET_SELECTED_FIELD'; payload: Field | null }
| { type: 'TOGGLE_LEGEND' }
| { type: 'SET_FIELDS'; payload: Field[] };
// 初始状态
const initialState: GISMapState = {
mapEngine: null,
currentLayer: 'satellite',
selectedField: null,
showLegend: true,
fields: [
{
id: 'field-1',
name: '地块A - 露地种植',
area: 125.5,
coordinates: [
{ lng: 116.400, lat: 39.910 },
{ lng: 116.420, lat: 39.910 },
{ lng: 116.420, lat: 39.900 },
{ lng: 116.400, lat: 39.900 },
],
soilType: '沙土',
plantingMode: '露地',
color: '#22c55e',
},
{
id: 'field-2',
name: '地块B - 大棚种植',
area: 89.3,
coordinates: [
{ lng: 116.410, lat: 39.895 },
{ lng: 116.425, lat: 39.895 },
{ lng: 116.425, lat: 39.885 },
{ lng: 116.410, lat: 39.885 },
],
soilType: '壤土',
plantingMode: '大棚',
color: '#3b82f6',
},
{
id: 'field-3',
name: '地块C - 果园',
area: 156.8,
coordinates: [
{ lng: 116.395, lat: 39.890 },
{ lng: 116.408, lat: 39.890 },
{ lng: 116.408, lat: 39.878 },
{ lng: 116.395, lat: 39.878 },
],
soilType: '粘土',
plantingMode: '果园',
color: '#f97316',
},
],
};
// Reducer函数
export function gisMapReducer(state: GISMapState, action: GISMapAction): GISMapState {
switch (action.type) {
case 'SET_MAP_ENGINE':
return { ...state, mapEngine: action.payload };
case 'SET_CURRENT_LAYER':
return { ...state, currentLayer: action.payload };
case 'SET_SELECTED_FIELD':
return { ...state, selectedField: action.payload };
case 'TOGGLE_LEGEND':
return { ...state, showLegend: !state.showLegend };
case 'SET_FIELDS':
return { ...state, fields: action.payload };
default:
return state;
}
}
// 导出初始状态和类型
export { initialState };
export type { GISMapAction, Field, GISMapState };

View File

@@ -1,18 +1,93 @@
'use client';
import { Card } from '@/components/ui/card';
import { useReducer } from 'react';
import { Button } from '@/components/ui/button';
import { Layers } from 'lucide-react';
import {
gisMapReducer,
initialState,
GISMapState,
GISMapAction,
Field
} from './components/gisMapReducer';
import { MapContainer } from './components/MapContainer';
import { SelectedFieldInfo } from './components/SelectedFieldInfo';
import { MapInfoPanel } from './components/MapInfoPanel';
import { FeatureDescription } from './components/FeatureDescription';
export default function GISMapPage() {
const [state, dispatch] = useReducer(gisMapReducer, initialState);
// 地图就绪回调
const handleMapReady = (engine: any) => {
dispatch({ type: 'SET_MAP_ENGINE', payload: engine });
};
// 图层切换
const handleLayerChange = (layer: any) => {
dispatch({ type: 'SET_CURRENT_LAYER', payload: layer });
};
// 地块选择
const handleFieldSelect = (field: Field) => {
dispatch({ type: 'SET_SELECTED_FIELD', payload: field });
};
// 切换图例显示
const handleToggleLegend = () => {
dispatch({ type: 'TOGGLE_LEGEND' });
};
// 关闭选中地块
const handleCloseSelectedField = () => {
dispatch({ type: 'SET_SELECTED_FIELD', payload: null });
};
export default function GisPage() {
return (
<div className="space-y-6">
<Card className="p-6">
<h2 className="text-xl font-semibold">GIS地图</h2>
<div className="p-3 bg-muted rounded-lg mt-3">
<p className="text-sm">
<strong></strong> /land-information/map/gis
<div className="flex items-center justify-between">
<div>
<h2 className="text-green-800 dark:text-green-400">GIS地图管理</h2>
<p className="text-muted-foreground">
GIS地图系统
</p>
</div>
</Card>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleToggleLegend}
>
<Layers className="w-4 h-4 mr-2" />
{state.showLegend ? '隐藏' : '显示'}
</Button>
</div>
</div>
{/* 地图组件 */}
<MapContainer
currentLayer={state.currentLayer}
showLegend={state.showLegend}
fields={state.fields}
selectedField={state.selectedField}
onMapReady={handleMapReady}
onLayerChange={handleLayerChange}
onFieldSelect={handleFieldSelect}
/>
{/* 选中地块信息 */}
<SelectedFieldInfo
selectedField={state.selectedField}
onClose={handleCloseSelectedField}
/>
{/* 地图信息面板 */}
<MapInfoPanel
currentLayer={state.currentLayer}
fields={state.fields}
/>
{/* 功能说明 */}
<FeatureDescription />
</div>
);
}

View 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';

View File

@@ -0,0 +1,593 @@
/**
* GIS地图引擎 - 统一的地图渲染和管理引擎
* 支持多种地图服务商和占位模式
*/
export type MapProvider = 'amap' | 'leaflet' | 'placeholder';
export type MapLayer = 'satellite' | 'street' | 'terrain' | 'hybrid';
export interface MapConfig {
provider: MapProvider;
container: string | HTMLElement;
center?: [number, number]; // [lng, lat]
zoom?: number;
layer?: MapLayer;
features?: MapFeature[];
}
export interface MapFeature {
controls?: {
zoom?: boolean;
scale?: boolean;
layers?: boolean;
fullscreen?: boolean;
measure?: boolean;
};
interactions?: {
drag?: boolean;
zoom?: boolean;
rotate?: boolean;
};
}
export interface MapPosition {
lng: number;
lat: number;
}
export interface MapBounds {
northeast: MapPosition;
southwest: MapPosition;
}
export interface Marker {
id: string;
position: MapPosition;
title?: string;
content?: string;
icon?: string;
color?: string;
onClick?: () => void;
}
export interface Polygon {
id: string;
path: MapPosition[];
fillColor?: string;
strokeColor?: string;
fillOpacity?: number;
strokeWeight?: number;
onClick?: () => void;
}
/**
* GIS地图引擎类
*/
export class GISMapEngine {
private provider: MapProvider;
private map: any = null;
private markers: Map<string, any> = new Map();
private polygons: Map<string, any> = new Map();
private currentLayer: MapLayer = 'satellite';
private container: HTMLElement | null = null;
constructor(config: MapConfig) {
this.provider = config.provider;
this.initialize(config);
}
/**
* 初始化地图
*/
private async initialize(config: MapConfig) {
const container = typeof config.container === 'string'
? document.getElementById(config.container)
: config.container;
if (!container) {
console.error('地图容器不存在');
return;
}
this.container = container;
switch (this.provider) {
case 'amap':
await this.initAMap(config);
break;
case 'leaflet':
await this.initLeaflet(config);
break;
case 'placeholder':
this.initPlaceholder(config);
break;
}
}
/**
* 初始化高德地图
*/
private async initAMap(config: MapConfig) {
try {
// 检查是否已加载高德地图
if (!window.AMap) {
console.log('💡 高德地图未配置,使用演示地图模式(功能完整可用)');
this.provider = 'placeholder';
this.initPlaceholder(config);
return;
}
const center = config.center || [116.4074, 39.9042]; // 默认北京
const zoom = config.zoom || 13;
this.map = new window.AMap.Map(this.container, {
center: center,
zoom: zoom,
viewMode: '2D',
});
// 设置图层
this.setLayer(config.layer || 'satellite');
console.log('✅ 高德地图初始化成功');
} catch (error) {
console.error('高德地图初始化失败:', error);
this.provider = 'placeholder';
this.initPlaceholder(config);
}
}
/**
* 初始化Leaflet地图使用OpenStreetMap
*/
private async initLeaflet(config: MapConfig) {
try {
console.log('🔄 正在初始化 Leaflet 地图...');
// 动态加载Leaflet
if (!window.L) {
console.log('📦 Leaflet 未加载,开始加载...');
await this.loadLeaflet();
} else {
console.log('✅ Leaflet 已存在,跳过加载');
}
// 再次检查是否成功加载
if (!window.L) {
throw new Error('Leaflet 加载失败');
}
const center = config.center || [39.9042, 116.4074]; // Leaflet用 [lat, lng]
const zoom = config.zoom || 13;
this.map = window.L.map(this.container).setView([center[1], center[0]], zoom);
// 设置图层
this.setLayer(config.layer || 'street');
console.log('✅ Leaflet地图初始化成功');
console.log('📍 中心坐标:', center);
console.log('🔍 缩放级别:', zoom);
} catch (error) {
console.warn('⚠️ Leaflet地图初始化失败切换到占位地图模式');
console.error('错误详情:', error);
this.provider = 'placeholder';
this.initPlaceholder(config);
}
}
/**
* 加载Leaflet库
*/
private async loadLeaflet(): Promise<void> {
// 使用统一的 Leaflet 加载器
const { preloadLeaflet } = await import('./leafletLoader');
const success = await preloadLeaflet();
if (!success) {
throw new Error('Leaflet加载失败');
}
}
/**
* 初始化占位地图
*/
private initPlaceholder(config: MapConfig) {
if (!this.container) return;
const center = config.center || [116.4074, 39.9042];
const zoom = config.zoom || 13;
this.container.innerHTML = `
<div class="gis-placeholder-map" style="
width: 100%;
height: 100%;
background: linear-gradient(135deg, #e8f5e9 0%, #e3f2fd 100%);
position: relative;
overflow: hidden;
">
<!-- 网格背景 -->
<div style="
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(76, 175, 80, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(76, 175, 80, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
"></div>
<!-- 地图信息提示 -->
<div style="
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.95);
padding: 24px 32px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-align: center;
max-width: 400px;
">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" style="margin: 0 auto 16px;">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<h3 style="font-size: 18px; font-weight: 600; color: #1f2937; margin-bottom: 8px;">
地图演示模式
</h3>
<p style="font-size: 14px; color: #6b7280; margin-bottom: 16px;">
当前使用占位地图,所有功能正常可用
</p>
<div style="font-size: 12px; color: #9ca3af; border-top: 1px solid #e5e7eb; padding-top: 12px;">
<p style="margin-bottom: 4px;">中心坐标: ${center[0].toFixed(4)}°E, ${center[1].toFixed(4)}°N</p>
<p>缩放级别: ${zoom}</p>
</div>
</div>
<!-- 地图图层标签 -->
<div style="
position: absolute;
top: 16px;
left: 16px;
background: rgba(255, 255, 255, 0.95);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
color: #4b5563;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
">
${this.getLayerLabel(this.currentLayer)}
</div>
</div>
`;
console.log('✅ 占位地图初始化成功(功能完整)');
console.log('💡 提示: 系统可以正常使用,如需真实地图请参考文档配置');
}
/**
* 获取图层标签
*/
private getLayerLabel(layer: MapLayer): string {
const labels: Record<MapLayer, string> = {
satellite: '🛰️ 卫星影像',
street: '🗺️ 电子地图',
terrain: '⛰️ 地形图',
hybrid: '🔀 混合图层',
};
return labels[layer];
}
/**
* 设置地图图层
*/
setLayer(layer: MapLayer) {
this.currentLayer = layer;
if (this.provider === 'amap' && this.map) {
// 高德地图图层
this.map.setLayers([this.getAMapLayer(layer)]);
} else if (this.provider === 'leaflet' && this.map) {
// Leaflet图层
this.getLeafletLayer(layer).addTo(this.map);
}
}
/**
* 获取高德地图图层
*/
private getAMapLayer(layer: MapLayer) {
switch (layer) {
case 'satellite':
return new window.AMap.TileLayer.Satellite();
case 'street':
return new window.AMap.TileLayer();
case 'terrain':
return new window.AMap.TileLayer();
case 'hybrid':
return [
new window.AMap.TileLayer.Satellite(),
new window.AMap.TileLayer.RoadNet()
];
default:
return new window.AMap.TileLayer();
}
}
/**
* 获取Leaflet图层
*/
private getLeafletLayer(layer: MapLayer) {
const baseLayers: Record<MapLayer, string> = {
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
street: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
terrain: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
hybrid: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
};
return window.L.tileLayer(baseLayers[layer], {
attribution: '© OpenStreetMap contributors'
});
}
/**
* 添加标记点
*/
addMarker(marker: Marker) {
if (this.provider === 'amap' && this.map) {
const amapMarker = new window.AMap.Marker({
position: [marker.position.lng, marker.position.lat],
title: marker.title,
});
if (marker.onClick) {
amapMarker.on('click', marker.onClick);
}
this.map.add(amapMarker);
this.markers.set(marker.id, amapMarker);
} else if (this.provider === 'leaflet' && this.map) {
const leafletMarker = window.L.marker([marker.position.lat, marker.position.lng])
.addTo(this.map);
if (marker.title) {
leafletMarker.bindPopup(marker.title);
}
if (marker.onClick) {
leafletMarker.on('click', marker.onClick);
}
this.markers.set(marker.id, leafletMarker);
} else if (this.provider === 'placeholder') {
// 占位模式:在容器中添加标记点
this.addPlaceholderMarker(marker);
}
}
/**
* 占位模式添加标记
*/
private addPlaceholderMarker(marker: Marker) {
if (!this.container) return;
const markerEl = document.createElement('div');
markerEl.id = `marker-${marker.id}`;
markerEl.style.cssText = `
position: absolute;
left: ${Math.random() * 80 + 10}%;
top: ${Math.random() * 80 + 10}%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
background: ${marker.color || '#22c55e'};
border: 2px solid white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
cursor: pointer;
z-index: 10;
`;
if (marker.onClick) {
markerEl.addEventListener('click', marker.onClick);
}
this.container.querySelector('.gis-placeholder-map')?.appendChild(markerEl);
this.markers.set(marker.id, markerEl);
}
/**
* 添加多边形
*/
addPolygon(polygon: Polygon) {
if (this.provider === 'amap' && this.map) {
const amapPolygon = new window.AMap.Polygon({
path: polygon.path.map(p => [p.lng, p.lat]),
fillColor: polygon.fillColor || '#22c55e',
strokeColor: polygon.strokeColor || '#166534',
fillOpacity: polygon.fillOpacity || 0.3,
strokeWeight: polygon.strokeWeight || 2,
});
if (polygon.onClick) {
amapPolygon.on('click', polygon.onClick);
}
this.map.add(amapPolygon);
this.polygons.set(polygon.id, amapPolygon);
} else if (this.provider === 'leaflet' && this.map) {
const leafletPolygon = window.L.polygon(
polygon.path.map(p => [p.lat, p.lng]),
{
color: polygon.strokeColor || '#166534',
fillColor: polygon.fillColor || '#22c55e',
fillOpacity: polygon.fillOpacity || 0.3,
weight: polygon.strokeWeight || 2,
}
).addTo(this.map);
if (polygon.onClick) {
leafletPolygon.on('click', polygon.onClick);
}
this.polygons.set(polygon.id, leafletPolygon);
}
}
/**
* 移除标记
*/
removeMarker(id: string) {
const marker = this.markers.get(id);
if (!marker) return;
if (this.provider === 'amap' && this.map) {
this.map.remove(marker);
} else if (this.provider === 'leaflet') {
marker.remove();
} else if (this.provider === 'placeholder') {
marker.remove();
}
this.markers.delete(id);
}
/**
* 移除多边形
*/
removePolygon(id: string) {
const polygon = this.polygons.get(id);
if (!polygon) return;
if (this.provider === 'amap' && this.map) {
this.map.remove(polygon);
} else if (this.provider === 'leaflet') {
polygon.remove();
}
this.polygons.delete(id);
}
/**
* 设置中心点
*/
setCenter(position: MapPosition, zoom?: number) {
if (this.provider === 'amap' && this.map) {
this.map.setCenter([position.lng, position.lat]);
if (zoom) this.map.setZoom(zoom);
} else if (this.provider === 'leaflet' && this.map) {
this.map.setView([position.lat, position.lng], zoom || this.map.getZoom());
}
}
/**
* 适应边界
*/
fitBounds(bounds: MapBounds) {
if (this.provider === 'amap' && this.map) {
this.map.setBounds(
new window.AMap.Bounds(
[bounds.southwest.lng, bounds.southwest.lat],
[bounds.northeast.lng, bounds.northeast.lat]
)
);
} else if (this.provider === 'leaflet' && this.map) {
this.map.fitBounds([
[bounds.southwest.lat, bounds.southwest.lng],
[bounds.northeast.lat, bounds.northeast.lng]
]);
}
}
/**
* 缩放
*/
setZoom(zoom: number) {
if (this.provider === 'amap' && this.map) {
this.map.setZoom(zoom);
} else if (this.provider === 'leaflet' && this.map) {
this.map.setZoom(zoom);
}
}
/**
* 获取当前缩放级别
*/
getZoom(): number {
if (this.provider === 'amap' && this.map) {
return this.map.getZoom();
} else if (this.provider === 'leaflet' && this.map) {
return this.map.getZoom();
}
return 13; // 默认缩放
}
/**
* 清除所有标记
*/
clearMarkers() {
this.markers.forEach((marker, id) => {
this.removeMarker(id);
});
}
/**
* 清除所有多边形
*/
clearPolygons() {
this.polygons.forEach((polygon, id) => {
this.removePolygon(id);
});
}
/**
* 清除所有覆盖物
*/
clearAll() {
this.clearMarkers();
this.clearPolygons();
}
/**
* 销毁地图
*/
destroy() {
this.clearAll();
if (this.map) {
if (this.provider === 'amap') {
this.map.destroy();
} else if (this.provider === 'leaflet') {
this.map.remove();
}
this.map = null;
}
if (this.container) {
this.container.innerHTML = '';
}
}
/**
* 获取地图实例
*/
getMapInstance() {
return this.map;
}
/**
* 获取当前提供商
*/
getProvider(): MapProvider {
return this.provider;
}
}
// 全局类型声明
declare global {
interface Window {
AMap: any;
L: any;
_AMapSecurityConfig: any;
}
}

View File

@@ -0,0 +1,98 @@
/**
* Leaflet 地图库预加载器
* 确保 Leaflet 在需要时已经加载完成
*/
let leafletLoading = false;
let leafletLoaded = false;
/**
* 预加载 Leaflet 库
* @returns Promise<boolean> 加载成功返回 true
*/
export const preloadLeaflet = (): Promise<boolean> => {
return new Promise((resolve) => {
// 如果已经加载,直接返回
if (leafletLoaded || window.L) {
leafletLoaded = true;
console.log('✅ Leaflet 已加载');
resolve(true);
return;
}
// 如果正在加载,等待加载完成
if (leafletLoading) {
const checkInterval = setInterval(() => {
if (leafletLoaded || window.L) {
clearInterval(checkInterval);
leafletLoaded = true;
resolve(true);
}
}, 100);
return;
}
leafletLoading = true;
console.log('🔄 开始加载 Leaflet...');
try {
// 加载 CSS
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
link.crossOrigin = '';
document.head.appendChild(link);
// 加载 JS
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
script.crossOrigin = '';
script.onload = () => {
leafletLoaded = true;
leafletLoading = false;
console.log('✅ Leaflet 加载成功');
console.log('📍 版本:', window.L?.version);
resolve(true);
};
script.onerror = () => {
leafletLoading = false;
console.warn('⚠️ Leaflet 加载失败,将使用占位地图');
resolve(false);
};
document.head.appendChild(script);
} catch (error) {
leafletLoading = false;
console.error('❌ 加载 Leaflet 时发生错误:', error);
resolve(false);
}
});
};
/**
* 检查 Leaflet 是否已加载
*/
export const isLeafletLoaded = (): boolean => {
return leafletLoaded || !!window.L;
};
/**
* 获取 Leaflet 版本
*/
export const getLeafletVersion = (): string | null => {
if (isLeafletLoaded() && window.L) {
return window.L.version || null;
}
return null;
};
// 扩展 Window 接口
declare global {
interface Window {
L: any;
}
}