From 304edcbb380045ab93f02e5c0be68fb792c108db Mon Sep 17 00:00:00 2001 From: peng Date: Thu, 30 Oct 2025 10:28:44 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E4=BA=A7=E7=AE=A1=E7=90=86=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=89=8D=E7=AB=AF=20-=20=E5=9C=B0=E5=9D=97=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=89=E4=B8=AA=E9=A1=B5=E9=9D=A2=E5=BC=80=E5=8F=91?= =?UTF-8?q?=EF=BC=9A1.=E5=9C=B0=E5=9D=97=E5=BD=B1=E5=83=8F=EF=BC=8C?= =?UTF-8?q?=E5=9C=9F=E5=A3=A4=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=EF=BC=8C?= =?UTF-8?q?=E5=88=86=E5=B1=82=E9=87=87=E6=A0=B7=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ControlPanel.tsx | 141 +++ .../components/DataAnalysis.tsx | 131 +++ .../layer-sampling/components/DataTable.tsx | 64 ++ .../layer-sampling/components/UsageGuide.tsx | 26 + .../components/Visualization3D.tsx | 260 ++++++ .../components/layerSamplingReducer.tsx | 66 ++ .../analysis/layer-sampling/page.tsx | 101 +- .../components/AddSamplePointDialog.tsx | 338 +++++++ .../components/DeleteConfirmDialog.tsx | 60 ++ .../components/EditSamplePointDialog.tsx | 344 +++++++ .../soil-data/components/LayerDataDialog.tsx | 165 ++++ .../components/ProfileInformation.tsx | 194 ++++ .../soil-data/components/SamplePointsList.tsx | 184 ++++ .../soil-data/components/SoilDataContent.tsx | 215 +++++ .../components/SpatialDistribution.tsx | 142 +++ .../components/StatisticalAnalysis.tsx | 246 +++++ .../soil-data/components/StatisticsCards.tsx | 81 ++ .../soil-data/components/UsageGuide.tsx | 27 + .../soil-data/components/soilDataReducer.tsx | 500 ++++++++++ .../analysis/soil-data/page.tsx | 24 +- .../satellite/components/FieldSatellite.tsx | 873 ++++++++++++++++++ .../components/satelliteImageService.ts | 352 +++++++ .../satellite/components/satelliteTypes.ts | 76 ++ .../land-information/map/satellite/page.tsx | 15 +- crop-x/src/components/layouts/Navbar.tsx | 8 +- 25 files changed, 4602 insertions(+), 31 deletions(-) create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/ControlPanel.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataAnalysis.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataTable.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/UsageGuide.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/Visualization3D.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/layerSamplingReducer.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/AddSamplePointDialog.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/DeleteConfirmDialog.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/EditSamplePointDialog.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/LayerDataDialog.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/ProfileInformation.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/SamplePointsList.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/SoilDataContent.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/SpatialDistribution.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticalAnalysis.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticsCards.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/UsageGuide.tsx create mode 100644 crop-x/src/app/(app)/land-information/analysis/soil-data/components/soilDataReducer.tsx create mode 100644 crop-x/src/app/(app)/land-information/map/satellite/components/FieldSatellite.tsx create mode 100644 crop-x/src/app/(app)/land-information/map/satellite/components/satelliteImageService.ts create mode 100644 crop-x/src/app/(app)/land-information/map/satellite/components/satelliteTypes.ts diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/ControlPanel.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/ControlPanel.tsx new file mode 100644 index 0000000..47d84ff --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/ControlPanel.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Box, Layers, Grid3x3, ZoomIn, ZoomOut } from 'lucide-react'; + +interface ControlPanelProps { + selectedField: string; + selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium'; + viewMode: '3d' | 'slice' | 'contour'; + depthSlice: number[]; + zoomLevel: number; + onFieldChange: (value: string) => void; + onNutrientChange: (value: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium') => void; + onViewModeChange: (value: '3d' | 'slice' | 'contour') => void; + onDepthSliceChange: (value: number[]) => void; + onZoomIn: () => void; + onZoomOut: () => void; +} + +export function ControlPanel({ + selectedField, + selectedNutrient, + viewMode, + depthSlice, + zoomLevel, + onFieldChange, + onNutrientChange, + onViewModeChange, + onDepthSliceChange, + onZoomIn, + onZoomOut, +}: ControlPanelProps) { + return ( +
+ + + + + + + + + + + + +
+ + + +
+
+ + + +
+ + {zoomLevel}% + +
+
+ + {viewMode === 'slice' && ( + + + +
+ 0cm + {depthSlice[0]}cm + 60cm +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataAnalysis.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataAnalysis.tsx new file mode 100644 index 0000000..bd8f4e9 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataAnalysis.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Activity, TrendingUp } from 'lucide-react'; + +interface DataAnalysisProps { + selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium'; +} + +export function DataAnalysis({ selectedNutrient }: DataAnalysisProps) { + const nutrientConfig = { + organic: { label: '有机质', unit: 'g/kg', color: '#22c55e', min: 0, max: 40 }, + nitrogen: { label: '全氮', unit: 'g/kg', color: '#3b82f6', min: 0, max: 2 }, + phosphorus: { label: '有效磷', unit: 'mg/kg', color: '#f59e0b', min: 0, max: 40 }, + potassium: { label: '速效钾', unit: 'mg/kg', color: '#a855f7', min: 0, max: 250 }, + }; + + const currentNutrient = nutrientConfig[selectedNutrient]; + + const layers3DData = [ + { depth: '0-20cm', avgValue: 28.5, distribution: [25, 28, 32, 27, 30, 26, 29, 31, 28, 27] }, + { depth: '20-40cm', avgValue: 20.2, distribution: [18, 21, 23, 19, 22, 20, 21, 22, 19, 20] }, + { depth: '40-60cm', avgValue: 13.5, distribution: [12, 14, 15, 13, 14, 12, 13, 15, 14, 13] }, + ]; + + const getColorForValue = (value: number, nutrient: typeof selectedNutrient) => { + const config = nutrientConfig[nutrient]; + const ratio = (value - config.min) / (config.max - config.min); + + if (ratio < 0.2) return '#ef4444'; + if (ratio < 0.4) return '#f97316'; + if (ratio < 0.6) return '#eab308'; + if (ratio < 0.8) return '#84cc16'; + return '#22c55e'; + }; + + return ( +
+ +
+ +

垂直分布趋势

+
+
+ {layers3DData.map((layer, index) => ( +
+
+ {layer.depth} + + {layer.avgValue} {currentNutrient.unit} + +
+
+
+
+
+ ))} +
+ +
+
+
+ 递减率: + -29.1%/20cm +
+
+ 表层富集: + 明显 +
+
+
+ + + +
+ +

水平变异性

+
+
+
+ 变异系数: + 14.8% +
+
+ 变异等级: + 中等变异 +
+
+ 最高值: + 32.1 {currentNutrient.unit} +
+
+ 最低值: + 22.3 {currentNutrient.unit} +
+
+ 极差: + 9.8 {currentNutrient.unit} +
+
+
+ + +

插值算法

+
+

克里金插值(Kriging)

+

• 考虑空间自相关性

+

• 最佳无偏估计

+

• 适用于土壤养分空间预测

+
+
+ + +

分析结论

+
    +
  • • 养分在垂直方向呈递减分布
  • +
  • • 表层(0-20cm)含量最高
  • +
  • • 水平分布呈中等变异
  • +
  • • 建议分层精准施肥
  • +
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataTable.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataTable.tsx new file mode 100644 index 0000000..28b7ca8 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/DataTable.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Card } from '@/components/ui/card'; + +export function DataTable() { + return ( + +

分层数据详细对比

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
土层深度有机质 (g/kg)全氮 (g/kg)有效磷 (mg/kg)速效钾 (mg/kg)平均pH值含水量 (%)
0-20cm28.51.225.31806.822.5
20-40cm20.20.818.51456.525.8
40-60cm13.50.512.3986.328.2
层间变化率-29.1%-33.3%-26.8%-19.4%-7.4%+25.3%
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/UsageGuide.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/UsageGuide.tsx new file mode 100644 index 0000000..d160545 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/UsageGuide.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { AlertCircle } from 'lucide-react'; + +export function UsageGuide() { + return ( + +
+ +
+

三维可视化分析说明:

+
    +
  • 三维模型: 立体展示土壤养分的空间分布,支持旋转和缩放
  • +
  • 立体切片: 选择任意深度查看该层的水平分布热力图
  • +
  • 等值面图: 以等值线形式展示养分浓度分布
  • +
  • 克里金插值: 基于采样点数据,使用地统计学方法生成连续分布模型
  • +
  • 垂直分析: 分析养分在土壤剖面的递减规律
  • +
  • 水平变异: 评估同一深度养分分布的均匀性
  • +
  • 精准施肥: 根据三维分布特征制定分层分区施肥方案
  • +
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/Visualization3D.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/Visualization3D.tsx new file mode 100644 index 0000000..5fd9bed --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/Visualization3D.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Box, Layers, Grid3x3, Eye, RotateCw, ZoomIn, ZoomOut } from 'lucide-react'; + +interface Visualization3DProps { + viewMode: '3d' | 'slice' | 'contour'; + selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium'; + depthSlice: number[]; + rotationAngle: number; + zoomLevel: number; + onRotate: () => void; + onZoomIn: () => void; + onZoomOut: () => void; +} + +export function Visualization3D({ + viewMode, + selectedNutrient, + depthSlice, + rotationAngle, + zoomLevel, + onRotate, + onZoomIn, + onZoomOut, +}: Visualization3DProps) { + const nutrientConfig = { + organic: { label: '有机质', unit: 'g/kg', color: '#22c55e', min: 0, max: 40 }, + nitrogen: { label: '全氮', unit: 'g/kg', color: '#3b82f6', min: 0, max: 2 }, + phosphorus: { label: '有效磷', unit: 'mg/kg', color: '#f59e0b', min: 0, max: 40 }, + potassium: { label: '速效钾', unit: 'mg/kg', color: '#a855f7', min: 0, max: 250 }, + }; + + const currentNutrient = nutrientConfig[selectedNutrient]; + + const layers3DData = [ + { depth: '0-20cm', avgValue: 28.5, distribution: [25, 28, 32, 27, 30, 26, 29, 31, 28, 27] }, + { depth: '20-40cm', avgValue: 20.2, distribution: [18, 21, 23, 19, 22, 20, 21, 22, 19, 20] }, + { depth: '40-60cm', avgValue: 13.5, distribution: [12, 14, 15, 13, 14, 12, 13, 15, 14, 13] }, + ]; + + const getColorForValue = (value: number, nutrient: typeof selectedNutrient) => { + const config = nutrientConfig[nutrient]; + const ratio = (value - config.min) / (config.max - config.min); + + if (ratio < 0.2) return '#ef4444'; + if (ratio < 0.4) return '#f97316'; + if (ratio < 0.6) return '#eab308'; + if (ratio < 0.8) return '#84cc16'; + return '#22c55e'; + }; + + return ( + +
+
+ +

+ {viewMode === '3d' && '三维分布模型'} + {viewMode === 'slice' && '立体切片图'} + {viewMode === 'contour' && '等值面图'} +

+
+ {currentNutrient.label} +
+ +
+ {viewMode === '3d' && ( +
+
+ {layers3DData.map((layer, layerIndex) => { + const offsetY = layerIndex * 60; + return ( +
+
+ {layer.distribution.map((value, cellIndex) => ( +
+ ))} +
+ +
+ {layer.depth} +
+
+ ); + })} + +
+ 水平方向 → +
+
+ 深度 ↓ +
+
+
+ )} + + {viewMode === 'slice' && ( +
+
+
选择切片深度
+
+ {depthSlice[0]}cm +
+ +
+ {Array.from({ length: 25 }).map((_, index) => { + const layerIndex = Math.floor(depthSlice[0] / 20); + const baseValue = layers3DData[layerIndex]?.avgValue || 15; + const variation = (Math.sin(index * 0.5) * 5); + const value = baseValue + variation; + + return ( +
+ + {value.toFixed(1)} + +
+ ); + })} +
+ +
+ {depthSlice[0]}cm 深度的水平切片 +
+
+
+ )} + + {viewMode === 'contour' && ( +
+
+ + {[30, 25, 20, 15, 10].map((value, index) => { + const radius = 50 + index * 40; + const color = getColorForValue(value, selectedNutrient); + return ( + + + + {value} {currentNutrient.unit} + + + ); + })} + +
+ 表层(0-20cm)等值线分布图 +
+
+
+ )} + +
+
+ + 视角: {rotationAngle}° +
+
+ +
+ + + +
+
+ +
+
+ 养分含量图例 + 单位: {currentNutrient.unit} +
+
+ {currentNutrient.min} +
+ {currentNutrient.max} +
+
+ 很低 + + 中等 + 较高 + +
+
+ + ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/layerSamplingReducer.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/layerSamplingReducer.tsx new file mode 100644 index 0000000..4b7ca0f --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/components/layerSamplingReducer.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { ReactNode } from 'react'; + +export interface LayerSamplingState { + selectedField: string; + selectedNutrient: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium'; + viewMode: '3d' | 'slice' | 'contour'; + depthSlice: number[]; + rotationAngle: number; + zoomLevel: number; +} + +export type LayerSamplingAction = + | { type: 'SET_SELECTED_FIELD'; payload: string } + | { type: 'SET_SELECTED_NUTRIENT'; payload: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium' } + | { type: 'SET_VIEW_MODE'; payload: '3d' | 'slice' | 'contour' } + | { type: 'SET_DEPTH_SLICE'; payload: number[] } + | { type: 'SET_ROTATION_ANGLE'; payload: number } + | { type: 'SET_ZOOM_LEVEL'; payload: number } + | { type: 'ROTATE' } + | { type: 'ZOOM_IN' } + | { type: 'ZOOM_OUT' }; + +export const initialState: LayerSamplingState = { + selectedField: 'field-1', + selectedNutrient: 'organic', + viewMode: '3d', + depthSlice: [20], + rotationAngle: 45, + zoomLevel: 100, +}; + +export function layerSamplingReducer(state: LayerSamplingState, action: LayerSamplingAction): LayerSamplingState { + switch (action.type) { + case 'SET_SELECTED_FIELD': + return { ...state, selectedField: action.payload }; + + case 'SET_SELECTED_NUTRIENT': + return { ...state, selectedNutrient: action.payload }; + + case 'SET_VIEW_MODE': + return { ...state, viewMode: action.payload }; + + case 'SET_DEPTH_SLICE': + return { ...state, depthSlice: action.payload }; + + case 'SET_ROTATION_ANGLE': + return { ...state, rotationAngle: action.payload }; + + case 'SET_ZOOM_LEVEL': + return { ...state, zoomLevel: action.payload }; + + case 'ROTATE': + return { ...state, rotationAngle: (state.rotationAngle + 45) % 360 }; + + case 'ZOOM_IN': + return { ...state, zoomLevel: Math.min(200, state.zoomLevel + 10) }; + + case 'ZOOM_OUT': + return { ...state, zoomLevel: Math.max(50, state.zoomLevel - 10) }; + + default: + return state; + } +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/page.tsx b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/page.tsx index c1fdc1e..a872b5c 100644 --- a/crop-x/src/app/(app)/land-information/analysis/layer-sampling/page.tsx +++ b/crop-x/src/app/(app)/land-information/analysis/layer-sampling/page.tsx @@ -1,18 +1,107 @@ 'use client'; +import { useReducer } from 'react'; +import { toast } from 'sonner'; import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { RotateCw } from 'lucide-react'; +import { + layerSamplingReducer, + initialState, + LayerSamplingState, + LayerSamplingAction +} from './components/layerSamplingReducer'; +import { ControlPanel } from './components/ControlPanel'; +import { Visualization3D } from './components/Visualization3D'; +import { DataAnalysis } from './components/DataAnalysis'; +import { DataTable } from './components/DataTable'; +import { UsageGuide } from './components/UsageGuide'; export default function LayerSamplingPage() { + const [state, dispatch] = useReducer(layerSamplingReducer, initialState); + + const handleFieldChange = (value: string) => { + dispatch({ type: 'SET_SELECTED_FIELD', payload: value }); + }; + + const handleNutrientChange = (value: 'organic' | 'nitrogen' | 'phosphorus' | 'potassium') => { + dispatch({ type: 'SET_SELECTED_NUTRIENT', payload: value }); + }; + + const handleViewModeChange = (value: '3d' | 'slice' | 'contour') => { + dispatch({ type: 'SET_VIEW_MODE', payload: value }); + }; + + const handleDepthSliceChange = (value: number[]) => { + dispatch({ type: 'SET_DEPTH_SLICE', payload: value }); + }; + + const handleRotate = () => { + dispatch({ type: 'ROTATE' }); + toast.success(`旋转至 ${state.rotationAngle}°`); + }; + + const handleZoomIn = () => { + dispatch({ type: 'ZOOM_IN' }); + }; + + const handleZoomOut = () => { + dispatch({ type: 'ZOOM_OUT' }); + }; + return (
- -

分层采样分析

-
-

- 页面路径: /land-information/analysis/layer-sampling +

+
+

分层采样分析

+

+ 三维可视化展示土壤养分在垂直和水平方向的分布规律

- +
+ +
+
+ + + +
+
+ +
+ +
+ +
+
+ + + +
); } \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/AddSamplePointDialog.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/AddSamplePointDialog.tsx new file mode 100644 index 0000000..b888ce3 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/AddSamplePointDialog.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Plus, X, Save, Droplets, Leaf, Zap, TrendingUp } from 'lucide-react'; +import { SoilDataState, SoilDataAction } from './soilDataReducer'; + +interface AddSamplePointDialogProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function AddSamplePointDialog({ state, dispatch }: AddSamplePointDialogProps) { + const handleAddSamplePoint = () => { + const newPoint = state.newPoint; + + if (!newPoint.code || !newPoint.fieldName || !newPoint.sampleDate || !newPoint.sampler) { + return; + } + + if (!newPoint.latitude || !newPoint.longitude || newPoint.latitude === 0 || newPoint.longitude === 0) { + return; + } + + // 从设备读取数据并填充到分层信息 + const layersWithData = (newPoint.layers || []).map(layer => { + if (layer.deviceId) { + const device = state.iotDevices.find(d => d.id === layer.deviceId); + if (device) { + return { + ...layer, + pH: device.data.pH, + organicMatter: device.data.organicMatter, + nitrogen: device.data.nitrogen, + phosphorus: device.data.phosphorus, + potassium: device.data.potassium, + moisture: device.data.moisture, + }; + } + } + return layer; + }); + + const samplePoint = { + id: `sp-${Date.now()}`, + ...newPoint, + layers: layersWithData, + } as any; + + dispatch({ type: 'ADD_SAMPLE_POINT', payload: samplePoint }); + dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: false }); + dispatch({ type: 'RESET_NEW_POINT' }); + }; + + const handleMapPointSelect = (lat: number, lng: number) => { + dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: lat, longitude: lng } }); + }; + + const handleAddLayer = () => { + const currentLayers = state.newPoint.layers || []; + const newLayer = { + depth: `${currentLayers.length * 20}-${(currentLayers.length + 1) * 20}cm`, + deviceId: '', + }; + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { + layers: [...currentLayers, newLayer] + } + }); + }; + + const handleUpdateLayerDevice = (layerIndex: number, deviceId: string) => { + const updatedLayers = [...(state.newPoint.layers || [])]; + updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], deviceId }; + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { layers: updatedLayers } + }); + }; + + const handleRemoveLayer = (index: number) => { + const currentLayers = state.newPoint.layers || []; + if (currentLayers.length > 1) { + const updatedLayers = currentLayers.filter((_, i) => i !== index); + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { layers: updatedLayers } + }); + } + }; + + return ( + { + if (!open) { + dispatch({ type: 'SET_SHOW_ADD_DIALOG', payload: false }); + dispatch({ type: 'RESET_NEW_POINT' }); + } + }}> + + + 新增采样点 + + 添加新的土壤采样点,包含GPS坐标定位和分层数据录入 + + + +
+ {/* 基本信息 */} +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { code: e.target.value } })} + placeholder="如: SP004" + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { fieldName: e.target.value } })} + placeholder="如: 东区1号地" + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampleDate: e.target.value } })} + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampler: e.target.value } })} + placeholder="如: 张三" + /> +
+
+ + + {state.newPoint.deviceId && (() => { + const device = state.iotDevices.find(d => d.id === state.newPoint.deviceId); + return device ? ( +
+
设备实时数据(最后更新:{device.data.lastUpdate})
+
+
pH: {device.data.pH}
+
有机质: {device.data.organicMatter}
+
全氮: {device.data.nitrogen}
+
有效磷: {device.data.phosphorus}
+
速效钾: {device.data.potassium}
+
含水量: {device.data.moisture}%
+
+
+ ) : null; + })()} +
+
+ + {/* GPS坐标定位 */} +
+ +
+
+ dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: parseFloat(e.target.value) || 0 } })} + placeholder="纬度" + step="0.000001" + /> +
+
+ dispatch({ type: 'UPDATE_NEW_POINT', payload: { longitude: parseFloat(e.target.value) || 0 } })} + placeholder="经度" + step="0.000001" + /> +
+
+ +
+

