生产管理系统前端 - gis地图管理开发
This commit is contained in:
@@ -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 + OpenStreetMap(开源免费),也支持切换到高德地图(需配置Key)
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
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';
|
||||
593
crop-x/src/lib/gisMapEngine.ts
Normal file
593
crop-x/src/lib/gisMapEngine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
98
crop-x/src/lib/leafletLoader.ts
Normal file
98
crop-x/src/lib/leafletLoader.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user