生产管理系统前端 - 数字化绘制与编辑
This commit is contained in:
@@ -424,4 +424,343 @@ const handleToggleArrayFilter = (key: 'soilTypes' | 'plantingModes' | 'tags', va
|
|||||||
|
|
||||||
5. **文档化习惯**:将开发过程中的经验和教训记录下来,形成知识积累
|
5. **文档化习惯**:将开发过程中的经验和教训记录下来,形成知识积累
|
||||||
- 认识到文档化对团队协作和知识传承的重要性
|
- 认识到文档化对团队协作和知识传承的重要性
|
||||||
- 建立了完整的开发规范文档体系
|
- 建立了完整的开发规范文档体系
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## path:land-information/map/gis,name:GIS地图管理开发经验与问题解决
|
||||||
|
|
||||||
|
### 总体开发经验总结
|
||||||
|
|
||||||
|
GIS地图管理页面的开发过程是一个复杂的技术集成挑战,涉及到第三方地图库的集成、异步资源加载、多层级组件交互等多个技术难点。通过这次开发,我们建立了一套完整的GIS应用开发模式,特别是在处理真实地图数据源和优雅降级方面积累了宝贵经验。
|
||||||
|
|
||||||
|
### 问题1:地图组件初始化时缺少真实地图数据源
|
||||||
|
|
||||||
|
**问题描述:**
|
||||||
|
- 初始实现的BaseMap组件只是简单的模拟展示,无法加载真实的卫星图像
|
||||||
|
- 用户反馈参考文件可以看到真实的卫星图,但当前页面只显示占位符
|
||||||
|
- 缺乏对真实地图服务商的集成支持
|
||||||
|
|
||||||
|
**原始需求分析:**
|
||||||
|
- 需要支持真实的卫星影像显示,而不是简单的占位地图
|
||||||
|
- 必须支持多种地图图层切换(卫星、电子、地形、混合)
|
||||||
|
- 需要完整的地图交互功能,包括缩放、平移、全屏等
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 复制完整的GISMapEngine实现,支持多种地图提供商
|
||||||
|
- 实现leafletLoader动态加载器,支持异步加载地图库
|
||||||
|
- 建立真实地图瓦片数据源连接,包括ArcGIS卫星影像和OpenStreetMap
|
||||||
|
|
||||||
|
**代码实现对比:**
|
||||||
|
```tsx
|
||||||
|
// ❌ 初始简化实现
|
||||||
|
export class GISMapEngine {
|
||||||
|
constructor(map: any) {
|
||||||
|
this.map = map; // 只是一个模拟对象
|
||||||
|
}
|
||||||
|
addPolygon(polygon: MapPolygon): void {
|
||||||
|
this.polygons.set(polygon.id, polygon); // 没有真实渲染
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 最终完整实现
|
||||||
|
export class GISMapEngine {
|
||||||
|
constructor(config: MapConfig) {
|
||||||
|
this.provider = config.provider;
|
||||||
|
this.initialize(config); // 真实初始化流程
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initLeaflet(config: MapConfig) {
|
||||||
|
// 动态加载Leaflet库
|
||||||
|
if (!window.L) {
|
||||||
|
await this.loadLeaflet();
|
||||||
|
}
|
||||||
|
// 创建真实地图实例
|
||||||
|
this.map = window.L.map(this.container).setView([center[1], center[0]], zoom);
|
||||||
|
// 设置真实瓦片图层
|
||||||
|
this.getLeafletLayer(layer).addTo(this.map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题2:地图库异步加载和依赖管理复杂性
|
||||||
|
|
||||||
|
**问题描述:**
|
||||||
|
- 地图库(Leaflet)需要从CDN异步加载,存在加载失败风险
|
||||||
|
- 地图组件需要在库加载完成后才能初始化,存在时序问题
|
||||||
|
- 多个地图组件可能重复加载同一资源,造成性能浪费
|
||||||
|
|
||||||
|
**原始需求分析:**
|
||||||
|
- 确保地图库能够可靠加载,提供良好的用户体验
|
||||||
|
- 处理加载失败的情况,提供优雅的降级方案
|
||||||
|
- 优化资源加载性能,避免重复加载
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 创建leafletLoader统一管理地图库的加载过程
|
||||||
|
- 实现加载状态管理和重试机制
|
||||||
|
- 建立全局加载状态缓存,避免重复加载
|
||||||
|
|
||||||
|
**关键实现代码:**
|
||||||
|
```tsx
|
||||||
|
// leafletLoader.ts - 统一加载管理
|
||||||
|
export const preloadLeaflet = (): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (leafletLoaded || window.L) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leafletLoading) {
|
||||||
|
// 等待正在进行的加载完成
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
if (leafletLoaded || window.L) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行实际加载过程
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||||
|
script.onload = () => { leafletLoaded = true; resolve(true); };
|
||||||
|
script.onerror = () => { resolve(false); };
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题3:地图组件与状态管理的深度集成
|
||||||
|
|
||||||
|
**问题描述:**
|
||||||
|
- 地图组件需要与useReducer状态管理深度集成
|
||||||
|
- 地图的异步初始化过程与React生命周期存在冲突
|
||||||
|
- 地图事件回调与状态更新的时序同步问题
|
||||||
|
|
||||||
|
**原始需求分析:**
|
||||||
|
- 地图操作需要能够更新全局状态(如选中地块、图层切换)
|
||||||
|
- 状态变化需要能够反映到地图显示上
|
||||||
|
- 需要处理地图组件的清理和资源释放
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 使用useImperativeHandle暴露地图实例方法给父组件
|
||||||
|
- 实现地图引擎的引用管理,确保状态同步
|
||||||
|
- 建立完整的生命周期管理,包括组件卸载时的资源清理
|
||||||
|
|
||||||
|
**状态管理集成代码:**
|
||||||
|
```tsx
|
||||||
|
// BaseMap组件中的状态集成
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 组件卸载时的资源清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (mapEngineRef.current) {
|
||||||
|
mapEngineRef.current.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题4:真实地图数据源的集成和配置
|
||||||
|
|
||||||
|
**问题描述:**
|
||||||
|
- 需要集成多种真实的地图瓦片数据源
|
||||||
|
- 不同地图服务商的API格式和坐标系统存在差异
|
||||||
|
- 需要处理地图瓦片的加载性能和缓存策略
|
||||||
|
|
||||||
|
**原始需求分析:**
|
||||||
|
- 提供真实的卫星影像、电子地图、地形图等多种图层
|
||||||
|
- 确保地图数据的准确性和时效性
|
||||||
|
- 优化地图加载性能,提供流畅的用户体验
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 集成多个开源地图数据源,确保服务的可靠性
|
||||||
|
- 统一不同数据源的坐标系统和API格式
|
||||||
|
- 实现智能的图层切换和缓存机制
|
||||||
|
|
||||||
|
**地图数据源配置:**
|
||||||
|
```tsx
|
||||||
|
private getLeafletLayer(layer: MapLayer) {
|
||||||
|
const baseLayers: Record<MapLayer, string> = {
|
||||||
|
// ArcGIS卫星影像 - 真实卫星图
|
||||||
|
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
// OpenStreetMap - 开源电子地图
|
||||||
|
street: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
// OpenTopoMap - 开源地形图
|
||||||
|
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',
|
||||||
|
maxZoom: 18,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题5:地图交互功能的完整实现
|
||||||
|
|
||||||
|
**问题描述:**
|
||||||
|
- 需要实现地块多边形的渲染和交互
|
||||||
|
- 地图标记点的添加和点击事件处理
|
||||||
|
- 地图控件(缩放、图层切换、全屏等)的集成
|
||||||
|
|
||||||
|
**原始需求分析:**
|
||||||
|
- 地块需要在地图上以彩色多边形形式显示
|
||||||
|
- 点击地块需要触发选择事件和状态更新
|
||||||
|
- 提供完整的地图导航和操作功能
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
- 使用地图引擎的Polygon和Marker API实现地块渲染
|
||||||
|
- 建立事件处理机制,连接地图交互和状态管理
|
||||||
|
- 集成完整的地图控件套件,提供专业级用户体验
|
||||||
|
|
||||||
|
**交互功能实现:**
|
||||||
|
```tsx
|
||||||
|
// 地块多边形渲染
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 地块标记点渲染
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发经验对比总结
|
||||||
|
|
||||||
|
### 与原始要求的差异分析
|
||||||
|
|
||||||
|
| 原始要求 | 实际实现 | 差异说明 | 解决过程 |
|
||||||
|
|---------|---------|---------|---------|
|
||||||
|
| 1:1还原地图功能 | 完整实现 + 真实数据源 | 需要集成真实地图服务商和瓦片数据 | 建立完整的地图引擎架构,支持多种数据源 |
|
||||||
|
| 第三方库集成 | 专业级集成 | 需要处理异步加载、错误处理、性能优化 | 实现统一加载器和优雅降级机制 |
|
||||||
|
| 组件状态管理 | 深度集成 + 生命周期管理 | 地图组件与React状态系统需要深度集成 | 使用useImperativeHandle和引用管理 |
|
||||||
|
| 交互功能实现 | 完整交互套件 | 需要实现多边形、标记、控件等完整功能 | 集成地图引擎API,建立事件处理机制 |
|
||||||
|
|
||||||
|
### 关键学习点和改进
|
||||||
|
|
||||||
|
1. **第三方库集成思维**:学会了如何可靠地集成和管理复杂的第三方JavaScript库
|
||||||
|
- 掌握了异步加载、错误处理、优雅降级的完整流程
|
||||||
|
- 理解了库版本管理和兼容性处理的重要性
|
||||||
|
|
||||||
|
2. **地图API应用经验**:深入了解了Web地图开发的技术栈和最佳实践
|
||||||
|
- 学会了瓦片地图的原理和多种数据源的使用
|
||||||
|
- 掌握了地图交互事件的处理和状态同步机制
|
||||||
|
|
||||||
|
3. **React高级模式应用**:在复杂组件中应用了useImperativeHandle、useRef等高级React模式
|
||||||
|
- 深入理解了React组件的暴露方法和引用传递机制
|
||||||
|
- 掌握了复杂组件生命周期管理的最佳实践
|
||||||
|
|
||||||
|
4. **性能优化意识**:建立了地图应用的性能优化思维
|
||||||
|
- 学会了资源懒加载和缓存策略的设计
|
||||||
|
- 理解了大型第三方库对应用性能的影响和优化方法
|
||||||
|
|
||||||
|
5. **用户体验设计**:在技术实现中始终考虑用户体验
|
||||||
|
- 建立了加载状态和错误处理的设计模式
|
||||||
|
- 掌握了优雅降级和渐进增强的实现方法
|
||||||
|
|
||||||
|
6. **架构设计能力**:设计了可扩展的地图应用架构
|
||||||
|
- 建立了插件化的地图引擎设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## path:src/app/(app)/land-information/map/draw,name:数字化绘制与编辑页面开发经验
|
||||||
|
|
||||||
|
### 1. **复杂状态管理设计**
|
||||||
|
- **useReducer 模式应用**:使用 useReducer 管理复杂的编辑状态,包含多个布尔状态、数组和对象
|
||||||
|
- **状态结构设计**:设计了包含高级编辑器状态、活动标签页、地块数据、保存对话框等的状态结构
|
||||||
|
- **Action 设计模式**:采用类型安全的 Action 设计,支持状态更新、字段管理、对话框控制等操作
|
||||||
|
|
||||||
|
### 2. **组件化架构设计**
|
||||||
|
- **模块化组件结构**:将复杂功能拆分为6个独立组件,每个组件负责单一职责
|
||||||
|
- `drawEditReducer.tsx`:状态管理核心
|
||||||
|
- `DrawingTools.tsx`:绘制工具组件
|
||||||
|
- `EditingTools.tsx`:编辑工具组件
|
||||||
|
- `FieldEntryDialog.tsx`:地块信息录入对话框
|
||||||
|
- `UsageGuide.tsx`:使用指南组件
|
||||||
|
- `AdvancedEditorPromo.tsx`:高级编辑器推广组件
|
||||||
|
- **组件通信设计**:通过 props 和回调函数实现组件间的数据传递和事件处理
|
||||||
|
|
||||||
|
### 3. **Canvas 绘图技术实现**
|
||||||
|
- **多种绘制模式**:实现点、线、多边形、矩形等多种绘制模式
|
||||||
|
- **实时交互反馈**:支持鼠标移动吸附、节点高亮、实时预览等功能
|
||||||
|
- **几何计算算法**:
|
||||||
|
- Shoelace 公式计算多边形面积
|
||||||
|
- 坐标距离计算周长
|
||||||
|
- 点在多边形内判断算法
|
||||||
|
- 自相交检测算法
|
||||||
|
|
||||||
|
### 4. **高级编辑功能实现**
|
||||||
|
- **节点编辑**:支持拖拽节点、添加节点、删除节点
|
||||||
|
- **地块分割**:绘制分割线将地块分成两部分,支持垂直和水平分割
|
||||||
|
- **地块合并**:多地块选择和凸包算法合并
|
||||||
|
- **历史记录管理**:实现撤销/重做功能,支持操作历史追踪
|
||||||
|
|
||||||
|
### 5. **用户体验设计**
|
||||||
|
- **分步骤操作引导**:为复杂操作提供详细的操作步骤说明
|
||||||
|
- **实时状态反馈**:Toast 通知、状态栏显示、操作确认等
|
||||||
|
- **键盘快捷键支持**:Ctrl+Z 撤销、Ctrl+S 保存、Delete 清除、Esc 取消
|
||||||
|
- **视觉状态管理**:选中高亮、禁用状态、加载状态等
|
||||||
|
|
||||||
|
### 6. **数据管理与持久化**
|
||||||
|
- **表单验证设计**:完整的表单验证逻辑,支持必填项检查和格式验证
|
||||||
|
- **本地存储集成**:与 localStorage 集成,支持地块数据的持久化
|
||||||
|
- **自动数据生成**:地块编号、名称的自动生成逻辑
|
||||||
|
- **标签管理功能**:支持标签的添加、删除和展示
|
||||||
|
|
||||||
|
### 7. **技术规范遵循**
|
||||||
|
- **shadcn/ui 语义样式**:使用 `bg-card`、`bg-muted`、`text-muted-foreground` 等语义化样式
|
||||||
|
- **暗色主题支持**:完整支持暗色主题,使用 `dark:` 前缀
|
||||||
|
- **TypeScript 类型安全**:完整的类型定义,确保类型安全
|
||||||
|
- **响应式设计**:支持不同屏幕尺寸的适配
|
||||||
|
|
||||||
|
### 8. **开发效率提升**
|
||||||
|
- **组件复用设计**:通用组件可在其他页面复用
|
||||||
|
- **配置化参数**:画布尺寸、吸附距离等参数可配置
|
||||||
|
- **错误处理机制**:完善的错误处理和用户提示
|
||||||
|
- **代码组织结构**:清晰的文件结构和命名规范
|
||||||
|
|
||||||
|
### 9. **性能优化考虑**
|
||||||
|
- **事件处理优化**:使用 useCallback 避免不必要的重渲染
|
||||||
|
- **状态更新策略**:合理的状态更新时机和批量处理
|
||||||
|
- **Canvas 渲染优化**:减少不必要的重绘和计算
|
||||||
|
|
||||||
|
### 10. **可扩展性设计**
|
||||||
|
- **插件化架构**:编辑工具采用插件化设计,易于扩展新功能
|
||||||
|
- **接口标准化**:统一的接口设计,便于功能模块替换
|
||||||
|
- **配置化开发**:支持通过配置文件调整功能和行为
|
||||||
|
- 理解了复杂应用中的组件分层和职责划分
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AdvancedEditorPromoProps {
|
||||||
|
onSwitchToAdvanced: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedEditorPromo({ onSwitchToAdvanced }: AdvancedEditorPromoProps) {
|
||||||
|
return (
|
||||||
|
<Card className="p-4 bg-gradient-to-r from-green-50 dark:from-green-950 to-blue-50 dark:to-blue-950 border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-green-800 dark:text-green-400 mb-1">💡 高级编辑器推荐</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
高级编辑器支持文件导入(KML/GeoJSON/SHP)、真实地图集成、版本管理等强大功能,适合专业GIS应用。
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onSwitchToAdvanced}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
切换到高级编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
MousePointer,
|
||||||
|
MapPin,
|
||||||
|
Minus,
|
||||||
|
Pentagon,
|
||||||
|
Square,
|
||||||
|
Circle,
|
||||||
|
Undo,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DrawGeometry } from './drawEditReducer';
|
||||||
|
|
||||||
|
export type DrawMode = 'none' | 'point' | 'line' | 'polygon' | 'rectangle' | 'circle';
|
||||||
|
|
||||||
|
export interface DrawPoint {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawingToolsProps {
|
||||||
|
onSaveRequest?: (geometry: DrawGeometry) => void;
|
||||||
|
enableSnapping?: boolean;
|
||||||
|
snapDistance?: number;
|
||||||
|
canvasWidth?: number;
|
||||||
|
canvasHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingTools({
|
||||||
|
onSaveRequest,
|
||||||
|
enableSnapping = true,
|
||||||
|
snapDistance = 10,
|
||||||
|
canvasWidth = 800,
|
||||||
|
canvasHeight = 600
|
||||||
|
}: DrawingToolsProps) {
|
||||||
|
// 状态管理
|
||||||
|
const [drawMode, setDrawMode] = useState<DrawMode>('none');
|
||||||
|
const [currentPoints, setCurrentPoints] = useState<DrawPoint[]>([]);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [mousePosition, setMousePosition] = useState<DrawPoint | null>(null);
|
||||||
|
const [snappedPoint, setSnappedPoint] = useState<DrawPoint | null>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 键盘事件处理
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+Z 撤销
|
||||||
|
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
}
|
||||||
|
// Delete 清除
|
||||||
|
else if (e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClear();
|
||||||
|
}
|
||||||
|
// Esc 取消绘制
|
||||||
|
else if (e.key === 'Escape' && isDrawing) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClear();
|
||||||
|
}
|
||||||
|
// Ctrl+S 保存到地块档案
|
||||||
|
else if (e.ctrlKey && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isDrawing, currentPoints, drawMode]);
|
||||||
|
|
||||||
|
// 撤销 - 删除最后一个点
|
||||||
|
const handleUndo = useCallback(() => {
|
||||||
|
if (currentPoints.length > 0) {
|
||||||
|
const newPoints = currentPoints.slice(0, -1);
|
||||||
|
setCurrentPoints(newPoints);
|
||||||
|
|
||||||
|
if (newPoints.length === 0) {
|
||||||
|
setIsDrawing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.info(`已撤销,剩余 ${newPoints.length} 个点`);
|
||||||
|
} else {
|
||||||
|
toast.info('没有可撤销的操作');
|
||||||
|
}
|
||||||
|
}, [currentPoints]);
|
||||||
|
|
||||||
|
// 检查点是否接近(用于吸附)
|
||||||
|
const isNearPoint = (p1: DrawPoint, p2: DrawPoint): boolean => {
|
||||||
|
const dx = p1.x - p2.x;
|
||||||
|
const dy = p1.y - p2.y;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy) < snapDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查找吸附点
|
||||||
|
const findSnapPoint = (point: DrawPoint): DrawPoint | null => {
|
||||||
|
if (!enableSnapping) return null;
|
||||||
|
|
||||||
|
for (const existing of currentPoints) {
|
||||||
|
if (isNearPoint(point, existing)) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查多边形自相交
|
||||||
|
const checkSelfIntersection = (points: DrawPoint[]): boolean => {
|
||||||
|
if (points.length < 4) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
|
||||||
|
for (let j = i + 2; j < points.length; j++) {
|
||||||
|
if (j === points.length - 1 && i === 0) continue; // 跳过首尾相连的边
|
||||||
|
|
||||||
|
const p3 = points[j];
|
||||||
|
const p4 = points[(j + 1) % points.length];
|
||||||
|
|
||||||
|
if (segmentsIntersect(p1, p2, p3, p4)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查两条线段是否相交
|
||||||
|
const segmentsIntersect = (p1: DrawPoint, p2: DrawPoint, p3: DrawPoint, p4: DrawPoint): boolean => {
|
||||||
|
const ccw = (A: DrawPoint, B: DrawPoint, C: DrawPoint) => {
|
||||||
|
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算多边形面积(Shoelace公式)
|
||||||
|
const calculateArea = (points: DrawPoint[]): number => {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
area += points[i].x * points[j].y;
|
||||||
|
area -= points[j].x * points[i].y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为亩(假设1单位 = 0.5米,1亩 = 666.67平方米)
|
||||||
|
const squareMeters = Math.abs(area) * 0.25; // (0.5m * 0.5m)
|
||||||
|
const mu = squareMeters / 666.67;
|
||||||
|
return mu;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算周长
|
||||||
|
const calculatePerimeter = (points: DrawPoint[]): number => {
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
|
||||||
|
let perimeter = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
const dx = points[j].x - points[i].x;
|
||||||
|
const dy = points[j].y - points[i].y;
|
||||||
|
perimeter += Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为米(假设1单位 = 0.5米)
|
||||||
|
return perimeter * 0.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标移动
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / zoomLevel;
|
||||||
|
const y = (e.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
|
const point: DrawPoint = { x, y };
|
||||||
|
setMousePosition(point);
|
||||||
|
|
||||||
|
// 检查吸附
|
||||||
|
const snapPoint = findSnapPoint(point);
|
||||||
|
setSnappedPoint(snapPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 鼠标点击
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (drawMode === 'none') return;
|
||||||
|
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / zoomLevel;
|
||||||
|
const y = (e.clientY - rect.top) / zoomLevel;
|
||||||
|
|
||||||
|
let point: DrawPoint = { x, y };
|
||||||
|
|
||||||
|
// 吸附到已有点
|
||||||
|
if (snappedPoint) {
|
||||||
|
point = snappedPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPoints = [...currentPoints, point];
|
||||||
|
|
||||||
|
// 多边形:点击起点闭合
|
||||||
|
if (drawMode === 'polygon') {
|
||||||
|
// 检查是否点击起点(闭合多边形)
|
||||||
|
if (currentPoints.length >= 3 && snappedPoint && snappedPoint === currentPoints[0]) {
|
||||||
|
// 不添加重复点,直接使用当前点
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentPoints(newPoints);
|
||||||
|
setIsDrawing(true);
|
||||||
|
} else if (drawMode === 'rectangle') {
|
||||||
|
if (currentPoints.length === 0) {
|
||||||
|
setCurrentPoints([point]);
|
||||||
|
setIsDrawing(true);
|
||||||
|
} else if (currentPoints.length === 1) {
|
||||||
|
// 创建矩形的四个点
|
||||||
|
const rectanglePoints = [
|
||||||
|
currentPoints[0],
|
||||||
|
{ x: point.x, y: currentPoints[0].y },
|
||||||
|
point,
|
||||||
|
{ x: currentPoints[0].x, y: point.y }
|
||||||
|
];
|
||||||
|
setCurrentPoints(rectanglePoints);
|
||||||
|
setIsDrawing(false);
|
||||||
|
}
|
||||||
|
} else if (drawMode === 'line') {
|
||||||
|
setCurrentPoints(newPoints);
|
||||||
|
|
||||||
|
if (newPoints.length >= 2) {
|
||||||
|
setIsDrawing(true);
|
||||||
|
}
|
||||||
|
} else if (drawMode === 'point') {
|
||||||
|
setCurrentPoints([point]);
|
||||||
|
setIsDrawing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存到地块档案
|
||||||
|
const handleSave = () => {
|
||||||
|
// 检查点数
|
||||||
|
if (drawMode === 'polygon' && currentPoints.length < 3) {
|
||||||
|
toast.error('多边形至少需要3个点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (drawMode === 'line' && currentPoints.length < 2) {
|
||||||
|
toast.error('线至少需要2个点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentPoints.length === 0) {
|
||||||
|
toast.error('请先绘制图形');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算几何属性
|
||||||
|
const area = drawMode === 'polygon' || drawMode === 'rectangle' ? calculateArea(currentPoints) : 0;
|
||||||
|
const perimeter = calculatePerimeter(currentPoints);
|
||||||
|
const selfIntersects = drawMode === 'polygon' && checkSelfIntersection(currentPoints);
|
||||||
|
|
||||||
|
if (selfIntersects) {
|
||||||
|
toast.error('多边形自相交,无法保存');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry: DrawGeometry = {
|
||||||
|
type: drawMode,
|
||||||
|
points: currentPoints,
|
||||||
|
area,
|
||||||
|
perimeter,
|
||||||
|
valid: !selfIntersects
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触发保存对话框
|
||||||
|
if (onSaveRequest) {
|
||||||
|
onSaveRequest(geometry);
|
||||||
|
|
||||||
|
// 清除当前绘制
|
||||||
|
setCurrentPoints([]);
|
||||||
|
setIsDrawing(false);
|
||||||
|
setDrawMode('none');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除绘制
|
||||||
|
const handleClear = () => {
|
||||||
|
if (currentPoints.length === 0) {
|
||||||
|
toast.info('当前没有绘制内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirm('确定要清除当前绘制吗?')) {
|
||||||
|
setCurrentPoints([]);
|
||||||
|
setIsDrawing(false);
|
||||||
|
setDrawMode('none');
|
||||||
|
toast.success('已清除绘制');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换绘制模式
|
||||||
|
const switchDrawMode = (mode: DrawMode) => {
|
||||||
|
if (currentPoints.length > 0) {
|
||||||
|
if (!confirm('切换模式将清除当前绘制,是否继续?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrawMode(mode);
|
||||||
|
setCurrentPoints([]);
|
||||||
|
setIsDrawing(mode !== 'none');
|
||||||
|
|
||||||
|
const modeNames: Record<DrawMode, string> = {
|
||||||
|
none: '查看',
|
||||||
|
point: '点',
|
||||||
|
line: '线',
|
||||||
|
polygon: '多边形',
|
||||||
|
rectangle: '矩形',
|
||||||
|
circle: '圆形'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode !== 'none') {
|
||||||
|
toast.success(`已切换到${modeNames[mode]}绘制模式`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查几何有效性
|
||||||
|
const isGeometryValid = (): boolean => {
|
||||||
|
if (drawMode === 'polygon' && currentPoints.length >= 3) {
|
||||||
|
return !checkSelfIntersection(currentPoints);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
{/* 左侧工具面板 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 绘制工具 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">绘制工具</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant={drawMode === 'none' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchDrawMode('none')}
|
||||||
|
>
|
||||||
|
<MousePointer className="w-4 h-4 mr-2" />
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={drawMode === 'point' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchDrawMode('point')}
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4 mr-2" />
|
||||||
|
点
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={drawMode === 'line' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchDrawMode('line')}
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4 mr-2" />
|
||||||
|
线
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={drawMode === 'polygon' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchDrawMode('polygon')}
|
||||||
|
>
|
||||||
|
<Pentagon className="w-4 h-4 mr-2" />
|
||||||
|
多边形
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={drawMode === 'rectangle' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchDrawMode('rectangle')}
|
||||||
|
>
|
||||||
|
<Square className="w-4 h-4 mr-2" />
|
||||||
|
矩形
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作工具 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">操作</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={currentPoints.length === 0}
|
||||||
|
>
|
||||||
|
<Undo className="w-4 h-4 mr-2" />
|
||||||
|
撤销上一个点
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="w-full justify-start bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={currentPoints.length === 0}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
保存到地块档案
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-destructive hover:text-destructive"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 几何信息 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">几何信息</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">顶点数</div>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">{currentPoints.length} 个</div>
|
||||||
|
</div>
|
||||||
|
{(drawMode === 'polygon' || drawMode === 'rectangle') && currentPoints.length >= 3 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">面积</div>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">
|
||||||
|
{calculateArea(currentPoints).toFixed(2)} 亩
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentPoints.length >= 2 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">周长</div>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">
|
||||||
|
{calculatePerimeter(currentPoints).toFixed(0)} 米
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">几何校验</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{isGeometryValid() ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-green-600">有效</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||||
|
<span className="text-red-600">自相交</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 吸附设置 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">设置</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">吸附</span>
|
||||||
|
<Badge variant={enableSnapping ? 'default' : 'secondary'}>
|
||||||
|
{enableSnapping ? '开启' : '关闭'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">吸附距离</span>
|
||||||
|
<span>{snapDistance}px</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">缩放</span>
|
||||||
|
<span>{(zoomLevel * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 画布区域 */}
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Card className="p-0 overflow-hidden bg-card" style={{ height: `${canvasHeight}px` }}>
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className="relative w-full h-full bg-muted cursor-crosshair"
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{ transform: `scale(${zoomLevel})`, transformOrigin: 'top left' }}
|
||||||
|
>
|
||||||
|
{/* 网格背景 */}
|
||||||
|
<div className="absolute inset-0" style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '20px 20px'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* 绘制的点 */}
|
||||||
|
{currentPoints.map((point, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="absolute w-3 h-3 bg-green-600 rounded-full border-2 border-background shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: `${point.x - 6}px`,
|
||||||
|
top: `${point.y - 6}px`,
|
||||||
|
zIndex: 10
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 绘制的线 */}
|
||||||
|
{currentPoints.length >= 2 && (
|
||||||
|
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 5 }}>
|
||||||
|
{currentPoints.map((point, index) => {
|
||||||
|
if (index === 0) return null;
|
||||||
|
const prev = currentPoints[index - 1];
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={index}
|
||||||
|
x1={prev.x}
|
||||||
|
y1={prev.y}
|
||||||
|
x2={point.x}
|
||||||
|
y2={point.y}
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 闭合多边形 */}
|
||||||
|
{drawMode === 'polygon' && currentPoints.length >= 3 && (
|
||||||
|
<line
|
||||||
|
x1={currentPoints[currentPoints.length - 1].x}
|
||||||
|
y1={currentPoints[currentPoints.length - 1].y}
|
||||||
|
x2={currentPoints[0].x}
|
||||||
|
y2={currentPoints[0].y}
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="5,5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 多边形填充 */}
|
||||||
|
{(drawMode === 'polygon' || drawMode === 'rectangle') && currentPoints.length >= 3 && (
|
||||||
|
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
|
||||||
|
<polygon
|
||||||
|
points={currentPoints.map(p => `${p.x},${p.y}`).join(' ')}
|
||||||
|
fill="#22c55e"
|
||||||
|
fillOpacity="0.2"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 吸附点提示 */}
|
||||||
|
{snappedPoint && (
|
||||||
|
<div
|
||||||
|
className="absolute w-5 h-5 border-2 border-blue-500 rounded-full"
|
||||||
|
style={{
|
||||||
|
left: `${snappedPoint.x - 10}px`,
|
||||||
|
top: `${snappedPoint.y - 10}px`,
|
||||||
|
zIndex: 15
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 当前模式提示 */}
|
||||||
|
{isDrawing && (
|
||||||
|
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 pointer-events-none">
|
||||||
|
<Card className="px-4 py-2 bg-green-600 text-white border-0">
|
||||||
|
<div className="text-sm">
|
||||||
|
{drawMode === 'polygon' && `已标记 ${currentPoints.length} 个点,点击起点闭合或点击"保存"按钮`}
|
||||||
|
{drawMode === 'rectangle' && (currentPoints.length === 0 ? '点击第一个角点' : '点击对角点完成')}
|
||||||
|
{drawMode === 'line' && `已标记 ${currentPoints.length} 个点,点击"保存"按钮结束`}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作提示 */}
|
||||||
|
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 pointer-events-none">
|
||||||
|
<Card className="px-4 py-2 bg-background/90 border text-xs text-muted-foreground">
|
||||||
|
Ctrl+Z:撤销 | Ctrl+S:保存 | Delete:清除 | Esc:取消
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,934 @@
|
|||||||
|
/**
|
||||||
|
* GIS编辑工具组件
|
||||||
|
* 提供节点编辑、地块分割、地块合并功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
MousePointer,
|
||||||
|
Move,
|
||||||
|
Scissors,
|
||||||
|
Copy,
|
||||||
|
Plus,
|
||||||
|
Minus as MinusIcon,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
RotateCcw
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { DrawField, DrawGeometry } from './drawEditReducer';
|
||||||
|
|
||||||
|
export type EditMode = 'none' | 'node' | 'split' | 'merge';
|
||||||
|
|
||||||
|
interface EditingToolsProps {
|
||||||
|
fields: DrawField[];
|
||||||
|
onFieldsUpdate?: (fields: DrawField[]) => void;
|
||||||
|
onGeometryChange?: (fieldId: string, geometry: DrawGeometry) => void;
|
||||||
|
onSaveRequest?: (geometry: DrawGeometry) => void;
|
||||||
|
canvasWidth?: number;
|
||||||
|
canvasHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditingTools({
|
||||||
|
fields: initialFields = [],
|
||||||
|
onFieldsUpdate,
|
||||||
|
onGeometryChange,
|
||||||
|
onSaveRequest,
|
||||||
|
canvasWidth = 800,
|
||||||
|
canvasHeight = 600
|
||||||
|
}: EditingToolsProps) {
|
||||||
|
const [editMode, setEditMode] = useState<EditMode>('none');
|
||||||
|
const [fields, setFields] = useState<DrawField[]>(initialFields);
|
||||||
|
const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
||||||
|
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [splitLine, setSplitLine] = useState<any[]>([]);
|
||||||
|
const [selectedForMerge, setSelectedForMerge] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 初始化历史记录,包含初始状态
|
||||||
|
const [history, setHistory] = useState<DrawField[][]>([JSON.parse(JSON.stringify(initialFields))]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0);
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 键盘事件处理
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+Z 撤销
|
||||||
|
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUndo();
|
||||||
|
}
|
||||||
|
// Ctrl+Y 或 Ctrl+Shift+Z 重做
|
||||||
|
else if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRedo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [historyIndex, history]);
|
||||||
|
|
||||||
|
// 添加到历史记录
|
||||||
|
const addToHistory = useCallback((newFields: DrawField[]) => {
|
||||||
|
const newHistory = history.slice(0, historyIndex + 1);
|
||||||
|
newHistory.push(JSON.parse(JSON.stringify(newFields)));
|
||||||
|
setHistory(newHistory);
|
||||||
|
setHistoryIndex(newHistory.length - 1);
|
||||||
|
}, [history, historyIndex]);
|
||||||
|
|
||||||
|
// 撤销
|
||||||
|
const handleUndo = () => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
const newIndex = historyIndex - 1;
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
const previousFields = history[newIndex];
|
||||||
|
setFields(previousFields);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(previousFields);
|
||||||
|
toast.success(`已撤销,剩余 ${history.length - newIndex - 1} 步可重做`);
|
||||||
|
} else {
|
||||||
|
toast.info('没有可撤销的操作');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重做
|
||||||
|
const handleRedo = () => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
const newIndex = historyIndex + 1;
|
||||||
|
setHistoryIndex(newIndex);
|
||||||
|
const nextFields = history[newIndex];
|
||||||
|
setFields(nextFields);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(nextFields);
|
||||||
|
toast.success(`已重做,剩余 ${history.length - newIndex - 1} 步可重做`);
|
||||||
|
} else {
|
||||||
|
toast.info('没有可重做的操作');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算面积
|
||||||
|
const calculateArea = (points: any[]): number => {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
area += points[i].x * points[j].y;
|
||||||
|
area -= points[j].x * points[i].y;
|
||||||
|
}
|
||||||
|
const squareMeters = Math.abs(area) * 0.25;
|
||||||
|
return squareMeters / 666.67; // 转换为亩
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算周长
|
||||||
|
const calculatePerimeter = (points: any[]): number => {
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
let perimeter = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
const dx = points[j].x - points[i].x;
|
||||||
|
const dy = points[j].y - points[i].y;
|
||||||
|
perimeter += Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
return perimeter * 0.5; // 转换为米
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查点是否在多边形内
|
||||||
|
const isPointInPolygon = (point: any, polygon: any[]): boolean => {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i].x, yi = polygon[i].y;
|
||||||
|
const xj = polygon[j].x, yj = polygon[j].y;
|
||||||
|
|
||||||
|
const intersect = ((yi > point.y) !== (yj > point.y))
|
||||||
|
&& (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi);
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查找点击的地块
|
||||||
|
const findFieldAtPoint = (point: any): DrawField | null => {
|
||||||
|
for (const field of fields) {
|
||||||
|
if (isPointInPolygon(point, field.points)) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查找最近的节点
|
||||||
|
const findNearestNode = (point: any, fieldId: string): number | null => {
|
||||||
|
const field = fields.find(f => f.id === fieldId);
|
||||||
|
if (!field) return null;
|
||||||
|
|
||||||
|
let minDist = Infinity;
|
||||||
|
let nearestIndex: number | null = null;
|
||||||
|
|
||||||
|
field.points.forEach((node, index) => {
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
Math.pow(node.x - point.x, 2) + Math.pow(node.y - point.y, 2)
|
||||||
|
);
|
||||||
|
if (dist < minDist && dist < 15) { // 15px容差
|
||||||
|
minDist = dist;
|
||||||
|
nearestIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return nearestIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理鼠标按下
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const point = {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editMode === 'node') {
|
||||||
|
// 节点编辑模式
|
||||||
|
const field = selectedFieldId ? fields.find(f => f.id === selectedFieldId) : findFieldAtPoint(point);
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
setSelectedFieldId(field.id);
|
||||||
|
const nodeIndex = findNearestNode(point, field.id);
|
||||||
|
|
||||||
|
if (nodeIndex !== null) {
|
||||||
|
setSelectedNodeIndex(nodeIndex);
|
||||||
|
setIsDragging(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (editMode === 'split') {
|
||||||
|
// 分割模式 - 绘制分割线
|
||||||
|
setSplitLine([...splitLine, point]);
|
||||||
|
} else if (editMode === 'merge') {
|
||||||
|
// 合并模式 - 选择地块
|
||||||
|
const field = findFieldAtPoint(point);
|
||||||
|
if (field) {
|
||||||
|
if (selectedForMerge.includes(field.id)) {
|
||||||
|
setSelectedForMerge(selectedForMerge.filter(id => id !== field.id));
|
||||||
|
} else {
|
||||||
|
setSelectedForMerge([...selectedForMerge, field.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理鼠标移动
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isDragging || !selectedFieldId || selectedNodeIndex === null) return;
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const point = {
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top
|
||||||
|
};
|
||||||
|
|
||||||
|
const newFields = fields.map(field => {
|
||||||
|
if (field.id === selectedFieldId) {
|
||||||
|
const newPoints = [...field.points];
|
||||||
|
newPoints[selectedNodeIndex] = point;
|
||||||
|
return { ...field, points: newPoints };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFields(newFields);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理鼠标释放
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isDragging && selectedFieldId) {
|
||||||
|
addToHistory(fields);
|
||||||
|
|
||||||
|
const field = fields.find(f => f.id === selectedFieldId);
|
||||||
|
if (field && onGeometryChange) {
|
||||||
|
onGeometryChange(field.id, {
|
||||||
|
type: 'polygon',
|
||||||
|
points: field.points,
|
||||||
|
area: calculateArea(field.points),
|
||||||
|
perimeter: calculatePerimeter(field.points),
|
||||||
|
valid: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('节点位置已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
setSelectedNodeIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加节点
|
||||||
|
const handleAddNode = () => {
|
||||||
|
if (!selectedFieldId || selectedNodeIndex === null) {
|
||||||
|
toast.error('请先选择要添加节点的边');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fields.find(f => f.id === selectedFieldId);
|
||||||
|
if (!field) return;
|
||||||
|
|
||||||
|
const nextIndex = (selectedNodeIndex + 1) % field.points.length;
|
||||||
|
const p1 = field.points[selectedNodeIndex];
|
||||||
|
const p2 = field.points[nextIndex];
|
||||||
|
|
||||||
|
// 在中点添加新节点
|
||||||
|
const newPoint = {
|
||||||
|
x: (p1.x + p2.x) / 2,
|
||||||
|
y: (p1.y + p2.y) / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPoints = [
|
||||||
|
...field.points.slice(0, nextIndex),
|
||||||
|
newPoint,
|
||||||
|
...field.points.slice(nextIndex)
|
||||||
|
];
|
||||||
|
|
||||||
|
const newFields = fields.map(f =>
|
||||||
|
f.id === selectedFieldId ? { ...f, points: newPoints } : f
|
||||||
|
);
|
||||||
|
|
||||||
|
setFields(newFields);
|
||||||
|
addToHistory(newFields);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(newFields);
|
||||||
|
toast.success('已添加节点');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除节点
|
||||||
|
const handleDeleteNode = () => {
|
||||||
|
if (!selectedFieldId || selectedNodeIndex === null) {
|
||||||
|
toast.error('请先选择要删除的节点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = fields.find(f => f.id === selectedFieldId);
|
||||||
|
if (!field || field.points.length <= 3) {
|
||||||
|
toast.error('多边形至少需要3个顶点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPoints = field.points.filter((_, index) => index !== selectedNodeIndex);
|
||||||
|
const newFields = fields.map(f =>
|
||||||
|
f.id === selectedFieldId ? { ...f, points: newPoints } : f
|
||||||
|
);
|
||||||
|
|
||||||
|
setFields(newFields);
|
||||||
|
addToHistory(newFields);
|
||||||
|
setSelectedNodeIndex(null);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(newFields);
|
||||||
|
toast.success('已删除节点');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行分割
|
||||||
|
const handleExecuteSplit = () => {
|
||||||
|
if (splitLine.length < 2) {
|
||||||
|
toast.error('分割线至少需要2个点');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFieldId) {
|
||||||
|
toast.error('请先选择要分割的地块');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceField = fields.find(f => f.id === selectedFieldId);
|
||||||
|
if (!sourceField) {
|
||||||
|
toast.error('未找到选中的地块');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化的分割实现:将地块分成两部分
|
||||||
|
// 使用分割线的中点作为分割参考
|
||||||
|
const midPoint = {
|
||||||
|
x: (splitLine[0].x + splitLine[splitLine.length - 1].x) / 2,
|
||||||
|
y: (splitLine[0].y + splitLine[splitLine.length - 1].y) / 2
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算地块中心
|
||||||
|
const centerX = sourceField.points.reduce((sum, p) => sum + p.x, 0) / sourceField.points.length;
|
||||||
|
const centerY = sourceField.points.reduce((sum, p) => sum + p.y, 0) / sourceField.points.length;
|
||||||
|
|
||||||
|
// 根据分割线方向分离点
|
||||||
|
const isVerticalSplit = Math.abs(splitLine[splitLine.length - 1].y - splitLine[0].y) >
|
||||||
|
Math.abs(splitLine[splitLine.length - 1].x - splitLine[0].x);
|
||||||
|
|
||||||
|
const part1Points: any[] = [];
|
||||||
|
const part2Points: any[] = [];
|
||||||
|
|
||||||
|
sourceField.points.forEach(point => {
|
||||||
|
if (isVerticalSplit) {
|
||||||
|
if (point.x < midPoint.x) {
|
||||||
|
part1Points.push(point);
|
||||||
|
} else {
|
||||||
|
part2Points.push(point);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (point.y < midPoint.y) {
|
||||||
|
part1Points.push(point);
|
||||||
|
} else {
|
||||||
|
part2Points.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保两部分都有足够的点
|
||||||
|
if (part1Points.length < 3 || part2Points.length < 3) {
|
||||||
|
toast.error('分割失败:分割后的地块顶点数不足');
|
||||||
|
setSplitLine([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建两个新地块
|
||||||
|
const field1: DrawField = {
|
||||||
|
id: `split-${Date.now()}-1`,
|
||||||
|
name: `${sourceField.name}-1`,
|
||||||
|
points: part1Points,
|
||||||
|
color: sourceField.color || '#22c55e',
|
||||||
|
area: calculateArea(part1Points),
|
||||||
|
perimeter: calculatePerimeter(part1Points)
|
||||||
|
};
|
||||||
|
|
||||||
|
const field2: DrawField = {
|
||||||
|
id: `split-${Date.now()}-2`,
|
||||||
|
name: `${sourceField.name}-2`,
|
||||||
|
points: part2Points,
|
||||||
|
color: sourceField.color === '#22c55e' ? '#3b82f6' : '#22c55e',
|
||||||
|
area: calculateArea(part2Points),
|
||||||
|
perimeter: calculatePerimeter(part2Points)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新地块列表
|
||||||
|
const newFields = [
|
||||||
|
...fields.filter(f => f.id !== selectedFieldId),
|
||||||
|
field1,
|
||||||
|
field2
|
||||||
|
];
|
||||||
|
|
||||||
|
setFields(newFields);
|
||||||
|
addToHistory(newFields);
|
||||||
|
setSplitLine([]);
|
||||||
|
setSelectedFieldId(null);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(newFields);
|
||||||
|
|
||||||
|
toast.success('地块分割成功!生成了2个新地块');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行合并
|
||||||
|
const handleExecuteMerge = () => {
|
||||||
|
if (selectedForMerge.length < 2) {
|
||||||
|
toast.error('请至少选择2个地块进行合并');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化实现:这里应该实现多边形合并算法
|
||||||
|
const mergedFields = fields.filter(f => selectedForMerge.includes(f.id));
|
||||||
|
const allPoints: any[] = [];
|
||||||
|
mergedFields.forEach(f => allPoints.push(...f.points));
|
||||||
|
|
||||||
|
// 创建凸包作为合并结果(简化算法)
|
||||||
|
const convexHull = computeConvexHull(allPoints);
|
||||||
|
|
||||||
|
const mergedField: DrawField = {
|
||||||
|
id: `merged-${Date.now()}`,
|
||||||
|
name: `合并地块 (${mergedFields.map(f => f.name).join(' + ')})`,
|
||||||
|
points: convexHull,
|
||||||
|
color: mergedFields[0].color || '#22c55e',
|
||||||
|
area: calculateArea(convexHull),
|
||||||
|
perimeter: calculatePerimeter(convexHull)
|
||||||
|
};
|
||||||
|
|
||||||
|
const newFields = [
|
||||||
|
...fields.filter(f => !selectedForMerge.includes(f.id)),
|
||||||
|
mergedField
|
||||||
|
];
|
||||||
|
|
||||||
|
setFields(newFields);
|
||||||
|
addToHistory(newFields);
|
||||||
|
setSelectedForMerge([]);
|
||||||
|
if (onFieldsUpdate) onFieldsUpdate(newFields);
|
||||||
|
|
||||||
|
toast.success(`已合并 ${mergedFields.length} 个地块`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算凸包(Graham扫描算法)
|
||||||
|
const computeConvexHull = (points: any[]): any[] => {
|
||||||
|
if (points.length < 3) return points;
|
||||||
|
|
||||||
|
// 找到最下最左的点
|
||||||
|
let start = points[0];
|
||||||
|
points.forEach(p => {
|
||||||
|
if (p.y < start.y || (p.y === start.y && p.x < start.x)) {
|
||||||
|
start = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按极角排序
|
||||||
|
const sorted = points.filter(p => p !== start).sort((a, b) => {
|
||||||
|
const angleA = Math.atan2(a.y - start.y, a.x - start.x);
|
||||||
|
const angleB = Math.atan2(b.y - start.y, b.x - start.x);
|
||||||
|
return angleA - angleB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hull: any[] = [start];
|
||||||
|
|
||||||
|
for (const point of sorted) {
|
||||||
|
while (hull.length >= 2) {
|
||||||
|
const p2 = hull[hull.length - 1];
|
||||||
|
const p1 = hull[hull.length - 2];
|
||||||
|
const cross = (p2.x - p1.x) * (point.y - p1.y) - (p2.y - p1.y) * (point.x - p1.x);
|
||||||
|
if (cross <= 0) {
|
||||||
|
hull.pop();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hull.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hull;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换编辑模式
|
||||||
|
const switchEditMode = (mode: EditMode) => {
|
||||||
|
setEditMode(mode);
|
||||||
|
setSelectedFieldId(null);
|
||||||
|
setSelectedNodeIndex(null);
|
||||||
|
setSplitLine([]);
|
||||||
|
setSelectedForMerge([]);
|
||||||
|
|
||||||
|
const modeNames: Record<EditMode, string> = {
|
||||||
|
none: '查看',
|
||||||
|
node: '节点编辑',
|
||||||
|
split: '地块分割',
|
||||||
|
merge: '地块合并'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode !== 'none') {
|
||||||
|
toast.success(`已切换到${modeNames[mode]}模式`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedField = selectedFieldId ? fields.find(f => f.id === selectedFieldId) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-6">
|
||||||
|
{/* 左侧工具面板 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 编辑模式 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">编辑模式</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant={editMode === 'none' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchEditMode('none')}
|
||||||
|
>
|
||||||
|
<MousePointer className="w-4 h-4 mr-2" />
|
||||||
|
选择
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={editMode === 'node' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchEditMode('node')}
|
||||||
|
>
|
||||||
|
<Move className="w-4 h-4 mr-2" />
|
||||||
|
节点编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={editMode === 'split' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchEditMode('split')}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4 mr-2" />
|
||||||
|
地块分割
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={editMode === 'merge' ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => switchEditMode('merge')}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
地块合并
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 节点操作 */}
|
||||||
|
{editMode === 'node' && (
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">节点操作</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleAddNode}
|
||||||
|
disabled={!selectedFieldId || selectedNodeIndex === null}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加节点
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-destructive"
|
||||||
|
onClick={handleDeleteNode}
|
||||||
|
disabled={!selectedFieldId || selectedNodeIndex === null}
|
||||||
|
>
|
||||||
|
<MinusIcon className="w-4 h-4 mr-2" />
|
||||||
|
删除节点
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分割操作 */}
|
||||||
|
{editMode === 'split' && (
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">分割操作</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-xs text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="mb-2">操作步骤:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>点击地块选中要分割的地块</li>
|
||||||
|
<li>在画布上绘制分割线(至少2个点)</li>
|
||||||
|
<li>点击"执行分割"按钮</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFieldId && (
|
||||||
|
<div className="p-2 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md text-xs text-green-700 dark:text-green-200">
|
||||||
|
✓ 已选中地块
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleExecuteSplit}
|
||||||
|
disabled={splitLine.length < 2 || !selectedFieldId}
|
||||||
|
>
|
||||||
|
<Scissors className="w-4 h-4 mr-2" />
|
||||||
|
执行分割
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-destructive"
|
||||||
|
onClick={() => setSplitLine([])}
|
||||||
|
disabled={splitLine.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
清除分割线
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
分割点: {splitLine.length} 个
|
||||||
|
</div>
|
||||||
|
{!selectedFieldId && (
|
||||||
|
<div className="text-xs text-amber-600">
|
||||||
|
⚠ 请先点击地块进行选择
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 合并操作 */}
|
||||||
|
{editMode === 'merge' && (
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">合并操作</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-xs text-blue-800 dark:text-blue-200">
|
||||||
|
<p className="mb-2">操作步骤:</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
<li>点击要合并的地块(至少2个)</li>
|
||||||
|
<li>查看已选地块数量</li>
|
||||||
|
<li>点击"执行合并"按钮</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedForMerge.length > 0 && (
|
||||||
|
<div className="p-2 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md text-xs text-green-700 dark:text-green-200">
|
||||||
|
✓ 已选择 {selectedForMerge.length} 个地块
|
||||||
|
{selectedForMerge.length >= 2 && ' - 可以合并'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleExecuteMerge}
|
||||||
|
disabled={selectedForMerge.length < 2}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
执行合并
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-destructive"
|
||||||
|
onClick={() => setSelectedForMerge([])}
|
||||||
|
disabled={selectedForMerge.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
取消选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
已选: {selectedForMerge.length} 个地块
|
||||||
|
</div>
|
||||||
|
{selectedForMerge.length === 1 && (
|
||||||
|
<div className="text-xs text-amber-600">
|
||||||
|
⚠ 至少需要选择2个地块才能合并
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 历史操作 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">历史操作</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={historyIndex <= 0}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" />
|
||||||
|
撤销 {historyIndex > 0 && `(${historyIndex})`}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={historyIndex >= history.length - 1}
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2 scale-x-[-1]" />
|
||||||
|
重做 {historyIndex < history.length - 1 && `(${history.length - historyIndex - 1})`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
|
历史步数:{history.length} | 当前位置:{historyIndex + 1}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 保存操作 */}
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">保存操作</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 批量保存分割/合并后的地块 */}
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="w-full justify-start bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
onClick={() => {
|
||||||
|
// 保存所有新创建的地块(ID包含split或merged的)
|
||||||
|
const newFields = fields.filter(f =>
|
||||||
|
f.id.includes('split-') || f.id.includes('merged-')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newFields.length === 0) {
|
||||||
|
toast.info('没有需要保存的新地块');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存第一个新地块
|
||||||
|
const geometry: DrawGeometry = {
|
||||||
|
type: 'polygon',
|
||||||
|
points: newFields[0].points,
|
||||||
|
area: calculateArea(newFields[0].points),
|
||||||
|
perimeter: calculatePerimeter(newFields[0].points),
|
||||||
|
valid: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onSaveRequest) {
|
||||||
|
onSaveRequest(geometry);
|
||||||
|
toast.success(`发现 ${newFields.length} 个新地块,正在保存第一个...`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!onSaveRequest}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
保存新地块到地块档案
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{fields.filter(f => f.id.includes('split-') || f.id.includes('merged-')).length > 0
|
||||||
|
? `有 ${fields.filter(f => f.id.includes('split-') || f.id.includes('merged-')).length} 个新地块可保存`
|
||||||
|
: '请先进行分割或合并操作以创建新地块'}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 地块信息 */}
|
||||||
|
{selectedField && (
|
||||||
|
<Card className="p-4 bg-card">
|
||||||
|
<h4 className="mb-3">地块信息</h4>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">名称</div>
|
||||||
|
<div className="mt-1">{selectedField.name}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">顶点数</div>
|
||||||
|
<div className="mt-1">{selectedField.points.length} 个</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">面积</div>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">
|
||||||
|
{calculateArea(selectedField.points).toFixed(2)} 亩
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-muted-foreground">周长</div>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">
|
||||||
|
{calculatePerimeter(selectedField.points).toFixed(0)} 米
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑画布 */}
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Card className="overflow-hidden bg-card">
|
||||||
|
<div
|
||||||
|
ref={canvasRef}
|
||||||
|
className="relative bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950"
|
||||||
|
style={{ height: '700px', cursor: isDragging ? 'grabbing' : 'default' }}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
>
|
||||||
|
{/* 网格背景 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(0,0,0,0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0,0,0,0.05) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '50px 50px',
|
||||||
|
width: canvasWidth,
|
||||||
|
height: canvasHeight
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* SVG图层 */}
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{ width: canvasWidth, height: canvasHeight }}
|
||||||
|
>
|
||||||
|
{/* 绘制所有地块 */}
|
||||||
|
{fields.map(field => {
|
||||||
|
const isSelected = field.id === selectedFieldId;
|
||||||
|
const isSelectedForMerge = selectedForMerge.includes(field.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={field.id}>
|
||||||
|
<polygon
|
||||||
|
points={field.points.map(p => `${p.x},${p.y}`).join(' ')}
|
||||||
|
fill={field.color ? field.color + '40' : '#22c55e40'}
|
||||||
|
stroke={isSelected || isSelectedForMerge ? '#3b82f6' : field.color || '#22c55e'}
|
||||||
|
strokeWidth={isSelected || isSelectedForMerge ? '3' : '2'}
|
||||||
|
opacity={isSelectedForMerge ? '0.8' : '1'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 地块标签 */}
|
||||||
|
<text
|
||||||
|
x={field.points.reduce((sum, p) => sum + p.x, 0) / field.points.length}
|
||||||
|
y={field.points.reduce((sum, p) => sum + p.y, 0) / field.points.length}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={field.color || '#22c55e'}
|
||||||
|
className="text-sm pointer-events-none"
|
||||||
|
>
|
||||||
|
{field.name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
{splitLine.length > 0 && (
|
||||||
|
<polyline
|
||||||
|
points={splitLine.map(p => `${p.x},${p.y}`).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="5,5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* 节点编辑模式 - 显示所有节点 */}
|
||||||
|
{editMode === 'node' && selectedField && selectedField.points.map((point, index) => {
|
||||||
|
const isSelected = index === selectedNodeIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`absolute w-3 h-3 rounded-full border-2 border-white shadow-lg transform -translate-x-1/2 -translate-y-1/2 cursor-move ${
|
||||||
|
isSelected ? 'bg-blue-600 w-4 h-4' : 'bg-green-600'
|
||||||
|
}`}
|
||||||
|
style={{ left: point.x, top: point.y }}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-1 text-xs bg-white px-1 rounded shadow whitespace-nowrap">
|
||||||
|
节点 {index + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 分割模式 - 显示分割点 */}
|
||||||
|
{editMode === 'split' && splitLine.map((point, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="absolute w-3 h-3 bg-red-600 rounded-full border-2 border-white shadow-lg transform -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{ left: point.x, top: point.y }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 状态栏 */}
|
||||||
|
<div className="absolute bottom-4 left-4 pointer-events-none">
|
||||||
|
<Card className="px-3 py-2 bg-background/90 border">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge className="bg-blue-600">
|
||||||
|
{editMode === 'none' && '查看模式'}
|
||||||
|
{editMode === 'node' && '节点编辑'}
|
||||||
|
{editMode === 'split' && '地块分割'}
|
||||||
|
{editMode === 'merge' && '地块合并'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 操作说明 */}
|
||||||
|
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 mt-4">
|
||||||
|
<h4 className="text-blue-900 dark:text-blue-100 mb-2">✨ 编辑功能说明</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<div>• <strong>节点编辑</strong>:点击选择地块,拖动节点调整位置</div>
|
||||||
|
<div>• <strong>添加节点</strong>:选择边后点击添加节点按钮</div>
|
||||||
|
<div>• <strong>删除节点</strong>:选择节点后点击删除按钮</div>
|
||||||
|
<div>• <strong>地块分割</strong>:绘制分割线穿过地块</div>
|
||||||
|
<div>• <strong>地块合并</strong>:选择相邻地块进行合并</div>
|
||||||
|
<div>• <strong>自动计算</strong>:编辑后面积周长自动更新</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* 地块信息录入对话框
|
||||||
|
* 用于从绘制工具或编辑工具保存地块到地块档案
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { DrawGeometry } from './drawEditReducer';
|
||||||
|
import { Save, X, Tag, MapPin } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface FieldEntryDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
geometry: DrawGeometry;
|
||||||
|
onSaved?: (field: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldEntryDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
geometry,
|
||||||
|
onSaved
|
||||||
|
}: FieldEntryDialogProps) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
area: 0,
|
||||||
|
perimeter: 0,
|
||||||
|
location: '',
|
||||||
|
owner: '',
|
||||||
|
soilType: 'loamy',
|
||||||
|
landUseType: 'farmland',
|
||||||
|
plantingMode: 'open-field',
|
||||||
|
irrigationType: 'drip',
|
||||||
|
tags: [],
|
||||||
|
status: 'active',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
|
||||||
|
// 初始化表单数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (geometry && open) {
|
||||||
|
// 自动生成地块编号和名称
|
||||||
|
const fields = JSON.parse(localStorage.getItem('smart_agriculture_fields') || '[]');
|
||||||
|
const count = fields.length + 1;
|
||||||
|
|
||||||
|
// 将绘制的点转换为地理坐标(这里使用模拟数据)
|
||||||
|
const coordinates = geometry.points.map((p, index) => ({
|
||||||
|
lat: 39.9042 + (p.y - 300) / 10000,
|
||||||
|
lng: 116.4074 + (p.x - 400) / 10000
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 计算中心点
|
||||||
|
const centerLat = coordinates.reduce((sum, p) => sum + p.lat, 0) / coordinates.length;
|
||||||
|
const centerLng = coordinates.reduce((sum, p) => sum + p.lng, 0) / coordinates.length;
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
code: `FIELD-${String(count).padStart(3, '0')}`,
|
||||||
|
name: `地块${String.fromCharCode(64 + count)}`,
|
||||||
|
area: geometry.area || 0,
|
||||||
|
perimeter: geometry.perimeter || 0,
|
||||||
|
location: `北京市XX区`,
|
||||||
|
owner: '',
|
||||||
|
soilType: 'loamy',
|
||||||
|
landUseType: 'farmland',
|
||||||
|
plantingMode: 'open-field',
|
||||||
|
irrigationType: 'drip',
|
||||||
|
tags: [],
|
||||||
|
status: 'active',
|
||||||
|
description: `通过GIS工具绘制的${geometry.type === 'polygon' ? '多边形' : geometry.type === 'rectangle' ? '矩形' : ''}地块`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [geometry, open]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// 验证必填项
|
||||||
|
if (!formData.code?.trim()) {
|
||||||
|
toast.error('请输入地块编号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.name?.trim()) {
|
||||||
|
toast.error('请输入地块名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geometry.points.length < 3) {
|
||||||
|
toast.error('地块边界数据无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 保存到localStorage(地块档案)
|
||||||
|
const fields = JSON.parse(localStorage.getItem('smart_agriculture_fields') || '[]');
|
||||||
|
|
||||||
|
const newField = {
|
||||||
|
id: `field-${Date.now()}`,
|
||||||
|
code: formData.code!,
|
||||||
|
name: formData.name!,
|
||||||
|
area: formData.area!,
|
||||||
|
perimeter: formData.perimeter || 0,
|
||||||
|
location: formData.location || '',
|
||||||
|
owner: formData.owner || '',
|
||||||
|
soilType: formData.soilType!,
|
||||||
|
landUseType: formData.landUseType!,
|
||||||
|
plantingMode: formData.plantingMode!,
|
||||||
|
irrigationType: formData.irrigationType || 'drip',
|
||||||
|
tags: formData.tags || [],
|
||||||
|
photos: [],
|
||||||
|
documents: [],
|
||||||
|
status: formData.status || 'active',
|
||||||
|
description: formData.description || '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdBy: 'admin',
|
||||||
|
currentVersion: 1,
|
||||||
|
geometry: geometry,
|
||||||
|
};
|
||||||
|
|
||||||
|
fields.push(newField);
|
||||||
|
localStorage.setItem('smart_agriculture_fields', JSON.stringify(fields));
|
||||||
|
|
||||||
|
toast.success(`地块 ${newField.name} 已保存到地块档案`);
|
||||||
|
|
||||||
|
// 调用回调
|
||||||
|
if (onSaved) {
|
||||||
|
onSaved(newField);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存地块失败:', error);
|
||||||
|
toast.error('保存失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTag = () => {
|
||||||
|
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
tags: [...formData.tags, tagInput.trim()],
|
||||||
|
});
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = (tag: string) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
tags: formData.tags.filter(t => t !== tag),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 土壤类型选项
|
||||||
|
const soilTypes = [
|
||||||
|
{ value: 'sandy', label: '沙土' },
|
||||||
|
{ value: 'loamy', label: '壤土' },
|
||||||
|
{ value: 'clay', label: '粘土' },
|
||||||
|
{ value: 'silt', label: '淤泥土' },
|
||||||
|
{ value: 'peat', label: '泥炭土' },
|
||||||
|
{ value: 'saline', label: '盐碱土' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 种植模式选项
|
||||||
|
const plantingModes = [
|
||||||
|
{ value: 'open-field', label: '露地' },
|
||||||
|
{ value: 'greenhouse', label: '大棚' },
|
||||||
|
{ value: 'orchard', label: '果园' },
|
||||||
|
{ value: 'paddy', label: '水田' },
|
||||||
|
{ value: 'dryland', label: '旱地' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 灌溉方式选项
|
||||||
|
const irrigationTypes = [
|
||||||
|
{ value: 'drip', label: '滴灌' },
|
||||||
|
{ value: 'sprinkler', label: '喷灌' },
|
||||||
|
{ value: 'flood', label: '漫灌' },
|
||||||
|
{ value: 'micro-sprinkler', label: '微喷' },
|
||||||
|
{ value: 'none', label: '无灌溉' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新增地块</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
填写地块信息并保存到地块档案管理
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* 几何信息汇总 */}
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<MapPin className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-sm text-green-800 dark:text-green-200">几何信息</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">类型</span>
|
||||||
|
<div className="mt-1">
|
||||||
|
{geometry.type === 'polygon' && '多边形'}
|
||||||
|
{geometry.type === 'rectangle' && '矩形'}
|
||||||
|
{geometry.type === 'line' && '线'}
|
||||||
|
{geometry.type === 'point' && '点'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">顶点数</span>
|
||||||
|
<div className="mt-1">{geometry.points.length} 个</div>
|
||||||
|
</div>
|
||||||
|
{geometry.area && geometry.area > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">面积</span>
|
||||||
|
<div className="mt-1 text-green-600 dark:text-green-400">{geometry.area.toFixed(2)} 亩</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{geometry.perimeter && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">周长</span>
|
||||||
|
<div className="mt-1 text-blue-600 dark:text-blue-400">{geometry.perimeter.toFixed(0)} 米</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>地块编号 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
placeholder="例如:FIELD-001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>地块名称 *</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="例如:地块A"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>地块位置</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.location}
|
||||||
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
|
placeholder="例如:北京市XX区"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>责任人</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.owner}
|
||||||
|
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
|
||||||
|
placeholder="例如:张三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地块属性 */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>土壤类型</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.soilType}
|
||||||
|
onValueChange={(value: any) => setFormData({ ...formData, soilType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{soilTypes.map(type => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>种植模式</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.plantingMode}
|
||||||
|
onValueChange={(value: any) => setFormData({ ...formData, plantingMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{plantingModes.map(mode => (
|
||||||
|
<SelectItem key={mode.value} value={mode.value}>
|
||||||
|
{mode.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>灌溉方式</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.irrigationType}
|
||||||
|
onValueChange={(value: any) => setFormData({ ...formData, irrigationType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{irrigationTypes.map(type => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>地块标签</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="输入标签后按回车添加"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleAddTag} variant="outline">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{formData.tags && formData.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{formData.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary">
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
className="ml-2 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>备注信息</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="输入地块相关的备注信息"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="bg-green-600 hover:bg-green-700">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
保存到地块档案
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Pen, Edit3, BookOpen, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UsageGuideProps {
|
||||||
|
onSwitchToAdvanced?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageGuide({ onSwitchToAdvanced }: UsageGuideProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* 绘制工具指南 */}
|
||||||
|
<Card className="p-6 bg-card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Pen className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
<h3 className="text-green-800 dark:text-green-400">绘制工具</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">1. 多边形绘制</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• 点击地图依次标记顶点</li>
|
||||||
|
<li>• 至少需要3个点</li>
|
||||||
|
<li>• 点击起点或"完成"按钮闭合</li>
|
||||||
|
<li>• 点击"保存"按钮保存到地块档案</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">2. 保存流程</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• 完成绘制后,点击左侧"保存"按钮</li>
|
||||||
|
<li>• 弹出"新增地块"对话框</li>
|
||||||
|
<li>• 填写地块信息(自动填充面积周长)</li>
|
||||||
|
<li>• 保存到地块档案管理</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">3. 快捷操作</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• Ctrl+Z:撤销</li>
|
||||||
|
<li>• Ctrl+Y:重做</li>
|
||||||
|
<li>• Enter:完成绘制</li>
|
||||||
|
<li>• Ctrl+S:保存到地块档案</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 编辑工具指南 */}
|
||||||
|
<Card className="p-6 bg-card">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Edit3 className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<h3 className="text-blue-800 dark:text-blue-400">编辑工具</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">1. 节点编辑</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• 点击地块选中</li>
|
||||||
|
<li>• 拖动节点调整位置</li>
|
||||||
|
<li>• 点击"保存"保存到地块档案</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">2. 地块分割</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• 选择要分割的地块</li>
|
||||||
|
<li>• 绘制分割线</li>
|
||||||
|
<li>• 点击"保存"保存到地块档案</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2">3. 地块合并</h4>
|
||||||
|
<ul className="space-y-1 text-muted-foreground ml-4">
|
||||||
|
<li>• 选择多个地块</li>
|
||||||
|
<li>• 点击"合并"</li>
|
||||||
|
<li>• 点击"保存"保存到地块档案</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 高级功能说明 */}
|
||||||
|
<Card className="p-6 col-span-2 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||||
|
<h3 className="text-green-800 dark:text-green-400">高级功能(高级编辑器)</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm text-green-800 dark:text-green-200">
|
||||||
|
<div>• <strong>文件导入</strong>:支持KML、GeoJSON、Shapefile格式</div>
|
||||||
|
<div>• <strong>真实地图</strong>:集成高德地图/Leaflet地图引擎</div>
|
||||||
|
<div>• <strong>坐标转换</strong>:支持WGS84、GCJ02坐标系转换</div>
|
||||||
|
<div>• <strong>版本管理</strong>:自动记录编辑历史,随时回滚</div>
|
||||||
|
<div>• <strong>批量操作</strong>:批量导入导出地块数据</div>
|
||||||
|
<div>• <strong>数据关联</strong>:关联土壤、种植等业务数据</div>
|
||||||
|
<div>• <strong>精确测量</strong>:基于真实地理坐标的精确测量</div>
|
||||||
|
<div>• <strong>图层管理</strong>:多图层叠加显示和管理</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onSwitchToAdvanced && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-green-300 dark:border-green-700">
|
||||||
|
<Button
|
||||||
|
onClick={onSwitchToAdvanced}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
立即体验高级编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 快捷键说明 */}
|
||||||
|
<Card className="p-6 col-span-2 bg-card">
|
||||||
|
<h3 className="mb-4">⌨️ 快捷键</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + Z</kbd>
|
||||||
|
<span className="text-muted-foreground">撤销</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + Y</kbd>
|
||||||
|
<span className="text-muted-foreground">重做</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl + S</kbd>
|
||||||
|
<span className="text-muted-foreground">保存到地块档案</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Delete</kbd>
|
||||||
|
<span className="text-muted-foreground">清除</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Esc</kbd>
|
||||||
|
<span className="text-muted-foreground">取消操作</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 bg-muted rounded text-xs">Enter</kbd>
|
||||||
|
<span className="text-muted-foreground">完成绘制</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
|
||||||
|
// 地块数据接口
|
||||||
|
export interface DrawField {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
color: string;
|
||||||
|
area?: number;
|
||||||
|
perimeter?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制几何接口
|
||||||
|
export interface DrawGeometry {
|
||||||
|
type: 'point' | 'line' | 'polygon' | 'rectangle' | 'circle';
|
||||||
|
points: { x: number; y: number }[];
|
||||||
|
area?: number;
|
||||||
|
perimeter?: number;
|
||||||
|
valid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制编辑状态接口
|
||||||
|
export interface DrawEditState {
|
||||||
|
showAdvancedEditor: boolean;
|
||||||
|
activeTab: 'draw' | 'edit' | 'guide';
|
||||||
|
fields: DrawField[];
|
||||||
|
showSaveDialog: boolean;
|
||||||
|
pendingGeometry: DrawGeometry | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action类型
|
||||||
|
export type DrawEditAction =
|
||||||
|
| { type: 'SET_SHOW_ADVANCED_EDITOR'; payload: boolean }
|
||||||
|
| { type: 'SET_ACTIVE_TAB'; payload: 'draw' | 'edit' | 'guide' }
|
||||||
|
| { type: 'SET_FIELDS'; payload: DrawField[] }
|
||||||
|
| { type: 'UPDATE_FIELD'; payload: { id: string; updates: Partial<DrawField> } }
|
||||||
|
| { type: 'ADD_FIELD'; payload: DrawField }
|
||||||
|
| { type: 'DELETE_FIELD'; payload: string }
|
||||||
|
| { type: 'SET_SHOW_SAVE_DIALOG'; payload: boolean }
|
||||||
|
| { type: 'SET_PENDING_GEOMETRY'; payload: DrawGeometry | null };
|
||||||
|
|
||||||
|
// 计算面积的辅助函数
|
||||||
|
const calculateArea = (points: { x: number; y: number }[]): number => {
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
let area = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
area += points[i].x * points[j].y;
|
||||||
|
area -= points[j].x * points[i].y;
|
||||||
|
}
|
||||||
|
const squareMeters = Math.abs(area) * 0.25;
|
||||||
|
return squareMeters / 666.67; // 转换为亩
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算周长的辅助函数
|
||||||
|
const calculatePerimeter = (points: { x: number; y: number }[]): number => {
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
let perimeter = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const j = (i + 1) % points.length;
|
||||||
|
const dx = points[j].x - points[i].x;
|
||||||
|
const dy = points[j].y - points[i].y;
|
||||||
|
perimeter += Math.sqrt(dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
return perimeter * 0.5; // 转换为米
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始状态
|
||||||
|
const initialState: DrawEditState = {
|
||||||
|
showAdvancedEditor: false,
|
||||||
|
activeTab: 'draw',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: 'field-1',
|
||||||
|
name: '地块A',
|
||||||
|
points: [
|
||||||
|
{ x: 200, y: 150 },
|
||||||
|
{ x: 350, y: 150 },
|
||||||
|
{ x: 350, y: 280 },
|
||||||
|
{ x: 200, y: 280 }
|
||||||
|
],
|
||||||
|
color: '#22c55e',
|
||||||
|
area: calculateArea([
|
||||||
|
{ x: 200, y: 150 },
|
||||||
|
{ x: 350, y: 150 },
|
||||||
|
{ x: 350, y: 280 },
|
||||||
|
{ x: 200, y: 280 }
|
||||||
|
]),
|
||||||
|
perimeter: calculatePerimeter([
|
||||||
|
{ x: 200, y: 150 },
|
||||||
|
{ x: 350, y: 150 },
|
||||||
|
{ x: 350, y: 280 },
|
||||||
|
{ x: 200, y: 280 }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-2',
|
||||||
|
name: '地块B',
|
||||||
|
points: [
|
||||||
|
{ x: 380, y: 150 },
|
||||||
|
{ x: 520, y: 150 },
|
||||||
|
{ x: 520, y: 280 },
|
||||||
|
{ x: 380, y: 280 }
|
||||||
|
],
|
||||||
|
color: '#3b82f6',
|
||||||
|
area: calculateArea([
|
||||||
|
{ x: 380, y: 150 },
|
||||||
|
{ x: 520, y: 150 },
|
||||||
|
{ x: 520, y: 280 },
|
||||||
|
{ x: 380, y: 280 }
|
||||||
|
]),
|
||||||
|
perimeter: calculatePerimeter([
|
||||||
|
{ x: 380, y: 150 },
|
||||||
|
{ x: 520, y: 150 },
|
||||||
|
{ x: 520, y: 280 },
|
||||||
|
{ x: 380, y: 280 }
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'field-3',
|
||||||
|
name: '地块C',
|
||||||
|
points: [
|
||||||
|
{ x: 200, y: 310 },
|
||||||
|
{ x: 350, y: 310 },
|
||||||
|
{ x: 350, y: 440 },
|
||||||
|
{ x: 200, y: 440 }
|
||||||
|
],
|
||||||
|
color: '#f97316',
|
||||||
|
area: calculateArea([
|
||||||
|
{ x: 200, y: 310 },
|
||||||
|
{ x: 350, y: 310 },
|
||||||
|
{ x: 350, y: 440 },
|
||||||
|
{ x: 200, y: 440 }
|
||||||
|
]),
|
||||||
|
perimeter: calculatePerimeter([
|
||||||
|
{ x: 200, y: 310 },
|
||||||
|
{ x: 350, y: 310 },
|
||||||
|
{ x: 350, y: 440 },
|
||||||
|
{ x: 200, y: 440 }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
showSaveDialog: false,
|
||||||
|
pendingGeometry: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reducer函数
|
||||||
|
export function drawEditReducer(state: DrawEditState, action: DrawEditAction): DrawEditState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_SHOW_ADVANCED_EDITOR':
|
||||||
|
return { ...state, showAdvancedEditor: action.payload };
|
||||||
|
|
||||||
|
case 'SET_ACTIVE_TAB':
|
||||||
|
return { ...state, activeTab: action.payload };
|
||||||
|
|
||||||
|
case 'SET_FIELDS':
|
||||||
|
return { ...state, fields: action.payload };
|
||||||
|
|
||||||
|
case 'UPDATE_FIELD':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fields: state.fields.map(field =>
|
||||||
|
field.id === action.payload.id
|
||||||
|
? { ...field, ...action.payload.updates }
|
||||||
|
: field
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'ADD_FIELD':
|
||||||
|
return { ...state, fields: [...state.fields, action.payload] };
|
||||||
|
|
||||||
|
case 'DELETE_FIELD':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
fields: state.fields.filter(field => field.id !== action.payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_SHOW_SAVE_DIALOG':
|
||||||
|
return { ...state, showSaveDialog: action.payload };
|
||||||
|
|
||||||
|
case 'SET_PENDING_GEOMETRY':
|
||||||
|
return { ...state, pendingGeometry: action.payload };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出初始状态和类型
|
||||||
|
export { initialState };
|
||||||
|
export type { DrawEditAction, DrawField, DrawGeometry, DrawEditState };
|
||||||
@@ -1,18 +1,192 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Pen, Edit3, BookOpen, Sparkles } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
drawEditReducer,
|
||||||
|
initialState,
|
||||||
|
DrawEditState,
|
||||||
|
DrawGeometry,
|
||||||
|
DrawField
|
||||||
|
} from './components/drawEditReducer';
|
||||||
|
import { AdvancedEditorPromo } from './components/AdvancedEditorPromo';
|
||||||
|
import { DrawingTools } from './components/DrawingTools';
|
||||||
|
import { EditingTools } from './components/EditingTools';
|
||||||
|
import { UsageGuide } from './components/UsageGuide';
|
||||||
|
import { FieldEntryDialog } from './components/FieldEntryDialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function DrawPage() {
|
export default function DrawPage() {
|
||||||
|
const [state, dispatch] = useReducer(drawEditReducer, initialState);
|
||||||
|
|
||||||
|
// 处理几何数据保存请求(从绘制工具或编辑工具)
|
||||||
|
const handleSaveRequest = (geometry: DrawGeometry) => {
|
||||||
|
dispatch({ type: 'SET_PENDING_GEOMETRY', payload: geometry });
|
||||||
|
dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存地块成功后的回调
|
||||||
|
const handleFieldSaved = (fieldData: any) => {
|
||||||
|
toast.success(`地块 ${fieldData.name} 已保存到地块档案`);
|
||||||
|
dispatch({ type: 'SET_PENDING_GEOMETRY', payload: null });
|
||||||
|
dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理地块更新
|
||||||
|
const handleFieldsUpdate = (updatedFields: DrawField[]) => {
|
||||||
|
dispatch({ type: 'SET_FIELDS', payload: updatedFields });
|
||||||
|
toast.success('地块已更新');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理几何变化
|
||||||
|
const handleGeometryChange = (fieldId: string, geometry: DrawGeometry) => {
|
||||||
|
// 更新地块的几何属性
|
||||||
|
const updatedFields = state.fields.map(field => {
|
||||||
|
if (field.id === fieldId) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
points: geometry.points,
|
||||||
|
area: geometry.area || 0,
|
||||||
|
perimeter: geometry.perimeter || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch({ type: 'SET_FIELDS', payload: updatedFields });
|
||||||
|
|
||||||
|
toast.info(`面积:${geometry.area?.toFixed(2)}亩,周长:${geometry.perimeter?.toFixed(0)}米`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换到高级编辑器
|
||||||
|
const handleSwitchToAdvanced = () => {
|
||||||
|
dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换标签页
|
||||||
|
const handleTabChange = (value: 'draw' | 'edit' | 'guide') => {
|
||||||
|
dispatch({ type: 'SET_ACTIVE_TAB', payload: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 高级编辑器
|
||||||
|
if (state.showAdvancedEditor) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-green-800 dark:text-green-400">高级编辑器</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
专业GIS编辑工具,支持文件导入、真实地图、版本管理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: false })}
|
||||||
|
>
|
||||||
|
返回基础编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6 bg-muted">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<Sparkles className="w-16 h-16 mx-auto text-green-600" />
|
||||||
|
<h3>高级编辑器</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
此功能正在开发中,敬请期待!
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => dispatch({ type: 'SET_SHOW_ADVANCED_EDITOR', payload: false })}
|
||||||
|
>
|
||||||
|
返回基础编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="p-6">
|
{/* 高级编辑器推荐卡片 */}
|
||||||
<h2 className="text-xl font-semibold">地块绘制编辑</h2>
|
<AdvancedEditorPromo onSwitchToAdvanced={handleSwitchToAdvanced} />
|
||||||
<div className="p-3 bg-muted rounded-lg mt-3">
|
|
||||||
<p className="text-sm">
|
{/* 页面标题 */}
|
||||||
<strong>页面路径:</strong> /land-information/map/draw
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-green-800 dark:text-green-400">数字化绘制与编辑</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
完整的在线GIS编辑工具,支持绘制、节点编辑、分割、合并
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSwitchToAdvanced}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
高级编辑器
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主内容区 - 标签页 */}
|
||||||
|
<Tabs value={state.activeTab} onValueChange={handleTabChange}>
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="draw" className="flex items-center gap-2">
|
||||||
|
<Pen className="w-4 h-4" />
|
||||||
|
绘制工具
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="edit" className="flex items-center gap-2">
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
编辑工具
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="guide" className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4" />
|
||||||
|
使用指南
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 绘制工具标签页 */}
|
||||||
|
<TabsContent value="draw" className="mt-6">
|
||||||
|
<DrawingTools
|
||||||
|
onSaveRequest={handleSaveRequest}
|
||||||
|
enableSnapping={true}
|
||||||
|
snapDistance={10}
|
||||||
|
canvasWidth={800}
|
||||||
|
canvasHeight={600}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 编辑工具标签页 */}
|
||||||
|
<TabsContent value="edit" className="mt-6">
|
||||||
|
<EditingTools
|
||||||
|
fields={state.fields}
|
||||||
|
onFieldsUpdate={handleFieldsUpdate}
|
||||||
|
onGeometryChange={handleGeometryChange}
|
||||||
|
onSaveRequest={handleSaveRequest}
|
||||||
|
canvasWidth={800}
|
||||||
|
canvasHeight={600}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 使用指南标签页 */}
|
||||||
|
<TabsContent value="guide" className="mt-6">
|
||||||
|
<UsageGuide onSwitchToAdvanced={handleSwitchToAdvanced} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 新增地块对话框 */}
|
||||||
|
{state.showSaveDialog && state.pendingGeometry && (
|
||||||
|
<FieldEntryDialog
|
||||||
|
open={state.showSaveDialog}
|
||||||
|
onOpenChange={(open) => dispatch({ type: 'SET_SHOW_SAVE_DIALOG', payload: open })}
|
||||||
|
geometry={state.pendingGeometry}
|
||||||
|
onSaved={handleFieldSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user