地图选择器

+

请在上方输入坐标或使用地图选择器

+
+
+
+ + {/* 分层数据 */} +
+
+ + +
+
+ {(state.newPoint.layers || []).map((layer, layerIndex) => ( + +
+

第 {layerIndex + 1} 层 ({layer.depth})

+ {(state.newPoint.layers || []).length > 1 && ( + + )} +
+
+
+ + { + const updatedLayers = [...(state.newPoint.layers || [])]; + updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], depth: e.target.value }; + dispatch({ type: 'UPDATE_NEW_POINT', payload: { layers: updatedLayers } }); + }} + placeholder="如: 0-20cm" + /> +
+
+ + +
+
+ {layer.deviceId && (() => { + const device = state.iotDevices.find(d => d.id === layer.deviceId); + return device ? ( +
+
从设备读取的数据(最后更新:{device.data.lastUpdate})
+
+
+ + pH: {device.data.pH} +
+
+ + 有机质: {device.data.organicMatter} g/kg +
+
+ + 全氮: {device.data.nitrogen} g/kg +
+
+ + 有效磷: {device.data.phosphorus} mg/kg +
+
+ + 速效钾: {device.data.potassium} mg/kg +
+
+ + 含水量: {device.data.moisture}% +
+
+
+ ) : null; + })()} +
+ ))} +
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/DeleteConfirmDialog.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/DeleteConfirmDialog.tsx new file mode 100644 index 0000000..a0a8453 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/DeleteConfirmDialog.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import { SoilDataState, SoilDataAction } from './soilDataReducer'; + +interface DeleteConfirmDialogProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function DeleteConfirmDialog({ state, dispatch }: DeleteConfirmDialogProps) { + const handleConfirmDelete = () => { + if (state.pointToDelete) { + dispatch({ type: 'DELETE_SAMPLE_POINT', payload: state.pointToDelete }); + dispatch({ type: 'SET_POINT_TO_DELETE', payload: null }); + dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: false }); + } + }; + + const handleCancel = () => { + dispatch({ type: 'SET_POINT_TO_DELETE', payload: null }); + dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: false }); + }; + + const pointToDelete = state.samplePoints.find(p => p.id === state.pointToDelete); + + return ( + { + if (!open) { + handleCancel(); + } + }}> + + + 确认删除采样点 + + 确定要删除采样点{pointToDelete?.code}吗? + {pointToDelete && ( + + 位置:{pointToDelete.fieldName} | 采样日期:{pointToDelete.sampleDate} + + )} + 此操作将删除该采样点的所有数据,包括分层信息和理化指标,且无法恢复。 + + + + + 取消 + + + 确认删除 + + + + + ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/EditSamplePointDialog.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/EditSamplePointDialog.tsx new file mode 100644 index 0000000..6bb900f --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/EditSamplePointDialog.tsx @@ -0,0 +1,344 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Plus, X, Save, Droplets, Leaf, Zap, TrendingUp } from 'lucide-react'; +import { SoilDataState, SoilDataAction } from './soilDataReducer'; + +interface EditSamplePointDialogProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function EditSamplePointDialog({ state, dispatch }: EditSamplePointDialogProps) { + const handleUpdateSamplePoint = () => { + const newPoint = state.newPoint; + + if (!newPoint.code || !newPoint.fieldName || !newPoint.sampleDate || !newPoint.sampler) { + return; + } + + if (!newPoint.latitude || !newPoint.longitude || newPoint.latitude === 0 || newPoint.longitude === 0) { + return; + } + + if (!state.selectedPoint) return; + + // 从设备读取数据并填充到分层信息 + const layersWithData = (newPoint.layers || []).map(layer => { + if (layer.deviceId) { + const device = state.iotDevices.find(d => d.id === layer.deviceId); + if (device) { + return { + ...layer, + pH: device.data.pH, + organicMatter: device.data.organicMatter, + nitrogen: device.data.nitrogen, + phosphorus: device.data.phosphorus, + potassium: device.data.potassium, + moisture: device.data.moisture, + }; + } + } + return layer; + }); + + const updatedPoint = { + ...state.selectedPoint, + ...newPoint, + layers: layersWithData, + }; + + dispatch({ type: 'UPDATE_SAMPLE_POINT', payload: updatedPoint }); + dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: false }); + dispatch({ type: 'SET_SELECTED_POINT', payload: null }); + dispatch({ type: 'RESET_NEW_POINT' }); + }; + + const handleMapPointSelect = (lat: number, lng: number) => { + dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: lat, longitude: lng } }); + }; + + const handleAddLayer = () => { + const currentLayers = state.newPoint.layers || []; + const newLayer = { + depth: `${currentLayers.length * 20}-${(currentLayers.length + 1) * 20}cm`, + deviceId: '', + }; + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { + layers: [...currentLayers, newLayer] + } + }); + }; + + const handleUpdateLayerDevice = (layerIndex: number, deviceId: string) => { + const updatedLayers = [...(state.newPoint.layers || [])]; + updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], deviceId }; + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { layers: updatedLayers } + }); + }; + + const handleRemoveLayer = (index: number) => { + const currentLayers = state.newPoint.layers || []; + if (currentLayers.length > 1) { + const updatedLayers = currentLayers.filter((_, i) => i !== index); + dispatch({ + type: 'UPDATE_NEW_POINT', + payload: { layers: updatedLayers } + }); + } + }; + + return ( + { + if (!open) { + dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: false }); + dispatch({ type: 'SET_SELECTED_POINT', payload: null }); + dispatch({ type: 'RESET_NEW_POINT' }); + } + }}> + + + 编辑采样点 + + 编辑土壤采样点信息,包含GPS坐标定位和分层数据修改 + + + + {state.selectedPoint && ( +
+ {/* 基本信息 */} +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { code: e.target.value } })} + placeholder="如: SP004" + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { fieldName: e.target.value } })} + placeholder="如: 东区1号地" + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampleDate: e.target.value } })} + /> +
+
+ + dispatch({ type: 'UPDATE_NEW_POINT', payload: { sampler: e.target.value } })} + placeholder="如: 张三" + /> +
+
+ + + {state.newPoint.deviceId && (() => { + const device = state.iotDevices.find(d => d.id === state.newPoint.deviceId); + return device ? ( +
+
设备实时数据(最后更新:{device.data.lastUpdate})
+
+
pH: {device.data.pH}
+
有机质: {device.data.organicMatter}
+
全氮: {device.data.nitrogen}
+
有效磷: {device.data.phosphorus}
+
速效钾: {device.data.potassium}
+
含水量: {device.data.moisture}%
+
+
+ ) : null; + })()} +
+
+ + {/* GPS坐标定位 */} +
+ +
+
+ dispatch({ type: 'UPDATE_NEW_POINT', payload: { latitude: parseFloat(e.target.value) || 0 } })} + placeholder="纬度" + step="0.000001" + /> +
+
+ dispatch({ type: 'UPDATE_NEW_POINT', payload: { longitude: parseFloat(e.target.value) || 0 } })} + placeholder="经度" + step="0.000001" + /> +
+
+ +
+

地图选择器

+

请在上方输入坐标或使用地图选择器

+
+
+
+ + {/* 分层数据 */} +
+
+ + +
+
+ {(state.newPoint.layers || []).map((layer, layerIndex) => ( + +
+

第 {layerIndex + 1} 层 ({layer.depth})

+ {(state.newPoint.layers || []).length > 1 && ( + + )} +
+
+
+ + { + const updatedLayers = [...(state.newPoint.layers || [])]; + updatedLayers[layerIndex] = { ...updatedLayers[layerIndex], depth: e.target.value }; + dispatch({ type: 'UPDATE_NEW_POINT', payload: { layers: updatedLayers } }); + }} + placeholder="如: 0-20cm" + /> +
+
+ + +
+
+ {layer.deviceId && (() => { + const device = state.iotDevices.find(d => d.id === layer.deviceId); + return device ? ( +
+
从设备读取的数据(最后更新:{device.data.lastUpdate})
+
+
+ + pH: {device.data.pH} +
+
+ + 有机质: {device.data.organicMatter} g/kg +
+
+ + 全氮: {device.data.nitrogen} g/kg +
+
+ + 有效磷: {device.data.phosphorus} mg/kg +
+
+ + 速效钾: {device.data.potassium} mg/kg +
+
+ + 含水量: {device.data.moisture}% +
+
+
+ ) : null; + })()} +
+ ))} +
+
+ +
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/LayerDataDialog.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/LayerDataDialog.tsx new file mode 100644 index 0000000..4a074b1 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/LayerDataDialog.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { SoilDataState, SoilDataAction, getPHLevel, getOrganicMatterLevel } from './soilDataReducer'; + +interface LayerDataDialogProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function LayerDataDialog({ state, dispatch }: LayerDataDialogProps) { + if (!state.selectedPoint) return null; + + return ( + { + if (!open) { + dispatch({ type: 'SET_SHOW_LAYER_DIALOG', payload: false }); + dispatch({ type: 'SET_SELECTED_POINT', payload: null }); + } + }}> + + + 土壤剖面分层数据 - {state.selectedPoint.code} + + 查看土壤剖面的分层详细数据 + + + +
+ {/* 基本信息 */} +
+
+ 采样点: +

{state.selectedPoint.code}

+
+
+ 地块: +

{state.selectedPoint.fieldName}

+
+
+ 采样日期: +

{state.selectedPoint.sampleDate}

+
+
+ 采样人: +

{state.selectedPoint.sampler}

+
+
+ + {/* 坐标信息 */} +
+
+ 纬度: +

+ {state.selectedPoint.latitude.toFixed(6)} +

+
+
+ 经度: +

+ {state.selectedPoint.longitude.toFixed(6)} +

+
+
+ + {/* 分层数据表格 */} +
+ + + + + + + + + + + + + + {state.selectedPoint.layers.map((layer, index) => ( + + + + + + + + + + ))} + +
深度pH值有机质全氮有效磷速效钾含水量
{layer.depth} +
+ {layer.pH} +
+
+
+ + {layer.organicMatter} g/kg + + {layer.nitrogen} g/kg{layer.phosphorus} mg/kg{layer.potassium} mg/kg{layer.moisture}%
+
+ + {/* 设备绑定信息 */} + {state.selectedPoint.deviceId && ( +
+

绑定的IoT设备

+ {(() => { + const device = state.iotDevices.find(d => d.id === state.selectedPoint?.deviceId); + return device ? ( +
+

设备编号: {device.code}

+

设备名称: {device.name}

+

设备类型: {device.type}

+

设备状态: + + {device.status === 'online' ? '在线' : '离线'} + +

+

最后更新: {device.data.lastUpdate}

+
+ ) : null; + })()} +
+ )} + + {/* 土壤质量评估 */} +
+

土壤质量评估

+
+

pH状况: + {state.selectedPoint.layers[0]?.pH + ? ` 表层pH值为${state.selectedPoint.layers[0].pH},呈${getPHLevel(state.selectedPoint.layers[0].pH).label}反应` + : ' 数据不足,无法评估' + } +

+

有机质含量: + {state.selectedPoint.layers[0]?.organicMatter + ? ` 表层有机质含量为${state.selectedPoint.layers[0].organicMatter} g/kg,${getOrganicMatterLevel(state.selectedPoint.layers[0].organicMatter).label}水平` + : ' 数据不足,无法评估' + } +

+

养分状况: + {state.selectedPoint.layers[0]?.nitrogen && state.selectedPoint.layers[0]?.phosphorus && state.selectedPoint.layers[0]?.potassium + ? ` 氮磷钾含量分别为${state.selectedPoint.layers[0].nitrogen}、${state.selectedPoint.layers[0].phosphorus}、${state.selectedPoint.layers[0].potassium} mg/kg` + : ' 数据不足,无法评估' + } +

+

综合评价: + {state.selectedPoint.layers[0]?.pH && state.selectedPoint.layers[0]?.organicMatter + ? (state.selectedPoint.layers[0].pH >= 6.5 && state.selectedPoint.layers[0].pH <= 7.5 && state.selectedPoint.layers[0].organicMatter >= 20 + ? ' 土壤理化性状良好,适宜大多数作物生长' + : ' 土壤需要改良,建议增施有机肥和调节pH值') + : ' 数据不足,无法进行综合评价' + } +

+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/ProfileInformation.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/ProfileInformation.tsx new file mode 100644 index 0000000..96bc733 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/ProfileInformation.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { SamplePoint, getPHLevel, getOrganicMatterLevel } from './soilDataReducer'; + +interface ProfileInformationProps { + samplePoints: SamplePoint[]; +} + +export default function ProfileInformation({ samplePoints }: ProfileInformationProps) { + const selectedPoint = samplePoints[0]; // 默认选择第一个采样点 + + if (!selectedPoint) { + return ( + +
+

暂无采样点数据

+

请先添加采样点数据

+
+
+ ); + } + + return ( +
+ +
+
+ + +
+
+
+ + {/* 剖面可视化 */} + +

土壤剖面可视化

+
+ {/* 左侧剖面图 */} +
+
+ {selectedPoint.layers.map((layer, index) => ( +
+
+
{layer.depth}
+
+
+ pH: {layer.pH} +
+
+ ))} +
+
深度剖面图
+
+ + {/* 右侧数据表格 */} +
+
+ + + + + + + + + + + + + + {selectedPoint.layers.map((layer, index) => ( + + + + + + + + + + ))} + +
深度pH值有机质(g/kg)全氮(g/kg)有效磷(mg/kg)速效钾(mg/kg)含水量(%)
{layer.depth} + + {layer.pH} + + + + {layer.organicMatter} + + {layer.nitrogen}{layer.phosphorus}{layer.potassium}{layer.moisture}%
+
+ + {/* 趋势分析 */} +
+

剖面特征分析

+
    +
  • pH值: 随深度增加{selectedPoint.layers[0]?.pH > (selectedPoint.layers[selectedPoint.layers.length - 1]?.pH || 0) ? '下降' : '上升'},表层为{getPHLevel(selectedPoint.layers[0]?.pH || 0).label}
  • +
  • 有机质: 表层含量高,向下递减,符合正常分布规律
  • +
  • 养分: 氮磷钾含量表层最高,20cm以下显著降低
  • +
  • 水分: 随深度增加含水量上升,底层保水性好
  • +
+
+
+
+
+ + {/* 分层数据详情 */} + +

分层数据详细记录

+
+ {selectedPoint.layers.map((layer, index) => ( +
+
+

第 {index + 1} 层

+ {layer.depth} +
+ +
+
+
+ pH值: +
+ {layer.pH} +
+
+
+
+ 有机质: + {layer.organicMatter} g/kg +
+
+ 全氮: + {layer.nitrogen} g/kg +
+
+ +
+
+ 有效磷: + {layer.phosphorus} mg/kg +
+
+ 速效钾: + {layer.potassium} mg/kg +
+
+ 含水量: + {layer.moisture}% +
+
+
+ + {/* 营养状况评估 */} +
+
+ 营养状况: + + {layer.organicMatter && layer.organicMatter > 25 ? '营养丰富' : + layer.organicMatter && layer.organicMatter > 15 ? '营养中等' : '营养偏低'} + +
+
+
+ ))} +
+ +
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SamplePointsList.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SamplePointsList.tsx new file mode 100644 index 0000000..cc3f68c --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SamplePointsList.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Search, MapPin, Layers, Edit, Trash2, Leaf, Zap, TrendingUp, Droplets } from 'lucide-react'; +import { SoilDataState, SoilDataAction, getPHLevel } from './soilDataReducer'; + +interface SamplePointsListProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function SamplePointsList({ state, dispatch }: SamplePointsListProps) { + const handleViewLayers = (point: any) => { + dispatch({ type: 'SET_SELECTED_POINT', payload: point }); + dispatch({ type: 'SET_SHOW_LAYER_DIALOG', payload: true }); + }; + + const handleEditPoint = (point: any) => { + dispatch({ type: 'SET_SELECTED_POINT', payload: point }); + dispatch({ type: 'SET_NEW_POINT', payload: point }); + dispatch({ type: 'SET_SHOW_EDIT_DIALOG', payload: true }); + }; + + const handleDeleteClick = (pointId: string) => { + dispatch({ type: 'SET_POINT_TO_DELETE', payload: pointId }); + dispatch({ type: 'SET_SHOW_DELETE_DIALOG', payload: true }); + }; + + const handleSearchChange = (value: string) => { + dispatch({ type: 'SET_FILTERS', payload: { searchKeyword: value } }); + }; + + const handleFieldChange = (value: string) => { + dispatch({ type: 'SET_FILTERS', payload: { selectedField: value } }); + }; + + // 筛选采样点 + const filteredPoints = state.samplePoints.filter(point => { + const matchesSearch = !state.filters.searchKeyword || + point.code.toLowerCase().includes(state.filters.searchKeyword.toLowerCase()) || + point.fieldName.toLowerCase().includes(state.filters.searchKeyword.toLowerCase()); + + const matchesField = state.filters.selectedField === 'all' || + point.fieldName === state.filters.selectedField; + + return matchesSearch && matchesField; + }); + + return ( +
+ {/* 搜索和筛选 */} + +
+
+
+ + handleSearchChange(e.target.value)} + className="pl-10" + /> +
+
+ +
+
+ + {/* 采样点列表 */} +
+ {filteredPoints.map((point) => ( + +
+
+
+ +
+
+

{point.code}

+ {point.fieldName} +
+

+ 坐标: {point.latitude.toFixed(6)}, {point.longitude.toFixed(6)} +

+
+
+ +
+
+ 采样日期 +

{point.sampleDate}

+
+
+ 采样人 +

{point.sampler}

+
+
+ 分层数 +

{point.layers.length} 层

+
+
+ 表层pH值 +
+ {point.layers[0]?.pH} +
+
+
+
+ + {/* 表层指标快览 */} + {point.layers[0] && ( +
+
表层指标(0-20cm)
+
+
+ + 有机质: {point.layers[0].organicMatter} g/kg +
+
+ + 全氮: {point.layers[0].nitrogen} g/kg +
+
+ + 有效磷: {point.layers[0].phosphorus} mg/kg +
+
+ + 速效钾: {point.layers[0].potassium} mg/kg +
+
+ + 含水量: {point.layers[0].moisture}% +
+
+
+ )} +
+ +
+ + + +
+
+ + ))} + + {filteredPoints.length === 0 && ( + +
+ +

暂无采样点数据

+

点击"新增采样点"开始添加数据

+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SoilDataContent.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SoilDataContent.tsx new file mode 100644 index 0000000..20020a7 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SoilDataContent.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Download, FileText, Plus, MapPin, Layers, BarChart3, Droplets } from 'lucide-react'; +import { SoilDataState, SoilDataAction } from './soilDataReducer'; +import StatisticsCards from './StatisticsCards'; +import SamplePointsList from './SamplePointsList'; +import SpatialDistribution from './SpatialDistribution'; +import ProfileInformation from './ProfileInformation'; +import StatisticalAnalysis from './StatisticalAnalysis'; +import AddSamplePointDialog from './AddSamplePointDialog'; +import EditSamplePointDialog from './EditSamplePointDialog'; +import LayerDataDialog from './LayerDataDialog'; +import DeleteConfirmDialog from './DeleteConfirmDialog'; +import UsageGuide from './UsageGuide'; + +interface SoilDataContentProps { + state: SoilDataState; + dispatch: React.Dispatch; +} + +export default function SoilDataContent({ state, dispatch }: SoilDataContentProps) { + const handleExportData = () => { + // 生成CSV数据 + const headers = ['采样点编号', '地块', '纬度', '经度', '采样日期', '采样人', '深度', 'pH值', '有机质(g/kg)', '全氮(g/kg)', '有效磷(mg/kg)', '速效钾(mg/kg)', '含水量(%)']; + const rows = state.samplePoints.flatMap(point => + point.layers.map(layer => [ + point.code, + point.fieldName, + point.latitude, + point.longitude, + point.sampleDate, + point.sampler, + layer.depth, + layer.pH, + layer.organicMatter, + layer.nitrogen, + layer.phosphorus, + layer.potassium, + layer.moisture, + ]) + ); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')), + ].join('\n'); + + // 创建下载链接 + const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `土壤基础数据_${new Date().toLocaleDateString()}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleGenerateReport = () => { + // 生成报告HTML内容 + const reportHTML = ` + + + + + 土壤检测报告 + + + +

土壤基础数据检测报告

+
+

报告概要

+

生成日期:${new Date().toLocaleDateString()}

+

采样点总数:${state.samplePoints.length} 个

+

覆盖地块:${state.statistics?.totalFields || 0} 个

+

分层样本:${state.statistics?.totalLayers || 0} 层

+
+ +

采样点详细数据

+ ${state.samplePoints.map(point => ` +

${point.code} - ${point.fieldName}

+

坐标:${point.latitude.toFixed(6)}, ${point.longitude.toFixed(6)}

+

采样日期:${point.sampleDate} | 采样人:${point.sampler}

+ + + + + + + + + + + + + + ${point.layers.map(layer => ` + + + + + + + + + + `).join('')} + +
深度pH值有机质(g/kg)全氮(g/kg)有效磷(mg/kg)速效钾(mg/kg)含水量(%)
${layer.depth}${layer.pH}${layer.organicMatter}${layer.nitrogen}${layer.phosphorus}${layer.potassium}${layer.moisture}
+ `).join('')} + + + + + `; + + // 创建下载链接 + const blob = new Blob([reportHTML], { type: 'text/html;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `土壤检测报告_${new Date().toLocaleDateString()}.html`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+ {/* 操作按钮区域 */} +
+
+

+ 土壤采样点数据管理 +

+

+ 管理土壤采样点、记录分层数据、分析土壤理化性状 +

+
+
+ + + +
+
+ + {/* 统计卡片 */} + + + {/* 主要内容区域 */} + dispatch({ type: 'SET_ACTIVE_TAB', payload: value })}> + + 采样点列表 + 空间分布 + 剖面信息 + 统计分析 + + + + + + + + + + + + + + + + + + + + {/* 对话框组件 */} + + + + + + {/* 使用说明 */} + +
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SpatialDistribution.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SpatialDistribution.tsx new file mode 100644 index 0000000..efa44dd --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/SpatialDistribution.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { SamplePoint } from './soilDataReducer'; + +interface SpatialDistributionProps { + samplePoints: SamplePoint[]; +} + +export default function SpatialDistribution({ samplePoints }: SpatialDistributionProps) { + // 计算地图中心点 + const getCenterPoint = () => { + if (samplePoints.length === 0) { + return { lat: 39.9042, lng: 116.4074 }; // 默认北京天安门 + } + + const avgLat = samplePoints.reduce((sum, p) => sum + p.latitude, 0) / samplePoints.length; + const avgLng = samplePoints.reduce((sum, p) => sum + p.longitude, 0) / samplePoints.length; + + return { lat: avgLat, lng: avgLng }; + }; + + const center = getCenterPoint(); + + return ( +
+ +

采样点空间分布图

+ + {/* 地图容器 - 暂时用占位符替代真实地图 */} +
+ {/* 地图占位符 */} +
+
+
+ + + +
+

地图组件加载中...

+

中心坐标: {center.lat.toFixed(4)}, {center.lng.toFixed(4)}

+
+
+ + {/* 模拟采样点标记 */} + {samplePoints.map((point, index) => { + const relativeLat = ((point.latitude - center.lat) * 10000 + 50) % 100; + const relativeLng = ((point.longitude - center.lng) * 10000 + 50) % 100; + + return ( +
+ ); + })} + + {/* 图例 */} +
+

图例

+
+
+
+ 酸性土壤 (pH < 6.5) +
+
+
+ 中性土壤 (6.5-7.5) +
+
+
+ 碱性土壤 (pH > 7.5) +
+
+
+
+ + + {/* 空间分布统计 */} +
+ +

采样密度统计

+
+ {Array.from(new Set(samplePoints.map(p => p.fieldName))).map(fieldName => { + const fieldPoints = samplePoints.filter(p => p.fieldName === fieldName); + return ( +
+ {fieldName}: + {fieldPoints.length} 个点 +
+ ); + })} +
+
+ + +

pH值分布

+
+ {[ + { label: '酸性', color: 'bg-orange-500', count: samplePoints.filter(p => p.layers[0]?.pH < 6.5).length }, + { label: '中性', color: 'bg-green-500', count: samplePoints.filter(p => p.layers[0]?.pH >= 6.5 && p.layers[0]?.pH < 7.5).length }, + { label: '碱性', color: 'bg-blue-500', count: samplePoints.filter(p => p.layers[0]?.pH >= 7.5).length }, + ].map(item => ( +
+
+
+ {item.label} +
+ {item.count} +
+ ))} +
+ + + +

覆盖范围

+
+
+ 总采样点: + {samplePoints.length} 个 +
+
+ 覆盖地块: + {new Set(samplePoints.map(p => p.fieldName)).size} 个 +
+
+ 分层数据: + {samplePoints.reduce((sum, p) => sum + p.layers.length, 0)} 层 +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticalAnalysis.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticalAnalysis.tsx new file mode 100644 index 0000000..feb7ad5 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticalAnalysis.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { SoilDataStatistics } from './soilDataReducer'; + +interface StatisticalAnalysisProps { + statistics: SoilDataStatistics | null; +} + +export default function StatisticalAnalysis({ statistics }: StatisticalAnalysisProps) { + if (!statistics) { + return ( + +
+

暂无统计数据

+

请先添加采样点数据

+
+
+ ); + } + + const totalSamples = statistics.phDistribution.strongAcidic + + statistics.phDistribution.acidic + + statistics.phDistribution.neutral + + statistics.phDistribution.alkaline + + statistics.phDistribution.strongAlkaline; + + const calculatePercentage = (value: number) => { + return totalSamples > 0 ? ((value / totalSamples) * 100).toFixed(0) : '0'; + }; + + return ( +
+
+ {/* pH值分布统计 */} + +

pH值分布统计

+
+
+
+ 强酸性 (<5.5) + {statistics.phDistribution.strongAcidic} 个 ({calculatePercentage(statistics.phDistribution.strongAcidic)}%) +
+
+
+
+
+
+
+ 酸性 (5.5-6.5) + {statistics.phDistribution.acidic} 个 ({calculatePercentage(statistics.phDistribution.acidic)}%) +
+
+
+
+
+
+
+ 中性 (6.5-7.5) + {statistics.phDistribution.neutral} 个 ({calculatePercentage(statistics.phDistribution.neutral)}%) +
+
+
+
+
+
+
+ 碱性 (7.5-8.5) + {statistics.phDistribution.alkaline} 个 ({calculatePercentage(statistics.phDistribution.alkaline)}%) +
+
+
+
+
+
+
+ 强碱性 (>8.5) + {statistics.phDistribution.strongAlkaline} 个 ({calculatePercentage(statistics.phDistribution.strongAlkaline)}%) +
+
+
+
+
+
+ + {/* pH值评估 */} +
+
+ pH值评估: + {statistics.phDistribution.neutral > totalSamples * 0.6 ? + ' 土壤酸碱度适中,适宜大多数作物生长' : + statistics.phDistribution.acidic > totalSamples * 0.5 ? + ' 土壤偏酸性,建议适量施用石灰调节' : + statistics.phDistribution.alkaline > totalSamples * 0.5 ? + ' 土壤偏碱性,建议适量施用酸性肥料调节' : + ' 土壤酸碱度需要进一步检测和调节' + } +
+
+ + + {/* 有机质含量统计 */} + +

有机质含量统计

+
+
+
+ 极低 (<10 g/kg) + {statistics.organicMatterDistribution.veryLow} 个 ({calculatePercentage(statistics.organicMatterDistribution.veryLow)}%) +
+
+
+
+
+
+
+ 低 (10-20 g/kg) + {statistics.organicMatterDistribution.low} 个 ({calculatePercentage(statistics.organicMatterDistribution.low)}%) +
+
+
+
+
+
+
+ 中等 (20-30 g/kg) + {statistics.organicMatterDistribution.medium} 个 ({calculatePercentage(statistics.organicMatterDistribution.medium)}%) +
+
+
+
+
+
+
+ 较高 (30-40 g/kg) + {statistics.organicMatterDistribution.high} 个 ({calculatePercentage(statistics.organicMatterDistribution.high)}%) +
+
+
+
+
+
+
+ 高 (>40 g/kg) + {statistics.organicMatterDistribution.veryHigh} 个 ({calculatePercentage(statistics.organicMatterDistribution.veryHigh)}%) +
+
+
+
+
+
+ + {/* 有机质评估 */} +
+
+ 有机质评估: + {(statistics.organicMatterDistribution.high + statistics.organicMatterDistribution.veryHigh) > totalSamples * 0.5 ? + ' 土壤有机质含量丰富,肥力良好' : + (statistics.organicMatterDistribution.medium) > totalSamples * 0.5 ? + ' 土壤有机质含量中等,需要适量补充' : + ' 土壤有机质含量偏低,建议增施有机肥' + } +
+
+ +
+ + {/* 综合统计表格 */} + +

数据统计概览

+
+
+
+ {statistics.totalPoints} +
+
采样点总数
+
+
+
+ {statistics.totalFields} +
+
覆盖地块数
+
+
+
+ {statistics.totalLayers} +
+
分层样本数
+
+
+
+ {statistics.averagePH.toFixed(1)} +
+
平均pH值
+
+
+ + {/* 综合评估 */} +
+

综合评估建议

+
+

数据覆盖度:已覆盖 {statistics.totalFields} 个地块,采样密度{statistics.totalPoints > 10 ? '充足' : '适中'}

+

土壤状况:平均pH值{statistics.averagePH.toFixed(1)},整体呈{statistics.averagePH < 6.5 ? '酸性' : statistics.averagePH > 7.5 ? '碱性' : '中性'}反应

+

改进建议: + {statistics.phDistribution.neutral > totalSamples * 0.5 ? + ' 土壤酸碱度状况良好,继续保持当前管理方式' : + ' 建议进行土壤改良,调节pH值至适宜范围' + } +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticsCards.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticsCards.tsx new file mode 100644 index 0000000..12484a9 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/StatisticsCards.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { MapPin, Layers, BarChart3, Droplets } from 'lucide-react'; +import { SoilDataStatistics } from './soilDataReducer'; + +interface StatisticsCardsProps { + statistics: SoilDataStatistics | null; +} + +export default function StatisticsCards({ statistics }: StatisticsCardsProps) { + if (!statistics) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + +
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+ +
+
+

采样点总数

+

+ {statistics.totalPoints} +

+
+ +
+
+ + +
+
+

覆盖地块

+

+ {statistics.totalFields} 个 +

+
+ +
+
+ + +
+
+

分层样本

+

+ {statistics.totalLayers} 层 +

+
+ +
+
+ + +
+
+

平均pH值

+

+ {statistics.averagePH.toFixed(1)} +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/UsageGuide.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/UsageGuide.tsx new file mode 100644 index 0000000..47a773a --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/UsageGuide.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Card } from '@/components/ui/card'; +import { AlertCircle } from 'lucide-react'; + +export default function UsageGuide() { + return ( + +
+ +
+

土壤基础数据管理说明:

+
    +
  • 采样点管理: 记录GPS坐标、采样日期、负责人等基础信息
  • +
  • 分层采样: 支持多层次土壤数据(0-20cm、20-40cm、40-60cm等)
  • +
  • 理化指标: 记录pH值、有机质、氮磷钾、含水量等关键指标
  • +
  • 空间可视化: 在地图上显示采样点分布,分析覆盖密度
  • +
  • 剖面分析: 可视化展示土壤垂直分布特征
  • +
  • 统计分析: 自动计算平均值、标准差、变异系数等统计指标
  • +
  • 数据导出: 支持导出CSV格式,生成专业检测报告
  • +
  • IoT集成: 支持绑定物联网设备,自动读取土壤监测数据
  • +
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/components/soilDataReducer.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/soilDataReducer.tsx new file mode 100644 index 0000000..9057d86 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/components/soilDataReducer.tsx @@ -0,0 +1,500 @@ +'use client'; + +import { toast } from 'sonner'; + +// 土壤采样点接口 +export interface SoilLayer { + depth: string; // 如 "0-20cm" + deviceId?: string; // IoT设备ID,用于读取该层数据 + // 以下字段从设备读取,仅用于展示 + pH?: number; + organicMatter?: number; // 有机质 g/kg + nitrogen?: number; // 全氮 g/kg + phosphorus?: number; // 有效磷 mg/kg + potassium?: number; // 速效钾 mg/kg + moisture?: number; // 含水量 % +} + +export interface SamplePoint { + id: string; + code: string; + fieldName: string; + latitude: number; + longitude: number; + sampleDate: string; + sampler: string; + deviceId?: string; // IoT设备ID + layers: SoilLayer[]; +} + +// IoT设备接口 +export interface IoTDevice { + id: string; + code: string; + name: string; + type: string; + status: 'online' | 'offline'; + // 实时监测数据 + data: { + pH: number; + organicMatter: number; + nitrogen: number; + phosphorus: number; + potassium: number; + moisture: number; + lastUpdate: string; + }; +} + +// 筛选条件接口 +export interface SoilDataFilters { + searchKeyword: string; + selectedField: string; + dateRange: { + start: string; + end: string; + }; +} + +// 统计结果接口 +export interface SoilDataStatistics { + totalPoints: number; + totalFields: number; + totalLayers: number; + averagePH: number; + phDistribution: { + strongAcidic: number; // 强酸性 + acidic: number; // 酸性 + neutral: number; // 中性 + alkaline: number; // 碱性 + strongAlkaline: number; // 强碱性 + }; + organicMatterDistribution: { + veryLow: number; // 极低 + low: number; // 低 + medium: number; // 中等 + high: number; // 较高 + veryHigh: number; // 高 + }; +} + +// 状态接口 +export interface SoilDataState { + samplePoints: SamplePoint[]; + iotDevices: IoTDevice[]; + filters: SoilDataFilters; + statistics: SoilDataStatistics | null; + activeTab: string; + showAddDialog: boolean; + showEditDialog: boolean; + showLayerDialog: boolean; + showDeleteDialog: boolean; + selectedPoint: SamplePoint | null; + pointToDelete: string | null; + newPoint: Partial; + loading: boolean; +} + +// Action类型 +export type SoilDataAction = + | { type: 'SET_SAMPLE_POINTS'; payload: SamplePoint[] } + | { type: 'SET_IOT_DEVICES'; payload: IoTDevice[] } + | { type: 'SET_FILTERS'; payload: Partial } + | { type: 'SET_STATISTICS'; payload: SoilDataStatistics | null } + | { type: 'SET_ACTIVE_TAB'; payload: string } + | { type: 'SET_SHOW_ADD_DIALOG'; payload: boolean } + | { type: 'SET_SHOW_EDIT_DIALOG'; payload: boolean } + | { type: 'SET_SHOW_LAYER_DIALOG'; payload: boolean } + | { type: 'SET_SHOW_DELETE_DIALOG'; payload: boolean } + | { type: 'SET_SELECTED_POINT'; payload: SamplePoint | null } + | { type: 'SET_POINT_TO_DELETE'; payload: string | null } + | { type: 'SET_NEW_POINT'; payload: Partial } + | { type: 'UPDATE_NEW_POINT'; payload: Partial } + | { type: 'RESET_NEW_POINT' } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'ADD_SAMPLE_POINT'; payload: SamplePoint } + | { type: 'UPDATE_SAMPLE_POINT'; payload: SamplePoint } + | { type: 'DELETE_SAMPLE_POINT'; payload: string } + | { type: 'LOAD_FROM_STORAGE' } + | { type: 'SAVE_TO_STORAGE' }; + +// 初始状态 +export const initialSoilDataState: SoilDataState = { + samplePoints: [], + iotDevices: [], + filters: { + searchKeyword: '', + selectedField: 'all', + dateRange: { + start: '', + end: '' + } + }, + statistics: null, + activeTab: 'list', + showAddDialog: false, + showEditDialog: false, + showLayerDialog: false, + showDeleteDialog: false, + selectedPoint: null, + pointToDelete: null, + newPoint: { + code: '', + fieldName: '', + latitude: 0, + longitude: 0, + sampleDate: '', + sampler: '', + deviceId: '', + layers: [ + { depth: '0-20cm', deviceId: '' }, + { depth: '20-40cm', deviceId: '' }, + { depth: '40-60cm', deviceId: '' }, + ] + }, + loading: false +}; + +// 初始化测试数据 +const initializeTestData = () => { + const testDevices: IoTDevice[] = [ + { + id: 'dev-1', + code: 'IOT-SOIL-001', + name: '土壤传感器001', + type: '多参数土壤传感器', + status: 'online', + data: { + pH: 6.8, + organicMatter: 28.5, + nitrogen: 1.2, + phosphorus: 25.3, + potassium: 180, + moisture: 22.5, + lastUpdate: '2024-10-18 14:30:00', + }, + }, + { + id: 'dev-2', + code: 'IOT-SOIL-002', + name: '土壤传感器002', + type: '多参数土壤传感器', + status: 'online', + data: { + pH: 7.2, + organicMatter: 32.1, + nitrogen: 1.5, + phosphorus: 28.6, + potassium: 195, + moisture: 20.3, + lastUpdate: '2024-10-18 14:28:00', + }, + }, + { + id: 'dev-3', + code: 'IOT-SOIL-003', + name: '土壤传感器003', + type: '多参数土壤传感器', + status: 'online', + data: { + pH: 5.8, + organicMatter: 22.3, + nitrogen: 0.9, + phosphorus: 18.5, + potassium: 152, + moisture: 24.6, + lastUpdate: '2024-10-18 14:25:00', + }, + }, + { + id: 'dev-4', + code: 'IOT-SOIL-004', + name: '土壤传感器004', + type: '多参数土壤传感器', + status: 'offline', + data: { + pH: 6.5, + organicMatter: 18.2, + nitrogen: 0.8, + phosphorus: 18.5, + potassium: 145, + moisture: 25.8, + lastUpdate: '2024-10-17 18:30:00', + }, + }, + ]; + + const testPoints: SamplePoint[] = [ + { + id: 'sp-1', + code: 'SP001', + fieldName: '东区1号地', + latitude: 39.9042, + longitude: 116.4074, + sampleDate: '2024-10-15', + sampler: '张三', + deviceId: 'dev-1', + layers: [ + { depth: '0-20cm', deviceId: 'dev-1', pH: 6.8, organicMatter: 28.5, nitrogen: 1.2, phosphorus: 25.3, potassium: 180, moisture: 22.5 }, + { depth: '20-40cm', deviceId: 'dev-1', pH: 6.5, organicMatter: 18.2, nitrogen: 0.8, phosphorus: 18.5, potassium: 145, moisture: 25.8 }, + { depth: '40-60cm', deviceId: 'dev-1', pH: 6.3, organicMatter: 12.5, nitrogen: 0.5, phosphorus: 12.3, potassium: 98, moisture: 28.2 }, + ], + }, + { + id: 'sp-2', + code: 'SP002', + fieldName: '东区1号地', + latitude: 39.9052, + longitude: 116.4084, + sampleDate: '2024-10-15', + sampler: '张三', + deviceId: 'dev-2', + layers: [ + { depth: '0-20cm', deviceId: 'dev-2', pH: 7.2, organicMatter: 32.1, nitrogen: 1.5, phosphorus: 28.6, potassium: 195, moisture: 20.3 }, + { depth: '20-40cm', deviceId: 'dev-2', pH: 6.8, organicMatter: 22.3, nitrogen: 1.0, phosphorus: 21.2, potassium: 158, moisture: 23.5 }, + ], + }, + { + id: 'sp-3', + code: 'SP003', + fieldName: '西区2号地', + latitude: 39.9032, + longitude: 116.4064, + sampleDate: '2024-10-14', + sampler: '李四', + deviceId: 'dev-3', + layers: [ + { depth: '0-20cm', deviceId: 'dev-3', pH: 5.8, organicMatter: 22.3, nitrogen: 0.9, phosphorus: 18.5, potassium: 152, moisture: 24.6 }, + { depth: '20-40cm', deviceId: 'dev-3', pH: 5.5, organicMatter: 15.8, nitrogen: 0.6, phosphorus: 14.2, potassium: 125, moisture: 26.8 }, + { depth: '40-60cm', deviceId: 'dev-3', pH: 5.3, organicMatter: 10.2, nitrogen: 0.4, phosphorus: 9.8, potassium: 88, moisture: 29.1 }, + ], + }, + ]; + + return { devices: testDevices, points: testPoints }; +}; + +// 计算统计数据 +const calculateStatistics = (points: SamplePoint[]): SoilDataStatistics => { + const totalPoints = points.length; + const uniqueFields = new Set(points.map(p => p.fieldName)); + const totalFields = uniqueFields.size; + const totalLayers = points.reduce((sum, p) => sum + p.layers.length, 0); + + // 计算平均pH值 + const phValues = points.flatMap(p => p.layers.map(l => l.pH || 0)); + const averagePH = phValues.length > 0 ? phValues.reduce((sum, ph) => sum + ph, 0) / phValues.length : 0; + + // pH分布统计 + const phDistribution = phValues.reduce((acc, ph) => { + if (ph < 5.5) acc.strongAcidic++; + else if (ph < 6.5) acc.acidic++; + else if (ph < 7.5) acc.neutral++; + else if (ph < 8.5) acc.alkaline++; + else acc.strongAlkaline++; + return acc; + }, { strongAcidic: 0, acidic: 0, neutral: 0, alkaline: 0, strongAlkaline: 0 }); + + // 有机质分布统计 + const organicMatterValues = points.flatMap(p => p.layers.map(l => l.organicMatter || 0)); + const organicMatterDistribution = organicMatterValues.reduce((acc, om) => { + if (om < 10) acc.veryLow++; + else if (om < 20) acc.low++; + else if (om < 30) acc.medium++; + else if (om < 40) acc.high++; + else acc.veryHigh++; + return acc; + }, { veryLow: 0, low: 0, medium: 0, high: 0, veryHigh: 0 }); + + return { + totalPoints, + totalFields, + totalLayers, + averagePH, + phDistribution, + organicMatterDistribution + }; +}; + +// Reducer函数 +export function soilDataReducer(state: SoilDataState, action: SoilDataAction): SoilDataState { + switch (action.type) { + case 'SET_SAMPLE_POINTS': + return { + ...state, + samplePoints: action.payload, + statistics: calculateStatistics(action.payload) + }; + + case 'SET_IOT_DEVICES': + return { ...state, iotDevices: action.payload }; + + case 'SET_FILTERS': + return { + ...state, + filters: { ...state.filters, ...action.payload } + }; + + case 'SET_STATISTICS': + return { ...state, statistics: action.payload }; + + case 'SET_ACTIVE_TAB': + return { ...state, activeTab: action.payload }; + + case 'SET_SHOW_ADD_DIALOG': + if (action.payload) { + return { + ...state, + showAddDialog: action.payload, + newPoint: { + code: '', + fieldName: '', + latitude: 0, + longitude: 0, + sampleDate: '', + sampler: '', + deviceId: '', + layers: [ + { depth: '0-20cm', deviceId: '' }, + { depth: '20-40cm', deviceId: '' }, + { depth: '40-60cm', deviceId: '' }, + ] + } + }; + } + return { ...state, showAddDialog: action.payload }; + + case 'SET_SHOW_EDIT_DIALOG': + return { ...state, showEditDialog: action.payload }; + + case 'SET_SHOW_LAYER_DIALOG': + return { ...state, showLayerDialog: action.payload }; + + case 'SET_SHOW_DELETE_DIALOG': + return { ...state, showDeleteDialog: action.payload }; + + case 'SET_SELECTED_POINT': + return { ...state, selectedPoint: action.payload }; + + case 'SET_POINT_TO_DELETE': + return { ...state, pointToDelete: action.payload }; + + case 'SET_NEW_POINT': + return { ...state, newPoint: action.payload }; + + case 'UPDATE_NEW_POINT': + return { + ...state, + newPoint: { ...state.newPoint, ...action.payload } + }; + + case 'RESET_NEW_POINT': + return { + ...state, + newPoint: { + code: '', + fieldName: '', + latitude: 0, + longitude: 0, + sampleDate: '', + sampler: '', + deviceId: '', + layers: [ + { depth: '0-20cm', deviceId: '' }, + { depth: '20-40cm', deviceId: '' }, + { depth: '40-60cm', deviceId: '' }, + ] + } + }; + + case 'SET_LOADING': + return { ...state, loading: action.payload }; + + case 'ADD_SAMPLE_POINT': + const updatedPoints = [...state.samplePoints, action.payload]; + toast.success('采样点添加成功!'); + return { + ...state, + samplePoints: updatedPoints, + statistics: calculateStatistics(updatedPoints) + }; + + case 'UPDATE_SAMPLE_POINT': + const updatedPointsList = state.samplePoints.map(point => + point.id === action.payload.id ? action.payload : point + ); + toast.success('采样点更新成功!'); + return { + ...state, + samplePoints: updatedPointsList, + statistics: calculateStatistics(updatedPointsList) + }; + + case 'DELETE_SAMPLE_POINT': + const filteredPoints = state.samplePoints.filter(p => p.id !== action.payload); + toast.success('采样点已删除'); + return { + ...state, + samplePoints: filteredPoints, + statistics: calculateStatistics(filteredPoints) + }; + + case 'LOAD_FROM_STORAGE': + try { + const stored = localStorage.getItem('soilData'); + if (stored) { + const parsedData = JSON.parse(stored); + return { + ...state, + samplePoints: parsedData.samplePoints || [], + statistics: calculateStatistics(parsedData.samplePoints || []) + }; + } else { + // 首次加载,初始化测试数据 + const testData = initializeTestData(); + localStorage.setItem('soilData', JSON.stringify({ + samplePoints: testData.points + })); + return { + ...state, + samplePoints: testData.points, + iotDevices: testData.devices, + statistics: calculateStatistics(testData.points) + }; + } + } catch (error) { + console.error('Failed to load soil data from storage:', error); + return state; + } + + case 'SAVE_TO_STORAGE': + try { + localStorage.setItem('soilData', JSON.stringify({ + samplePoints: state.samplePoints + })); + } catch (error) { + console.error('Failed to save soil data to storage:', error); + } + return state; + + default: + return state; + } +} + +// 工具函数 +export const getPHLevel = (pH: number) => { + if (pH < 5.5) return { label: '强酸性', color: 'bg-red-500' }; + if (pH < 6.5) return { label: '酸性', color: 'bg-orange-500' }; + if (pH < 7.5) return { label: '中性', color: 'bg-green-500' }; + if (pH < 8.5) return { label: '碱性', color: 'bg-blue-500' }; + return { label: '强碱性', color: 'bg-purple-500' }; +}; + +export const getOrganicMatterLevel = (om: number) => { + if (om < 10) return { label: '极低', color: 'text-red-600' }; + if (om < 20) return { label: '低', color: 'text-orange-600' }; + if (om < 30) return { label: '中等', color: 'text-yellow-600' }; + if (om < 40) return { label: '较高', color: 'text-green-600' }; + return { label: '高', color: 'text-blue-600' }; +}; \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/analysis/soil-data/page.tsx b/crop-x/src/app/(app)/land-information/analysis/soil-data/page.tsx index 2e5d3f1..bde1f3e 100644 --- a/crop-x/src/app/(app)/land-information/analysis/soil-data/page.tsx +++ b/crop-x/src/app/(app)/land-information/analysis/soil-data/page.tsx @@ -1,18 +1,26 @@ 'use client'; +import { useReducer, useEffect } from 'react'; import { Card } from '@/components/ui/card'; +import { soilDataReducer, initialSoilDataState, SoilDataState } from './components/soilDataReducer'; +import SoilDataContent from './components/SoilDataContent'; export default function SoilDataPage() { + const [state, dispatch] = useReducer(soilDataReducer, initialSoilDataState); + + useEffect(() => { + // 加载存储的数据 + dispatch({ type: 'LOAD_FROM_STORAGE' }); + }, []); + + // 保存数据到localStorage + useEffect(() => { + dispatch({ type: 'SAVE_TO_STORAGE' }); + }, [state.samplePoints]); + return (
- -

土壤基础数据

-
-

- 页面路径: /land-information/analysis/soil-data -

-
-
+
); } \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/map/satellite/components/FieldSatellite.tsx b/crop-x/src/app/(app)/land-information/map/satellite/components/FieldSatellite.tsx new file mode 100644 index 0000000..0492a88 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/map/satellite/components/FieldSatellite.tsx @@ -0,0 +1,873 @@ +/** + * 地块影像组件 + * 集成时序遥感影像服务,支持天地图、Sentinel、Landsat等数据源 + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Slider } from '@/components/ui/slider'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Satellite, + Calendar, + Image as ImageIcon, + TrendingUp, + TrendingDown, + Layers, + Download, + Eye, + BarChart3, + Cloud, + Leaf, + Sun, + Droplets, + AlertCircle, + RefreshCw, + Filter, + Maximize2, + ChevronLeft, + ChevronRight, + Activity +} from 'lucide-react'; +import { toast } from 'sonner'; +import { + SatelliteImageService, + DATA_SOURCES, + getCloudCoverColorClass, + formatImageDate +} from './satelliteImageService'; +import { + SatelliteImage, + ImageComparisonResult, + TimeSeriesAnalysis +} from './satelliteTypes'; + +export function FieldSatellite() { + const [selectedField, setSelectedField] = useState('field-1'); + const [imageSource, setImageSource] = useState('Sentinel-2'); + const [selectedImage, setSelectedImage] = useState(null); + const [comparisonImage, setComparisonImage] = useState(null); + const [showComparison, setShowComparison] = useState(false); + const [timeSliderValue, setTimeSliderValue] = useState([0]); + const [maxCloudCover, setMaxCloudCover] = useState(30); + const [images, setImages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [comparisonResult, setComparisonResult] = useState(null); + const [timeSeriesAnalysis, setTimeSeriesAnalysis] = useState(null); + const [activeView, setActiveView] = useState<'single' | 'comparison' | 'timeseries'>('single'); + + // 模拟地块数据 + const mockFields = [ + { id: 'field-1', name: '东区1号地', code: 'DB001' }, + { id: 'field-2', name: '西区2号地', code: 'DB002' }, + { id: 'field-3', name: '南区3号地', code: 'DB003' }, + ]; + + // 加载影像数据 + useEffect(() => { + loadImages(); + }, [selectedField, imageSource, maxCloudCover]); + + // 自动选择第一张影像 + useEffect(() => { + if (images.length > 0 && !selectedImage) { + setSelectedImage(images[0]); + setTimeSliderValue([0]); + } + }, [images]); + + // 更新时序分析 + useEffect(() => { + if (images.length >= 2) { + const analysis = SatelliteImageService.analyzeTimeSeries(images); + setTimeSeriesAnalysis(analysis); + } + }, [images]); + + const loadImages = async () => { + setIsLoading(true); + try { + // 获取最近6个月的影像 + const endDate = new Date(); + const startDate = new Date(); + startDate.setMonth(startDate.getMonth() - 6); + + const loadedImages = await SatelliteImageService.getFieldImages( + selectedField, + startDate.toISOString().split('T')[0], + endDate.toISOString().split('T')[0], + imageSource, + maxCloudCover + ); + + setImages(loadedImages); + toast.success(`加载了 ${loadedImages.length} 张影像`); + } catch (error) { + toast.error('加载影像失败'); + } finally { + setIsLoading(false); + } + }; + + const handleImageSelect = (image: SatelliteImage, index: number) => { + setSelectedImage(image); + setTimeSliderValue([index]); + toast.success(`已加载 ${formatImageDate(image.date)} 影像`); + }; + + const handleTimeSliderChange = (value: number[]) => { + setTimeSliderValue(value); + if (images[value[0]]) { + setSelectedImage(images[value[0]]); + } + }; + + const handleComparisonToggle = () => { + if (!showComparison) { + // 开启对比模式,选择当前影像的前一张作为对比 + const currentIndex = timeSliderValue[0]; + if (currentIndex < images.length - 1) { + setComparisonImage(images[currentIndex + 1]); + } else if (images.length >= 2) { + setComparisonImage(images[0]); + } + } + setShowComparison(!showComparison); + setActiveView(showComparison ? 'single' : 'comparison'); + }; + + const handleCompare = () => { + if (!selectedImage || !comparisonImage) { + toast.error('请选择两张影像进行对比'); + return; + } + + const result = SatelliteImageService.compareImages(comparisonImage, selectedImage); + setComparisonResult(result); + toast.success('影像对比完成'); + }; + + const handleDownload = async () => { + if (!selectedImage) { + toast.error('请先选择影像'); + return; + } + + try { + await SatelliteImageService.downloadImage(selectedImage, 'jpg'); + toast.success(`正在下载 ${formatImageDate(selectedImage.date)} 影像...`); + } catch (error) { + toast.error('下载失败'); + } + }; + + const navigateImage = (direction: 'prev' | 'next') => { + const currentIndex = timeSliderValue[0]; + let newIndex = currentIndex; + + if (direction === 'prev' && currentIndex > 0) { + newIndex = currentIndex - 1; + } else if (direction === 'next' && currentIndex < images.length - 1) { + newIndex = currentIndex + 1; + } + + if (newIndex !== currentIndex) { + handleImageSelect(images[newIndex], newIndex); + } + }; + + return ( +
+
+
+

地块影像

+

+ 时序遥感影像分析与作物长势监测 +

+
+
+ + + +
+
+ + {/* 数据源和地块信息 */} +
+ +
+ +
+
数据源
+
{DATA_SOURCES[imageSource as keyof typeof DATA_SOURCES]?.name || imageSource}
+
+
+
+ +
+ +
+
影像数量
+
{images.length} 张
+
+
+
+ +
+ +
+
分辨率
+
{DATA_SOURCES[imageSource as keyof typeof DATA_SOURCES]?.resolution || '--'}米
+
+
+
+ +
+ +
+
健康分数
+
{timeSeriesAnalysis?.healthScore || '--'}
+
+
+
+
+ +
+ {/* 左侧控制面板 */} +
+ {/* 地块选择 */} + + + + + + {/* 数据源选择 */} + + +
+ {Object.entries(DATA_SOURCES).map(([key, source]) => ( + + ))} +
+
+ + {/* 云量过滤 */} + + + setMaxCloudCover(value[0])} + max={100} + step={5} + className="w-full" + /> +
+ + 仅显示云量 ≤ {maxCloudCover}% 的影像 +
+
+ + {/* 视图切换 */} + + +
+ + + +
+
+ + {/* 指标说明 */} + +

植被指数说明

+
+
+
+ + NDVI +
+

+ 归一化植被指数,反映植被覆盖度和长势 +

+
+
+
+ + EVI +
+

+ 增强型植被指数,对高生物量更敏感 +

+
+
+
+ + SAVI +
+

+ 土壤调节植被指数,减少土壤背景影响 +

+
+
+
+
+ + {/* 主显示区域 */} +
+ setActiveView(v as any)}> + + 单影像 + 影像对比 + 时序分析 + + + {/* 单影像视图 */} + + {/* 影像显示 */} + +
+ {/* 模拟卫星影像 */} +
+
+
+ + {/* 导航按钮 */} +
+ + +
+ + {/* 影像信息叠加 */} + {selectedImage && ( + <> +
+
+ +
+ + {formatImageDate(selectedImage.date)} + {selectedImage.season} +
+
+ +
+ + 云量: {selectedImage.cloudCover.toFixed(0)}% +
+
+
+
+ + {/* NDVI图例 */} +
+ +
NDVI 值
+
+
+
+ 0.0 + 1.0 +
+
+
+
+ + {/* 当前NDVI值 */} +
+ +
+
当前NDVI
+
+ {selectedImage.ndvi.toFixed(2)} +
+
+ {SatelliteImageService.getNDVILabel(selectedImage.ndvi)} +
+
+
+
+ + )} +
+ + + {/* 作物长势分析 */} + {selectedImage && ( + +

作物长势分析

+
+ +
+ + 植被覆盖度 +
+
+ {(selectedImage.ndvi * 100).toFixed(0)}% +
+
+ +
+ + 长势评价 +
+
+ {SatelliteImageService.getNDVILabel(selectedImage.ndvi)} +
+
+ +
+ + 叶面积指数 +
+
{selectedImage.lai.toFixed(1)}
+
+ +
+ + 增强植被指数 +
+
{selectedImage.evi.toFixed(2)}
+
+
+
+ )} + + + {/* 影像对比视图 */} + + +
+

影像对比分析

+ +
+ + {/* 选择对比影像 */} +
+
+ + +
+
+ + +
+
+ + {/* 对比结果 */} + {comparisonResult && ( +
+ +
+ {comparisonResult.changeType === 'improvement' && } + {comparisonResult.changeType === 'decline' && } + {comparisonResult.changeType === 'stable' && } +
+

+ {comparisonResult.changeDescription} +

+
+
+
+ +
+ +

NDVI变化

+
+ 0 ? '#22c55e' : + comparisonResult.ndviChange < 0 ? '#ef4444' : '#6b7280' + }}> + {comparisonResult.ndviChange > 0 ? '+' : ''}{comparisonResult.ndviChange.toFixed(3)} + + + ({comparisonResult.image1.ndvi.toFixed(2)} → {comparisonResult.image2.ndvi.toFixed(2)}) + +
+
+ +

EVI变化

+
+ 0 ? '#22c55e' : + comparisonResult.eviChange < 0 ? '#ef4444' : '#6b7280' + }}> + {comparisonResult.eviChange > 0 ? '+' : ''}{comparisonResult.eviChange.toFixed(3)} + + + ({comparisonResult.image1.evi.toFixed(2)} → {comparisonResult.image2.evi.toFixed(2)}) + +
+
+
+ + +

管理建议

+
    + {comparisonResult.recommendations.map((rec, index) => ( +
  • + + {rec} +
  • + ))} +
+
+
+ )} +
+
+ + {/* 时序分析视图 */} + + {timeSeriesAnalysis && ( + <> + +

生长趋势分析

+
+ +
变化趋势
+
+ {timeSeriesAnalysis.trend === 'increasing' && } + {timeSeriesAnalysis.trend === 'decreasing' && } + {timeSeriesAnalysis.trend === 'stable' && } + {timeSeriesAnalysis.trend === 'fluctuating' && } + + {timeSeriesAnalysis.trend === 'increasing' && '上升'} + {timeSeriesAnalysis.trend === 'decreasing' && '下降'} + {timeSeriesAnalysis.trend === 'stable' && '稳定'} + {timeSeriesAnalysis.trend === 'fluctuating' && '波动'} + +
+
+ +
生长阶段
+
{timeSeriesAnalysis.growthStage}
+
+ +
健康分数
+
{timeSeriesAnalysis.healthScore}
+
+ +
影像数量
+
{timeSeriesAnalysis.dates.length}
+
+
+ + {/* NDVI趋势图 */} +
+

NDVI变化曲线

+ + {/* 网格线 */} + {[0, 25, 50, 75, 100].map(y => ( + + ))} + + {/* NDVI曲线 */} + + `${(i / (timeSeriesAnalysis.ndviValues.length - 1)) * 100},${100 - ndvi * 100}` + ).join(' ')} + fill="none" + stroke="#22c55e" + strokeWidth="1" + /> + + {/* 数据点 */} + {timeSeriesAnalysis.ndviValues.map((ndvi, i) => ( + + ))} + + + {/* 刻度标签 */} +
+ {timeSeriesAnalysis.dates[0]} + {timeSeriesAnalysis.dates[timeSeriesAnalysis.dates.length - 1]} +
+
+
+ + {/* 警报信息 */} + {timeSeriesAnalysis.alerts.length > 0 && ( + +

+ + 注意事项 +

+
    + {timeSeriesAnalysis.alerts.map((alert, index) => ( +
  • + {alert} +
  • + ))} +
+
+ )} + + )} +
+ + + {/* 时间滑块(所有视图通用) */} + {images.length > 0 && ( + +
+ +
+
+ 时间轴 + + {images[timeSliderValue[0]] && formatImageDate(images[timeSliderValue[0]].date)} + +
+ +
+ {images[0] && formatImageDate(images[0].date)} + {images[images.length - 1] && formatImageDate(images[images.length - 1].date)} +
+
+
+
+ )} + + {/* 影像列表 */} + +

历史影像列表

+
+ {images.map((image, index) => ( +
handleImageSelect(image, index)} + > +
+
+ + {formatImageDate(image.date)} +
+ + + {image.cloudCover.toFixed(0)}% + +
+
+
+ 数据源: + {image.source} +
+
+ 分辨率: + {image.resolution}m +
+
+ NDVI: + + {image.ndvi.toFixed(2)} + +
+
+ EVI: + {image.evi.toFixed(2)} +
+
+
+ ))} +
+
+
+
+ + {/* 使用说明 */} + +
+ +
+

遥感影像功能说明:

+
    +
  • 时序影像:通过时间滑块查看同一地块不同时期的卫星影像,直观对比地块变化
  • +
  • 影像对比:选择两个时期的影像进行对比,自动计算NDVI、EVI变化并生成管理建议
  • +
  • 时序分析:分析NDVI变化趋势,判断作物生长阶段,计算健康分数
  • +
  • 多数据源:支持Sentinel-2(10米)、Landsat-8(30米)、天地图等多种数据源
  • +
  • 云量过滤:可设置最大云量阈值,自动过滤云量过高的影像,确保分析准确性
  • +
  • 植被指数:NDVI(植被覆盖度)、EVI(高生物量敏感)、SAVI(土壤调节)、LAI(叶面积指数)
  • +
  • 智能建议:根据影像变化自动生成灌溉、施肥、病虫害防治等管理建议
  • +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteImageService.ts b/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteImageService.ts new file mode 100644 index 0000000..cddd494 --- /dev/null +++ b/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteImageService.ts @@ -0,0 +1,352 @@ +/** + * 卫星影像服务类 + * 提供卫星影像数据的获取、分析和对比功能 + */ + +import { SatelliteImage, ImageComparisonResult, TimeSeriesAnalysis, DataSource } from './satelliteTypes'; + +export class SatelliteImageService { + /** + * 获取指定地块的卫星影像数据 + */ + static async getFieldImages( + fieldId: string, + startDate: string, + endDate: string, + source: string = 'Sentinel-2', + maxCloudCover: number = 30 + ): Promise { + // 模拟API调用,生成测试数据 + const mockImages: SatelliteImage[] = []; + const start = new Date(startDate); + const end = new Date(endDate); + + // 生成6个月的影像数据,每15天一张 + const intervalDays = 15; + const totalImages = Math.floor((end.getTime() - start.getTime()) / (intervalDays * 24 * 60 * 60 * 1000)); + + for (let i = 0; i < totalImages; i++) { + const date = new Date(start); + date.setDate(date.getDate() + i * intervalDays); + + // 模拟NDVI变化趋势(作物生长周期) + const dayOfYear = Math.floor((date.getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / (24 * 60 * 60 * 1000)); + const ndviBase = 0.3 + 0.4 * Math.sin((dayOfYear - 90) * Math.PI / 180); + const ndvi = Math.max(0.1, Math.min(0.9, ndviBase + (Math.random() - 0.5) * 0.2)); + + // 相关植被指数计算 + const evi = ndvi * (0.9 + Math.random() * 0.2); + const lai = ndvi * 6 + Math.random() * 2; + + // 随机云量 + const cloudCover = Math.random() * 50; + + // 只包含云量符合要求的影像 + if (cloudCover <= maxCloudCover) { + mockImages.push({ + id: `${fieldId}-${source}-${date.toISOString().split('T')[0]}`, + date: date.toISOString().split('T')[0], + source, + resolution: parseInt(DATA_SOURCES[source]?.resolution || '10'), + cloudCover, + ndvi, + evi, + lai, + season: SatelliteImageService.getSeason(date), + thumbnail: SatelliteImageService.generateThumbnailUrl(date, source), + url: SatelliteImageService.generateImageUrl(date, source) + }); + } + } + + return mockImages.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + } + + /** + * 对比两张影像 + */ + static compareImages(image1: SatelliteImage, image2: SatelliteImage): ImageComparisonResult { + const ndviChange = image2.ndvi - image1.ndvi; + const eviChange = image2.evi - image1.evi; + + let changeType: 'improvement' | 'decline' | 'stable'; + let changeDescription: string; + + const threshold = 0.05; // NDVI变化阈值 + + if (ndviChange > threshold) { + changeType = 'improvement'; + changeDescription = '作物长势明显改善'; + } else if (ndviChange < -threshold) { + changeType = 'decline'; + changeDescription = '作物长势出现下降'; + } else { + changeType = 'stable'; + changeDescription = '作物长势保持稳定'; + } + + // 生成管理建议 + const recommendations = this.generateRecommendations(changeType, ndviChange, eviChange); + + return { + image1, + image2, + ndviChange, + eviChange, + changeType, + changeDescription, + recommendations + }; + } + + /** + * 分析时序数据 + */ + static analyzeTimeSeries(images: SatelliteImage[]): TimeSeriesAnalysis { + if (images.length === 0) { + return { + dates: [], + ndviValues: [], + trend: 'stable', + growthStage: '无数据', + healthScore: '--', + alerts: [] + }; + } + + // 按日期排序 + const sortedImages = images.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + const dates = sortedImages.map(img => img.date); + const ndviValues = sortedImages.map(img => img.ndvi); + + // 分析趋势 + const trend = this.analyzeTrend(ndviValues); + + // 判断生长阶段 + const growthStage = this.determineGrowthStage(ndviValues); + + // 计算健康分数 + const healthScore = this.calculateHealthScore(ndviValues); + + // 生成警报 + const alerts = this.generateAlerts(ndviValues, sortedImages); + + return { + dates, + ndviValues, + trend, + growthStage, + healthScore, + alerts + }; + } + + /** + * 下载影像 + */ + static async downloadImage(image: SatelliteImage, format: 'jpg' | 'png' | 'tiff' = 'jpg'): Promise { + // 模拟下载过程 + return new Promise((resolve, reject) => { + setTimeout(() => { + // 模拟下载成功 + console.log(`Downloading ${image.url} as ${format}`); + resolve(); + }, 2000); + }); + } + + /** + * 获取NDVI对应的颜色 + */ + static getNDVIColor(ndvi: number): string { + if (ndvi < 0.2) return '#ef4444'; // 红色 + if (ndvi < 0.4) return '#f97316'; // 橙色 + if (ndvi < 0.6) return '#eab308'; // 黄色 + if (ndvi < 0.8) return '#84cc16'; // 浅绿色 + return '#22c55e'; // 深绿色 + } + + /** + * 获取NDVI对应的描述 + */ + static getNDVILabel(ndvi: number): string { + if (ndvi < 0.2) return '植被稀疏'; + if (ndvi < 0.4) return '植被较少'; + if (ndvi < 0.6) return '植被适中'; + if (ndvi < 0.8) return '植被良好'; + return '植被茂盛'; + } + + // 私有方法 + + private static getSeason(date: Date): string { + const month = date.getMonth(); + if (month >= 2 && month <= 4) return '春季'; + if (month >= 5 && month <= 7) return '夏季'; + if (month >= 8 && month <= 10) return '秋季'; + return '冬季'; + } + + private static generateThumbnailUrl(date: Date, source: string): string { + const dateStr = date.toISOString().split('T')[0]; + return `https://picsum.photos/seed/${source}-${dateStr}/200/200.jpg`; + } + + private static generateImageUrl(date: Date, source: string): string { + const dateStr = date.toISOString().split('T')[0]; + return `https://picsum.photos/seed/${source}-${dateStr}/800/600.jpg`; + } + + private static analyzeTrend(values: number[]): 'increasing' | 'decreasing' | 'stable' | 'fluctuating' { + if (values.length < 2) return 'stable'; + + let increaseCount = 0; + let decreaseCount = 0; + + for (let i = 1; i < values.length; i++) { + if (values[i] > values[i - 1] + 0.02) increaseCount++; + else if (values[i] < values[i - 1] - 0.02) decreaseCount++; + } + + const totalChanges = values.length - 1; + const increaseRatio = increaseCount / totalChanges; + const decreaseRatio = decreaseCount / totalChanges; + + if (increaseRatio > 0.6) return 'increasing'; + if (decreaseRatio > 0.6) return 'decreasing'; + if (increaseRatio > 0.3 && decreaseRatio > 0.3) return 'fluctuating'; + return 'stable'; + } + + private static determineGrowthStage(ndviValues: number[]): string { + if (ndviValues.length === 0) return '未知'; + + const avgNdvi = ndviValues.reduce((sum, val) => sum + val, 0) / ndviValues.length; + const latestNdvi = ndviValues[ndviValues.length - 1]; + + if (avgNdvi < 0.3) return '苗期'; + if (avgNdvi < 0.5) return '生长期'; + if (avgNdvi < 0.7) return '旺盛期'; + return '成熟期'; + } + + private static calculateHealthScore(ndviValues: number[]): string { + if (ndviValues.length === 0) return '--'; + + const avgNdvi = ndviValues.reduce((sum, val) => sum + val, 0) / ndviValues.length; + const latestNdvi = ndviValues[ndviValues.length - 1]; + + // 综合平均NDVI和最新NDVI计算健康分数 + const healthScore = Math.round((avgNdvi * 0.6 + latestNdvi * 0.4) * 100); + return `${healthScore}/100`; + } + + private static generateAlerts(ndviValues: number[], images: SatelliteImage[]): string[] { + const alerts: string[] = []; + + if (ndviValues.length < 2) return alerts; + + const latestNdvi = ndviValues[ndviValues.length - 1]; + const previousNdvi = ndviValues[ndviValues.length - 2]; + const ndviChange = latestNdvi - previousNdvi; + + // NDVI下降警报 + if (ndviChange < -0.1) { + alerts.push('NDVI显著下降,建议检查灌溉和病虫害情况'); + } + + // NDVI过低警报 + if (latestNdvi < 0.3) { + alerts.push('植被覆盖度过低,建议加强田间管理'); + } + + // 云量过高警报 + const highCloudImages = images.filter(img => img.cloudCover > 40); + if (highCloudImages.length > images.length * 0.5) { + alerts.push('近期影像云量较高,可能影响分析准确性'); + } + + return alerts; + } + + private static generateRecommendations( + changeType: 'improvement' | 'decline' | 'stable', + ndviChange: number, + eviChange: number + ): string[] { + const recommendations: string[] = []; + + if (changeType === 'improvement') { + recommendations.push('作物长势良好,继续保持当前管理措施'); + recommendations.push('可适当增加施肥量,维持作物生长势头'); + if (Math.abs(eviChange) > 0.05) { + recommendations.push('EVI指数变化明显,建议监测叶面积指数变化'); + } + } else if (changeType === 'decline') { + recommendations.push('作物长势下降,建议立即检查灌溉情况'); + recommendations.push('加强病虫害监测,必要时采取防治措施'); + recommendations.push('考虑补充施肥,提供充足营养'); + if (ndviChange < -0.1) { + recommendations.push('NDVI下降明显,建议进行实地勘察'); + } + } else { + recommendations.push('作物长势稳定,维持当前管理方案'); + recommendations.push('定期监测植被指数变化趋势'); + recommendations.push('根据生长阶段调整田间管理措施'); + } + + return recommendations; + } +} + +// 导出数据源 +export const DATA_SOURCES: Record = { + 'Sentinel-2': { + name: 'Sentinel-2', + resolution: '10', + description: '欧洲航天局,高分辨率多光谱成像', + provider: 'ESA' + }, + 'Landsat-8': { + name: 'Landsat-8', + resolution: '30', + description: '美国地质调查局,中分辨率多光谱成像', + provider: 'USGS' + }, + 'MODIS': { + name: 'MODIS', + resolution: '250', + description: '中分辨率成像光谱仪,每日覆盖', + provider: 'NASA' + }, + '高分一号': { + name: '高分一号', + resolution: '8', + description: '中国高分系列,高分辨率光学卫星', + provider: 'CNSA' + }, + '天地图': { + name: '天地图', + resolution: '2', + description: '中国国产卫星影像服务', + provider: 'NASG' + } +}; + +// 工具函数 +export const formatImageDate = (dateString: string): string => { + const date = new Date(dateString); + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +}; + +export const getCloudCoverColorClass = (cloudCover: number): string => { + if (cloudCover <= 10) return 'bg-green-100 text-green-800 border-green-200'; + if (cloudCover <= 30) return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + if (cloudCover <= 50) return 'bg-orange-100 text-orange-800 border-orange-200'; + return 'bg-red-100 text-red-800 border-red-200'; +}; \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteTypes.ts b/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteTypes.ts new file mode 100644 index 0000000..326732f --- /dev/null +++ b/crop-x/src/app/(app)/land-information/map/satellite/components/satelliteTypes.ts @@ -0,0 +1,76 @@ +/** + * 卫星影像服务类型定义 + */ + +export interface SatelliteImage { + id: string; + date: string; + source: string; + resolution: number; + cloudCover: number; + ndvi: number; + evi: number; + lai: number; + season: string; + thumbnail: string; + url: string; +} + +export interface ImageComparisonResult { + image1: SatelliteImage; + image2: SatelliteImage; + ndviChange: number; + eviChange: number; + changeType: 'improvement' | 'decline' | 'stable'; + changeDescription: string; + recommendations: string[]; +} + +export interface TimeSeriesAnalysis { + dates: string[]; + ndviValues: number[]; + trend: 'increasing' | 'decreasing' | 'stable' | 'fluctuating'; + growthStage: string; + healthScore: string; + alerts: string[]; +} + +export interface DataSource { + name: string; + resolution: string; + description: string; + provider: string; +} + +export const DATA_SOURCES: Record = { + 'Sentinel-2': { + name: 'Sentinel-2', + resolution: '10', + description: '欧洲航天局,高分辨率多光谱成像', + provider: 'ESA' + }, + 'Landsat-8': { + name: 'Landsat-8', + resolution: '30', + description: '美国地质调查局,中分辨率多光谱成像', + provider: 'USGS' + }, + 'MODIS': { + name: 'MODIS', + resolution: '250', + description: '中分辨率成像光谱仪,每日覆盖', + provider: 'NASA' + }, + '高分一号': { + name: '高分一号', + resolution: '8', + description: '中国高分系列,高分辨率光学卫星', + provider: 'CNSA' + }, + '天地图': { + name: '天地图', + resolution: '2', + description: '中国国产卫星影像服务', + provider: 'NASG' + } +}; \ No newline at end of file diff --git a/crop-x/src/app/(app)/land-information/map/satellite/page.tsx b/crop-x/src/app/(app)/land-information/map/satellite/page.tsx index 0911a1f..4445fb3 100644 --- a/crop-x/src/app/(app)/land-information/map/satellite/page.tsx +++ b/crop-x/src/app/(app)/land-information/map/satellite/page.tsx @@ -1,18 +1,7 @@ 'use client'; -import { Card } from '@/components/ui/card'; +import { FieldSatellite } from './components/FieldSatellite'; export default function SatellitePage() { - return ( -
- -

地块卫星影像

-
-

- 页面路径: /land-information/map/satellite -

-
-
-
- ); + return ; } \ No newline at end of file diff --git a/crop-x/src/components/layouts/Navbar.tsx b/crop-x/src/components/layouts/Navbar.tsx index 866d095..811c184 100644 --- a/crop-x/src/components/layouts/Navbar.tsx +++ b/crop-x/src/components/layouts/Navbar.tsx @@ -1,7 +1,7 @@ 'use client'; import { Book, Menu, Sunset, Trees, Zap } from "lucide-react"; -import { Tractor, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react'; +import { Sprout, Map, Clipboard, Package, Brain, Droplets, Settings } from 'lucide-react'; import { MessageBell } from './components/MessageBell'; import { UserProfile } from './components/UserProfile'; import { ThemeToggle } from './ThemeToggle'; @@ -117,11 +117,11 @@ const Navbar1 = ({ navbarData }: Navbar1Props) => {
-
- +
+
-

智慧农业生产管理系统

+

智慧农业生产管理系统

Smart Agriculture Management System