生产管理系统前端 - 提交空间数据管理开发页面

This commit is contained in:
2025-10-30 09:10:44 +08:00
parent 3239f819d0
commit 71bc00cc4e
17 changed files with 6496 additions and 13 deletions

View File

@@ -0,0 +1,167 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Filter, Search, Trash2 } from 'lucide-react';
import { FilterCondition, SoilType, PlantingMode, LandTag } from './landStatisticsReducer';
interface FilterPanelProps {
filters: FilterCondition;
soilTypes: SoilType[];
plantingModes: PlantingMode[];
tags: LandTag[];
onFilterChange: (key: keyof FilterCondition, value: any) => void;
onToggleArrayFilter: (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => void;
onClearFilters: () => void;
onExecuteQuery: () => void;
}
export function FilterPanel({
filters,
soilTypes,
plantingModes,
tags,
onFilterChange,
onToggleArrayFilter,
onClearFilters,
onExecuteQuery,
}: FilterPanelProps) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Filter className="w-5 h-5 text-green-600 dark:text-green-400" />
<h3 className="text-lg font-semibold"></h3>
</div>
<div className="space-y-6">
{/* 关键词搜索 */}
<div className="space-y-2">
<Label></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索地块名称、编号或位置..."
value={filters.keyword}
onChange={(e) => onFilterChange('keyword', e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* 土壤类型 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{soilTypes.map((type) => (
<Badge
key={type.id}
variant={filters.soilTypes.includes(type.key) ? 'default' : 'outline'}
className="cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: filters.soilTypes.includes(type.key) ? type.color : 'transparent',
borderColor: type.color,
color: filters.soilTypes.includes(type.key) ? 'white' : type.color,
}}
onClick={() => onToggleArrayFilter('soilTypes', type.key)}
>
<div
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: type.color }}
/>
{type.name}
</Badge>
))}
</div>
</div>
{/* 种植模式 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{plantingModes.map((mode) => (
<Badge
key={mode.id}
variant={filters.plantingModes.includes(mode.key) ? 'default' : 'outline'}
className="cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: filters.plantingModes.includes(mode.key) ? '#16a34a' : 'transparent',
borderColor: '#16a34a',
color: filters.plantingModes.includes(mode.key) ? 'white' : '#16a34a',
}}
onClick={() => onToggleArrayFilter('plantingModes', mode.key)}
>
<span className="mr-1">{mode.emoji}</span>
{mode.name}
</Badge>
))}
</div>
</div>
{/* 标签 */}
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge
key={tag.id}
variant={filters.tags.includes(tag.name) ? 'default' : 'outline'}
className="cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: filters.tags.includes(tag.name) ? tag.color : 'transparent',
borderColor: tag.color,
color: filters.tags.includes(tag.name) ? 'white' : tag.color,
}}
onClick={() => onToggleArrayFilter('tags', tag.name)}
>
{tag.name}
</Badge>
))}
</div>
</div>
{/* 面积范围 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
placeholder="如50"
value={filters.minArea}
onChange={(e) => onFilterChange('minArea', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
placeholder="如200"
value={filters.maxArea}
onChange={(e) => onFilterChange('maxArea', e.target.value)}
/>
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-col sm:flex-row gap-2 pt-4">
<Button
className="bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 flex-1"
onClick={onExecuteQuery}
>
<Search className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={onClearFilters}
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,267 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
BarChart3,
Download,
BarChart,
PieChart,
} from 'lucide-react';
import { StatisticsResult } from './landStatisticsReducer';
import {
BarChart as RechartsBarChart,
Bar,
PieChart as RechartsPieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
const COLORS = ['#22c55e', '#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#f97316', '#8b5cf6'];
interface StatisticsResultsProps {
statistics: StatisticsResult;
chartType: 'bar' | 'pie';
onChartTypeChange: (type: 'bar' | 'pie') => void;
onExportData: () => void;
}
export function StatisticsResults({
statistics,
chartType,
onChartTypeChange,
onExportData,
}: StatisticsResultsProps) {
return (
<>
{/* 基础统计 */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-green-600 dark:text-green-400" />
<h3 className="text-lg font-semibold"></h3>
</div>
<Button variant="outline" onClick={onExportData}>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card className="p-4 bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-semibold text-green-600 dark:text-green-400">{statistics.totalCount}</div>
</Card>
<Card className="p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-semibold text-blue-600 dark:text-blue-400">{statistics.totalArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-semibold text-purple-600 dark:text-purple-400">{statistics.avgArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-semibold text-orange-600 dark:text-orange-400">{statistics.maxArea.toFixed(2)} </div>
</Card>
<Card className="p-4 bg-pink-50 dark:bg-pink-900/20 border-pink-200 dark:border-pink-800">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-semibold text-pink-600 dark:text-pink-400">{statistics.minArea.toFixed(2)} </div>
</Card>
</div>
</Card>
{/* 图表选择 */}
<div className="flex items-center gap-2">
<Button
variant={chartType === 'bar' ? 'default' : 'outline'}
onClick={() => onChartTypeChange('bar')}
className={chartType === 'bar' ? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600' : ''}
>
<BarChart className="w-4 h-4 mr-2" />
</Button>
<Button
variant={chartType === 'pie' ? 'default' : 'outline'}
onClick={() => onChartTypeChange('pie')}
className={chartType === 'pie' ? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600' : ''}
>
<PieChart className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 土壤类型分布 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
{chartType === 'bar' ? (
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={statistics.soilTypeDistribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis dataKey="name" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
<Bar dataKey="count" name="地块数量" fill="#22c55e" />
<Bar dataKey="area" name="总面积(亩)" fill="#3b82f6" />
</RechartsBarChart>
</ResponsiveContainer>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.soilTypeDistribution}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.soilTypeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.soilTypeDistribution}
dataKey="area"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.soilTypeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
)}
</Card>
{/* 种植模式分布 */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
{chartType === 'bar' ? (
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart data={statistics.plantingModeDistribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis dataKey="name" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
<Bar dataKey="count" name="地块数量" fill="#a855f7" />
<Bar dataKey="area" name="总面积(亩)" fill="#f59e0b" />
</RechartsBarChart>
</ResponsiveContainer>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.plantingModeDistribution}
dataKey="count"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.plantingModeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2 text-center"></p>
<ResponsiveContainer width="100%" height={250}>
<RechartsPieChart>
<Pie
data={statistics.plantingModeDistribution}
dataKey="area"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{statistics.plantingModeDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
)}
</Card>
{/* 标签分布 */}
{statistics.tagDistribution.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{statistics.tagDistribution.map((tag) => (
<Card key={tag.name} className="p-4">
<Badge
style={{ backgroundColor: tag.color, color: 'white' }}
className="w-full justify-center mb-2"
>
{tag.name}
</Badge>
<div className="text-center">
<div className="text-2xl font-semibold text-green-600 dark:text-green-400">{tag.count}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</Card>
))}
</div>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { Card } from '@/components/ui/card';
export function UsageExamples() {
return (
<Card className="p-6 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<h3 className="text-lg font-semibold mb-2 flex items-center gap-2">
💡
<span>使</span>
</h3>
<ul className="space-y-1 text-sm text-blue-800 dark:text-blue-200">
<li> 50"沙土"50</li>
<li> "露地""有机种植"</li>
<li> 50-100"大棚"50-100</li>
<li> </li>
</ul>
</Card>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import { ReactNode } from 'react';
// 类型定义
export interface LandField {
id: string;
code: string;
name: string;
area: number;
location: string;
soilType: string;
plantingMode: string;
tags: string[];
status: 'active' | 'inactive' | 'pending';
description?: string;
createdAt: string;
updatedAt: string;
}
export interface LandTag {
id: string;
name: string;
color: string;
description?: string;
createdAt: string;
}
export interface SoilType {
id: string;
key: string;
name: string;
color: string;
}
export interface PlantingMode {
id: string;
key: string;
name: string;
emoji: string;
}
export interface FilterCondition {
soilTypes: string[];
plantingModes: string[];
tags: string[];
minArea: string;
maxArea: string;
keyword: string;
}
export interface StatisticsResult {
totalCount: number;
totalArea: number;
avgArea: number;
maxArea: number;
minArea: number;
soilTypeDistribution: { name: string; count: number; area: number; color: string }[];
plantingModeDistribution: { name: string; count: number; area: number; emoji: string }[];
tagDistribution: { name: string; count: number; color: string }[];
}
export interface LandStatisticsState {
fields: LandField[];
tags: LandTag[];
soilTypes: SoilType[];
plantingModes: PlantingMode[];
filters: FilterCondition;
statistics: StatisticsResult | null;
chartType: 'bar' | 'pie';
loading: boolean;
}
// 初始状态
export const initialState: LandStatisticsState = {
fields: [],
tags: [],
soilTypes: [],
plantingModes: [],
filters: {
soilTypes: [],
plantingModes: [],
tags: [],
minArea: '',
maxArea: '',
keyword: '',
},
statistics: null,
chartType: 'bar',
loading: false,
};
// Action 类型定义
export type LandStatisticsAction =
| { type: 'SET_FIELDS'; payload: LandField[] }
| { type: 'SET_TAGS'; payload: LandTag[] }
| { type: 'SET_SOIL_TYPES'; payload: SoilType[] }
| { type: 'SET_PLANTING_MODES'; payload: PlantingMode[] }
| { type: 'UPDATE_FILTER'; payload: { key: keyof FilterCondition; value: any } }
| { type: 'TOGGLE_ARRAY_FILTER'; payload: { key: 'soilTypes' | 'plantingModes' | 'tags'; value: string } }
| { type: 'CLEAR_FILTERS' }
| { type: 'SET_STATISTICS'; payload: StatisticsResult | null }
| { type: 'SET_CHART_TYPE'; payload: 'bar' | 'pie' }
| { type: 'SET_LOADING'; payload: boolean };
// Reducer 函数
export function LandStatisticsReducer(
state: LandStatisticsState,
action: LandStatisticsAction
): LandStatisticsState {
switch (action.type) {
case 'SET_FIELDS':
return { ...state, fields: action.payload };
case 'SET_TAGS':
return { ...state, tags: action.payload };
case 'SET_SOIL_TYPES':
return { ...state, soilTypes: action.payload };
case 'SET_PLANTING_MODES':
return { ...state, plantingModes: action.payload };
case 'UPDATE_FILTER':
return {
...state,
filters: {
...state.filters,
[action.payload.key]: action.payload.value,
},
};
case 'TOGGLE_ARRAY_FILTER':
const { key, value } = action.payload;
const currentArray = state.filters[key];
const newArray = currentArray.includes(value)
? currentArray.filter(v => v !== value)
: [...currentArray, value];
return {
...state,
filters: {
...state.filters,
[key]: newArray,
},
};
case 'CLEAR_FILTERS':
return {
...state,
filters: {
soilTypes: [],
plantingModes: [],
tags: [],
minArea: '',
maxArea: '',
keyword: '',
},
statistics: null,
};
case 'SET_STATISTICS':
return { ...state, statistics: action.payload };
case 'SET_CHART_TYPE':
return { ...state, chartType: action.payload };
case 'SET_LOADING':
return { ...state, loading: action.payload };
default:
return state;
}
}
// Context 类型定义
export interface LandStatisticsContextType {
state: LandStatisticsState;
dispatch: React.Dispatch<LandStatisticsAction>;
loadData: (forceReload?: boolean) => void;
executeQuery: () => void;
exportData: () => void;
handleFilterChange: (key: keyof FilterCondition, value: any) => void;
handleToggleArrayFilter: (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => void;
handleClearFilters: () => void;
handleChartTypeChange: (type: 'bar' | 'pie') => void;
}

View File

@@ -0,0 +1,490 @@
'use client';
import { createContext, useContext, useReducer, useEffect, ReactNode } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
LandStatisticsReducer,
initialState,
FilterCondition,
StatisticsResult,
LandStatisticsContextType,
} from './components/landStatisticsReducer';
import { FilterPanel } from './components/FilterPanel';
import { StatisticsResults } from './components/StatisticsResults';
import { UsageExamples } from './components/UsageExamples';
// Context 创建
const LandStatisticsContext = createContext<LandStatisticsContextType | null>(null);
// Provider 组件
export function LandStatisticsProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(LandStatisticsReducer, initialState);
const loadData = (forceReload = false) => {
// 加载地块数据
const fieldsData = localStorage.getItem('land_archive_data');
if (fieldsData && !forceReload) {
dispatch({ type: 'SET_FIELDS', payload: JSON.parse(fieldsData) });
} else {
// 初始化测试数据 - 包含所有土壤类型和种植模式
const testFields = [
{
id: '1',
code: 'TD001',
name: '东区沙质土试验田',
area: 85.5,
location: '东区1号地块',
soilType: 'sandy',
plantingMode: 'conventional',
tags: ['有机种植', '高产示范', '滴灌设施'],
status: 'active' as const,
description: '东区主要试验地块',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '2',
code: 'TD002',
name: '南区黏质土种植区',
area: 120.8,
location: '南区2号地块',
soilType: 'clay',
plantingMode: 'organic',
tags: ['有机种植', '生态种植', '智能监测'],
status: 'active' as const,
description: '南区有机种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '3',
code: 'TD003',
name: '西区壤质土生产基地',
area: 95.2,
location: '西区3号地块',
soilType: 'loam',
plantingMode: 'greenhouse',
tags: ['科技示范', '智能监测', '节水灌溉'],
status: 'active' as const,
description: '西区温室生产基地',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '4',
code: 'TD004',
name: '北区泥炭土水培区',
area: 45.6,
location: '北区4号地块',
soilType: 'peat',
plantingMode: 'hydroponic',
tags: ['水培种植', '智能监测', '绿色种植'],
status: 'active' as const,
description: '北区水培种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '5',
code: 'TD005',
name: '中央区石灰质土种植区',
area: 78.9,
location: '中央区5号地块',
soilType: 'chalky',
plantingMode: 'aeroponic',
tags: ['气培种植', '科技示范', '滴灌设施'],
status: 'active' as const,
description: '中央区气培种植试验田',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '6',
code: 'TD006',
name: '东区粉质土生态园',
area: 65.3,
location: '东区6号地块',
soilType: 'silty',
plantingMode: 'organic',
tags: ['生态种植', '绿色种植', '节水灌溉'],
status: 'active' as const,
description: '东区生态循环种植园',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '7',
code: 'TD007',
name: '南区岩石土改良区',
area: 35.7,
location: '南区7号地块',
soilType: 'rocky',
plantingMode: 'conventional',
tags: ['科技示范', '节水灌溉', '高产示范'],
status: 'active' as const,
description: '南区岩石土改良试验田',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '8',
code: 'TD008',
name: '西区沙质土有机基地',
area: 110.4,
location: '西区8号地块',
soilType: 'sandy',
plantingMode: 'organic',
tags: ['有机种植', '绿色种植', '智能监测'],
status: 'active' as const,
description: '西区有机种植基地',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '9',
code: 'TD009',
name: '北区黏质土传统区',
area: 88.6,
location: '北区9号地块',
soilType: 'clay',
plantingMode: 'conventional',
tags: ['传统种植', '滴灌设施', '高产示范'],
status: 'active' as const,
description: '北区传统种植示范区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
},
{
id: '10',
code: 'TD010',
name: '中央区壤质土智能园',
area: 92.1,
location: '中央区10号地块',
soilType: 'loam',
plantingMode: 'greenhouse',
tags: ['智能监测', '科技示范', '节水灌溉'],
status: 'active' as const,
description: '中央区智能温室园区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
];
dispatch({ type: 'SET_FIELDS', payload: testFields });
localStorage.setItem('land_archive_data', JSON.stringify(testFields));
}
// 加载标签数据
const tagsData = localStorage.getItem('land_archive_custom_tags');
if (tagsData) {
dispatch({ type: 'SET_TAGS', payload: JSON.parse(tagsData) });
} else {
// 初始化默认标签
const defaultTags = [
{ id: '1', name: '有机种植', color: '#22c55e', description: '符合有机种植标准的地块', createdAt: new Date().toISOString() },
{ id: '2', name: '高产示范', color: '#3b82f6', description: '高产示范田', createdAt: new Date().toISOString() },
{ id: '3', name: '滴灌设施', color: '#f97316', description: '配备滴灌系统的地块', createdAt: new Date().toISOString() },
{ id: '4', name: '智能监测', color: '#a855f7', description: '安装了智能监测设备的地块', createdAt: new Date().toISOString() },
{ id: '5', name: '生态种植', color: '#10b981', description: '采用生态循环种植模式', createdAt: new Date().toISOString() },
{ id: '6', name: '科技示范', color: '#ef4444', description: '农业科技示范地块', createdAt: new Date().toISOString() },
{ id: '7', name: '节水灌溉', color: '#06b6d4', description: '采用节水灌溉技术', createdAt: new Date().toISOString() },
{ id: '8', name: '绿色种植', color: '#84cc16', description: '绿色环保种植方式', createdAt: new Date().toISOString() },
];
dispatch({ type: 'SET_TAGS', payload: defaultTags });
localStorage.setItem('land_archive_custom_tags', JSON.stringify(defaultTags));
}
// 加载土壤类型
const soilTypesData = localStorage.getItem('land_soil_types');
if (soilTypesData) {
dispatch({ type: 'SET_SOIL_TYPES', payload: JSON.parse(soilTypesData) });
} else {
// 初始化默认土壤类型
const defaultSoilTypes = [
{ id: '1', key: 'sandy', name: '沙质土', color: '#f59e0b' },
{ id: '2', key: 'clay', name: '黏质土', color: '#8b5cf6' },
{ id: '3', key: 'loam', name: '壤质土', color: '#22c55e' },
{ id: '4', key: 'peat', name: '泥炭土', color: '#06b6d4' },
{ id: '5', key: 'chalky', name: '石灰质土', color: '#ec4899' },
{ id: '6', key: 'silty', name: '粉质土', color: '#f97316' },
{ id: '7', key: 'rocky', name: '岩石土', color: '#6b7280' }
];
dispatch({ type: 'SET_SOIL_TYPES', payload: defaultSoilTypes });
localStorage.setItem('land_soil_types', JSON.stringify(defaultSoilTypes));
}
// 加载种植模式
const plantingModesData = localStorage.getItem('land_planting_modes');
if (plantingModesData) {
dispatch({ type: 'SET_PLANTING_MODES', payload: JSON.parse(plantingModesData) });
} else {
// 初始化默认种植模式
const defaultPlantingModes = [
{ id: '1', key: 'conventional', name: '传统种植', emoji: '🌾' },
{ id: '2', key: 'organic', name: '有机种植', emoji: '🌱' },
{ id: '3', key: 'greenhouse', name: '温室种植', emoji: '🏠' },
{ id: '4', key: 'hydroponic', name: '水培种植', emoji: '💧' },
{ id: '5', key: 'aeroponic', name: '气培种植', emoji: '☁️' }
];
dispatch({ type: 'SET_PLANTING_MODES', payload: defaultPlantingModes });
localStorage.setItem('land_planting_modes', JSON.stringify(defaultPlantingModes));
}
};
const handleFilterChange = (key: keyof FilterCondition, value: any) => {
dispatch({ type: 'UPDATE_FILTER', payload: { key, value } });
};
const handleToggleArrayFilter = (key: 'soilTypes' | 'plantingModes' | 'tags', value: string) => {
dispatch({ type: 'TOGGLE_ARRAY_FILTER', payload: { key, value } });
};
const handleClearFilters = () => {
dispatch({ type: 'CLEAR_FILTERS' });
toast.success('筛选条件已清空');
};
const executeQuery = () => {
// 应用筛选条件
let filteredFields = [...state.fields];
// 关键词筛选
if (state.filters.keyword) {
const keyword = state.filters.keyword.toLowerCase();
filteredFields = filteredFields.filter(f =>
f.name.toLowerCase().includes(keyword) ||
f.code.toLowerCase().includes(keyword) ||
f.location?.toLowerCase().includes(keyword)
);
}
// 土壤类型筛选
if (state.filters.soilTypes.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.soilTypes.includes(f.soilType)
);
}
// 种植模式筛选
if (state.filters.plantingModes.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.plantingModes.includes(f.plantingMode)
);
}
// 标签筛选
if (state.filters.tags.length > 0) {
filteredFields = filteredFields.filter(f =>
state.filters.tags.some(tag => f.tags.includes(tag))
);
}
// 面积范围筛选
if (state.filters.minArea) {
const minArea = parseFloat(state.filters.minArea);
filteredFields = filteredFields.filter(f => f.area >= minArea);
}
if (state.filters.maxArea) {
const maxArea = parseFloat(state.filters.maxArea);
filteredFields = filteredFields.filter(f => f.area <= maxArea);
}
if (filteredFields.length === 0) {
toast.warning('未找到符合条件的地块');
dispatch({ type: 'SET_STATISTICS', payload: null });
return;
}
// 计算统计结果
const totalCount = filteredFields.length;
const totalArea = filteredFields.reduce((sum, f) => sum + f.area, 0);
const avgArea = totalArea / totalCount;
const maxArea = Math.max(...filteredFields.map(f => f.area));
const minArea = Math.min(...filteredFields.map(f => f.area));
// 土壤类型分布 - 显示所有定义的土壤类型
const soilTypeDistribution = state.soilTypes.map(soilType => {
const count = filteredFields.filter(f => f.soilType === soilType.key).length;
const area = filteredFields
.filter(f => f.soilType === soilType.key)
.reduce((sum, f) => sum + f.area, 0);
return {
name: soilType.name,
count,
area,
color: soilType.color,
};
});
// 种植模式分布 - 显示所有定义的种植模式
const plantingModeDistribution = state.plantingModes.map(mode => {
const count = filteredFields.filter(f => f.plantingMode === mode.key).length;
const area = filteredFields
.filter(f => f.plantingMode === mode.key)
.reduce((sum, f) => sum + f.area, 0);
return {
name: mode.name,
count,
area,
emoji: mode.emoji,
};
});
// 标签分布
const tagMap = new Map<string, number>();
filteredFields.forEach(f => {
f.tags.forEach(tag => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
});
});
const tagDistribution = Array.from(tagMap.entries()).map(([name, count]) => {
const tag = state.tags.find(t => t.name === name);
return {
name,
count,
color: tag?.color || '#6b7280',
};
}).sort((a, b) => b.count - a.count);
const statisticsResult: StatisticsResult = {
totalCount,
totalArea,
avgArea,
maxArea,
minArea,
soilTypeDistribution,
plantingModeDistribution,
tagDistribution,
};
dispatch({ type: 'SET_STATISTICS', payload: statisticsResult });
toast.success(`查询完成,找到 ${totalCount} 个地块`);
};
const handleChartTypeChange = (type: 'bar' | 'pie') => {
dispatch({ type: 'SET_CHART_TYPE', payload: type });
};
const exportData = () => {
if (!state.statistics) {
toast.error('请先执行查询');
return;
}
const data = {
查询时间: new Date().toLocaleString('zh-CN'),
筛选条件: state.filters,
统计结果: state.statistics,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `地块统计分析_${new Date().getTime()}.json`;
a.click();
URL.revokeObjectURL(url);
toast.success('数据导出成功');
};
const contextValue: LandStatisticsContextType = {
state,
dispatch,
loadData,
executeQuery,
exportData,
handleFilterChange,
handleToggleArrayFilter,
handleClearFilters,
handleChartTypeChange,
};
return (
<LandStatisticsContext.Provider value={contextValue}>
{children}
</LandStatisticsContext.Provider>
);
}
// Hook
export function useLandStatistics() {
const context = useContext(LandStatisticsContext);
if (!context) {
throw new Error('useLandStatistics must be used within LandStatisticsProvider');
}
return context;
}
export default function LandStatisticsPage() {
return (
<LandStatisticsProvider>
<LandStatistics />
</LandStatisticsProvider>
);
}
function LandStatistics() {
const { state, loadData, executeQuery, exportData, handleFilterChange, handleToggleArrayFilter, handleClearFilters, handleChartTypeChange } = useLandStatistics();
useEffect(() => {
loadData();
}, []);
const reloadTestData = () => {
localStorage.removeItem('land_archive_data');
localStorage.removeItem('land_archive_custom_tags');
localStorage.removeItem('land_soil_types');
localStorage.removeItem('land_planting_modes');
loadData(true);
handleClearFilters();
toast.success('测试数据已重新加载');
};
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">
</p>
</div>
<Button
variant="outline"
onClick={reloadTestData}
className="text-sm"
>
</Button>
</div>
{/* 筛选条件 */}
<FilterPanel
filters={state.filters}
soilTypes={state.soilTypes}
plantingModes={state.plantingModes}
tags={state.tags}
onFilterChange={handleFilterChange}
onToggleArrayFilter={handleToggleArrayFilter}
onClearFilters={handleClearFilters}
onExecuteQuery={executeQuery}
/>
{/* 统计结果 */}
{state.statistics && (
<StatisticsResults
statistics={state.statistics}
chartType={state.chartType}
onChartTypeChange={handleChartTypeChange}
onExportData={exportData}
/>
)}
{/* 使用示例 */}
<UsageExamples />
</div>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { SpatialField } from './spatialQueryReducer';
import {
Download,
FileText,
Globe,
Database,
Code,
Copy,
CheckCircle
} from 'lucide-react';
import { generateGeoJSON, generateKML, generateCSV, generateSQLExample } from './spatialQueryUtils';
import { toast } from 'sonner';
interface ExportDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
results: SpatialField[];
queryType: string;
queryGeometry: any;
bufferDistance?: number;
}
export function ExportDialog({
open,
onOpenChange,
results,
queryType,
queryGeometry,
bufferDistance
}: ExportDialogProps) {
const [exportFormat, setExportFormat] = useState<'geojson' | 'kml' | 'csv'>('geojson');
const [includeGeometry, setIncludeGeometry] = useState(true);
const [includeAttributes, setIncludeAttributes] = useState(true);
const [previewContent, setPreviewContent] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [copied, setCopied] = useState(false);
const generateExportData = () => {
let data = '';
switch (exportFormat) {
case 'geojson':
data = generateGeoJSON(results);
break;
case 'kml':
data = generateKML(results);
break;
case 'csv':
data = generateCSV(results);
break;
}
return data;
};
const handlePreview = () => {
const data = generateExportData();
setPreviewContent(data.substring(0, 1000) + (data.length > 1000 ? '...' : ''));
setShowPreview(true);
};
const handleDownload = () => {
const data = generateExportData();
const blob = new Blob([data], {
type: getContentType(exportFormat)
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFileName(exportFormat);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`数据已导出为 ${exportFormat.toUpperCase()} 格式`);
onOpenChange(false);
};
const handleCopyToClipboard = () => {
const data = generateExportData();
navigator.clipboard.writeText(data).then(() => {
setCopied(true);
toast.success('数据已复制到剪贴板');
setTimeout(() => setCopied(false), 2000);
});
};
const getContentType = (format: string): string => {
switch (format) {
case 'geojson':
return 'application/json';
case 'kml':
return 'application/vnd.google-earth.kml+xml';
case 'csv':
return 'text/csv';
default:
return 'text/plain';
}
};
const getFileName = (format: string): string => {
const timestamp = new Date().toISOString().slice(0, 10);
return `spatial_query_results_${timestamp}.${format}`;
};
const formatInfo = {
geojson: {
name: 'GeoJSON',
description: '开放的地理空间数据格式,支持几何和属性信息',
icon: Globe,
color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-50 dark:bg-green-950'
},
kml: {
name: 'KML',
description: 'Google Earth 支持的地理标记语言',
icon: FileText,
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-950'
},
csv: {
name: 'CSV',
description: '表格数据格式适合在Excel等软件中分析',
icon: Database,
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-950'
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 导出统计 */}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium">{results.length} </div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium">
{results.reduce((sum, field) => sum + field.area, 0).toFixed(1)}
</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium">{getQueryTypeName(queryType)}</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium">{new Date().toLocaleString()}</div>
</div>
</div>
</div>
{/* 格式选择 */}
<div className="space-y-3">
<Label></Label>
<RadioGroup value={exportFormat} onValueChange={(value: any) => setExportFormat(value)}>
{Object.entries(formatInfo).map(([key, info]) => {
const IconComponent = info.icon;
return (
<div key={key} className="flex items-center space-x-2 p-3 border rounded-lg hover:bg-muted/50">
<RadioGroupItem value={key} id={key} />
<Label htmlFor={key} className="flex-1 cursor-pointer">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${info.bgColor}`}>
<IconComponent className={`w-4 h-4 ${info.color}`} />
</div>
<div>
<div className="font-medium">{info.name}</div>
<div className="text-sm text-muted-foreground">{info.description}</div>
</div>
</div>
</Label>
</div>
);
})}
</RadioGroup>
</div>
{/* 导出选项 */}
<div className="space-y-3">
<Label></Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="include-geometry"
checked={includeGeometry}
onCheckedChange={(checked) => setIncludeGeometry(checked as boolean)}
/>
<Label htmlFor="include-geometry" className="text-sm">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="include-attributes"
checked={includeAttributes}
onCheckedChange={(checked) => setIncludeAttributes(checked as boolean)}
/>
<Label htmlFor="include-attributes" className="text-sm">
</Label>
</div>
</div>
</div>
{/* 预览区域 */}
{showPreview && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label></Label>
<Badge variant="outline" className="text-xs">
1000
</Badge>
</div>
<Textarea
value={previewContent}
readOnly
className="font-mono text-xs bg-muted min-h-[200px]"
placeholder="预览内容..."
/>
</div>
)}
{/* SQL示例 */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
<Label>PostGIS SQL查询示例</Label>
</div>
<Textarea
value={generateSQLExample(queryType, queryGeometry, bufferDistance)}
readOnly
className="font-mono text-sm bg-muted"
rows={6}
/>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(generateSQLExample(queryType, queryGeometry, bufferDistance));
toast.success('SQL语句已复制到剪贴板');
}}
>
<Copy className="w-3 h-3 mr-2" />
SQL语句
</Button>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setShowPreview(!showPreview)}>
{showPreview ? '隐藏预览' : '显示预览'}
</Button>
<Button
variant="outline"
onClick={handleCopyToClipboard}
disabled={copied}
>
{copied ? (
<>
<CheckCircle className="w-4 h-4 mr-2" />
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
</>
)}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleDownload} className="bg-green-600 hover:bg-green-700">
<Download className="w-4 h-4 mr-2" />
{formatInfo[exportFormat].name}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// 获取查询类型名称
function getQueryTypeName(type: string): string {
const names: Record<string, string> = {
'point-in-polygon': '点在多边形内查询',
'polygon-intersect': '多边形相交查询',
'polygon-adjacent': '多边形相邻查询',
'buffer': '缓冲区查询'
};
return names[type] || type;
}

View File

@@ -0,0 +1,491 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { SpatialQueryState, SpatialQueryAction } from './spatialQueryReducer';
import {
MapPin,
Shapes,
MousePointer,
RefreshCw,
Check,
X,
Navigation
} from 'lucide-react';
interface MapPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: 'point' | 'polygon';
state: SpatialQueryState;
dispatch: React.Dispatch<SpatialQueryAction>;
onConfirm: (result: { lat: number; lng: number } | number[][]) => void;
}
export function MapPicker({
open,
onOpenChange,
mode,
state,
dispatch,
onConfirm
}: MapPickerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [currentPoint, setCurrentPoint] = useState<{ lat: number; lng: number } | null>(null);
const [polygonPoints, setPolygonPoints] = useState<number[][]>([]);
const [mousePos, setMousePos] = useState<{ x: number; y: number } | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [mapOffset, setMapOffset] = useState({ x: 0, y: 0 });
const [mapScale, setMapScale] = useState(1);
// 模拟地图中心坐标(北京天安门)
const mapCenter = { lat: 39.9042, lng: 116.4074 };
const mapBounds = {
north: 39.9100,
south: 39.8984,
east: 116.4200,
west: 116.3948
};
useEffect(() => {
if (open && canvasRef.current) {
drawMap();
}
}, [open, currentPoint, polygonPoints, mousePos, mapOffset, mapScale]);
const drawMap = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// 清空画布
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, width, height);
// 保存变换状态
ctx.save();
// 应用偏移和缩放
ctx.translate(mapOffset.x, mapOffset.y);
ctx.scale(mapScale, mapScale);
// 绘制网格背景
drawGrid(ctx, width, height);
// 绘制模拟地块
drawMockFields(ctx, width, height);
// 绘制查询几何图形
if (mode === 'point' && currentPoint) {
drawPoint(ctx, currentPoint, width, height);
}
if (mode === 'polygon' && polygonPoints.length > 0) {
drawPolygon(ctx, polygonPoints, width, height);
}
// 绘制鼠标预览
if (mousePos && mode === 'polygon' && isDrawing) {
drawPolygonPreview(ctx, polygonPoints, mousePos, width, height);
}
// 恢复变换状态
ctx.restore();
// 绘制UI元素不受变换影响
drawUIElements(ctx, width, height);
};
const drawGrid = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
const gridSize = 50 * mapScale;
const startX = (mapOffset.x % gridSize + gridSize) % gridSize;
const startY = (mapOffset.y % gridSize + gridSize) % gridSize;
for (let x = startX; x < width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = startY; y < height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
};
const drawMockFields = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
// 模拟3个地块的位置和形状
const fields = [
{
points: [[100, 100], [200, 100], [200, 180], [100, 180]],
color: '#22c55e',
name: '东区沙质土试验田'
},
{
points: [[250, 120], [350, 120], [350, 200], [250, 200]],
color: '#3b82f6',
name: '西区黏质土示范区'
},
{
points: [[150, 250], [250, 250], [250, 330], [150, 330]],
color: '#f97316',
name: '南区壤质土生产基地'
}
];
fields.forEach(field => {
// 绘制地块多边形
ctx.fillStyle = field.color + '30';
ctx.strokeStyle = field.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(field.points[0][0], field.points[0][1]);
field.points.forEach(point => {
ctx.lineTo(point[0], point[1]);
});
ctx.closePath();
ctx.fill();
ctx.stroke();
// 绘制地块名称
ctx.fillStyle = '#1f2937';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
const centerX = field.points.reduce((sum, p) => sum + p[0], 0) / field.points.length;
const centerY = field.points.reduce((sum, p) => sum + p[1], 0) / field.points.length;
ctx.fillText(field.name, centerX, centerY);
});
};
const drawPoint = (ctx: CanvasRenderingContext2D, point: { lat: number; lng: number }, width: number, height: number) => {
const canvasCoords = latLngToCanvas(point.lat, point.lng, width, height);
// 绘制点
ctx.fillStyle = '#ef4444';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(canvasCoords.x, canvasCoords.y, 8, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// 绘制中心点
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(canvasCoords.x, canvasCoords.y, 2, 0, 2 * Math.PI);
ctx.fill();
};
const drawPolygon = (ctx: CanvasRenderingContext2D, points: number[][], width: number, height: number) => {
if (points.length === 0) return;
ctx.fillStyle = '#3b82f630';
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
points.forEach(point => {
ctx.lineTo(point[0], point[1]);
});
if (points.length > 2) {
ctx.closePath();
ctx.fill();
}
ctx.stroke();
// 绘制顶点
points.forEach((point, index) => {
ctx.fillStyle = '#3b82f6';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(point[0], point[1], 5, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
// 绘制顶点编号
ctx.fillStyle = '#1f2937';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText((index + 1).toString(), point[0], point[1] - 10);
});
};
const drawPolygonPreview = (ctx: CanvasRenderingContext2D, points: number[][], mousePos: { x: number; y: number }, width: number, height: number) => {
if (points.length === 0) return;
ctx.strokeStyle = '#3b82f680';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
if (points.length > 0) {
ctx.moveTo(points[points.length - 1][0], points[points.length - 1][1]);
ctx.lineTo(mousePos.x, mousePos.y);
}
ctx.stroke();
ctx.setLineDash([]);
};
const drawUIElements = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
// 绘制坐标信息
if (mousePos) {
const latLng = canvasToLatLng(mousePos.x, mousePos.y, width, height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(mousePos.x + 10, mousePos.y - 30, 120, 25);
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${latLng.lat.toFixed(4)}, ${latLng.lng.toFixed(4)}`, mousePos.x + 15, mousePos.y - 15);
}
// 绘制操作提示
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(10, 10, 200, 60);
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
let instructions = '';
if (mode === 'point') {
instructions = '单击地图选择查询点\n滚轮缩放拖拽移动地图';
} else {
instructions = `单击添加多边形顶点 (${polygonPoints.length}个)\n右键完成绘制滚轮缩放拖拽移动`;
}
instructions.split('\n').forEach((line, index) => {
ctx.fillText(line, 15, 30 + index * 15);
});
};
const latLngToCanvas = (lat: number, lng: number, width: number, height: number) => {
const x = ((lng - mapBounds.west) / (mapBounds.east - mapBounds.west)) * width;
const y = ((mapBounds.north - lat) / (mapBounds.north - mapBounds.south)) * height;
return { x, y };
};
const canvasToLatLng = (x: number, y: number, width: number, height: number) => {
const lng = (x / width) * (mapBounds.east - mapBounds.west) + mapBounds.west;
const lat = mapBounds.north - (y / height) * (mapBounds.north - mapBounds.south);
return { lat, lng };
};
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const latLng = canvasToLatLng(x, y, canvas.width, canvas.height);
if (mode === 'point') {
setCurrentPoint(latLng);
} else if (mode === 'polygon') {
if (e.button === 0) { // 左键添加顶点
setPolygonPoints([...polygonPoints, [x, y]]);
setIsDrawing(true);
}
}
};
const handleCanvasRightClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (mode === 'polygon' && polygonPoints.length >= 3) {
// 完成多边形绘制
setIsDrawing(false);
}
};
const handleCanvasMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setMousePos({ x, y });
if (isDragging) {
setMapOffset({
x: x - (mousePos?.x || 0),
y: y - (mousePos?.y || 0)
});
}
};
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) { // 中键或Shift+左键拖拽
e.preventDefault();
setIsDragging(true);
}
};
const handleCanvasMouseUp = () => {
setIsDragging(false);
};
const handleCanvasWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setMapScale(prev => Math.max(0.5, Math.min(3, prev * delta)));
};
const handleConfirm = () => {
if (mode === 'point' && currentPoint) {
onConfirm(currentPoint);
} else if (mode === 'polygon' && polygonPoints.length >= 3) {
// 转换为坐标数组
const coordinates = polygonPoints.map(point => {
const latLng = canvasToLatLng(point[0], point[1], 600, 400);
return [latLng.lng, latLng.lat];
});
onConfirm(coordinates);
}
};
const handleReset = () => {
setCurrentPoint(null);
setPolygonPoints([]);
setIsDrawing(false);
setMapOffset({ x: 0, y: 0 });
setMapScale(1);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{mode === 'point' ? (
<>
<MapPin className="w-5 h-5" />
</>
) : (
<>
<Shapes className="w-5 h-5" />
</>
)}
</DialogTitle>
<DialogDescription>
{mode === 'point'
? '在地图上单击选择空间查询的参考点'
: '在地图上绘制多边形用于空间查询至少3个顶点'
}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 状态信息 */}
<Card className="p-3 bg-muted/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm">
<Badge variant="outline">
{mode === 'point' ? '点选择模式' : '多边形绘制模式'}
</Badge>
{mode === 'point' && currentPoint && (
<span className="text-muted-foreground">
: {currentPoint.lat.toFixed(4)}, {currentPoint.lng.toFixed(4)}
</span>
)}
{mode === 'polygon' && (
<span className="text-muted-foreground">
: {polygonPoints.length}
{polygonPoints.length >= 3 && ' (可完成绘制)'}
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Navigation className="w-4 h-4" />
: {(mapScale * 100).toFixed(0)}%
</div>
</div>
</Card>
{/* 地图画布 */}
<div className="border rounded-lg overflow-hidden">
<canvas
ref={canvasRef}
width={600}
height={400}
className="w-full cursor-crosshair"
onClick={handleCanvasClick}
onContextMenu={handleCanvasRightClick}
onMouseMove={handleCanvasMouseMove}
onMouseDown={handleCanvasMouseDown}
onMouseUp={handleCanvasMouseUp}
onWheel={handleCanvasWheel}
style={{ cursor: isDragging ? 'grabbing' : 'crosshair' }}
/>
</div>
{/* 操作说明 */}
<Card className="p-4 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800">
<h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2"></h4>
<div className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
{mode === 'point' ? (
<>
<div> </div>
<div> 使</div>
<div> Shift+</div>
</>
) : (
<>
<div> </div>
<div> 3</div>
<div> 使</div>
<div> Shift+</div>
</>
)}
</div>
</Card>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleReset}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleConfirm}
disabled={
(mode === 'point' && !currentPoint) ||
(mode === 'polygon' && polygonPoints.length < 3)
}
className="bg-green-600 hover:bg-green-700"
>
<Check className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,393 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { SpatialQueryState, SpatialQueryAction } from './spatialQueryReducer';
import {
MapPin,
Shapes,
Circle,
Search,
Map,
RefreshCw,
Download,
Code,
Database
} from 'lucide-react';
interface QueryPanelProps {
state: SpatialQueryState;
dispatch: React.Dispatch<SpatialQueryAction>;
onExecuteQuery: () => void;
onShowMapPicker: () => void;
}
export function QueryPanel({ state, dispatch, onExecuteQuery, onShowMapPicker }: QueryPanelProps) {
return (
<div className="space-y-6">
{/* 查询类型选择 */}
<Card className="p-6 bg-card">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Search className="w-5 h-5 text-blue-600 dark:text-blue-400" />
<h3 className="text-lg font-semibold"></h3>
</div>
<Select
value={state.queryType}
onValueChange={(value: any) => dispatch({ type: 'SET_QUERY_TYPE', payload: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="point-in-polygon">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
<SelectItem value="polygon-intersect">
<div className="flex items-center gap-2">
<Shapes className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
<SelectItem value="polygon-adjacent">
<div className="flex items-center gap-2">
<Map className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
<SelectItem value="buffer">
<div className="flex items-center gap-2">
<Circle className="w-4 h-4" />
<span></span>
</div>
</SelectItem>
</SelectContent>
</Select>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">
{state.queryType === 'point-in-polygon' && '查询指定坐标点位于哪些地块范围内'}
{state.queryType === 'polygon-intersect' && '查询与指定多边形相交的所有地块'}
{state.queryType === 'polygon-adjacent' && '查询与指定多边形相邻(共享边界)的地块'}
{state.queryType === 'buffer' && '查询指定点周围一定距离范围内的地块'}
</p>
</div>
</div>
</Card>
{/* 查询参数设置 */}
<Card className="p-6 bg-card">
<div className="space-y-4">
<h4 className="font-medium"></h4>
{state.queryType === 'point-in-polygon' && (
<div className="space-y-3">
<Label></Label>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
type="number"
step="0.000001"
placeholder="39.9042"
value={state.selectedPoint?.lat || ''}
onChange={(e) => dispatch({
type: 'SET_SELECTED_POINT',
payload: state.selectedPoint
? { ...state.selectedPoint, lat: parseFloat(e.target.value) || 0 }
: { lat: parseFloat(e.target.value) || 0, lng: 0 }
})}
/>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
type="number"
step="0.000001"
placeholder="116.4074"
value={state.selectedPoint?.lng || ''}
onChange={(e) => dispatch({
type: 'SET_SELECTED_POINT',
payload: state.selectedPoint
? { ...state.selectedPoint, lng: parseFloat(e.target.value) || 0 }
: { lat: 0, lng: parseFloat(e.target.value) || 0 }
})}
/>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
dispatch({ type: 'SET_SHOW_MAP_PICKER', payload: true });
dispatch({ type: 'SET_MAP_PICKER_MODE', payload: 'point' });
onShowMapPicker();
}}
>
<Map className="w-4 h-4 mr-2" />
</Button>
</div>
)}
{(state.queryType === 'polygon-intersect' || state.queryType === 'polygon-adjacent') && (
<div className="space-y-3">
<Label></Label>
{state.queryPolygon && state.queryPolygon.length > 0 ? (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-green-800 dark:text-green-200">
({state.queryPolygon.length} )
</span>
<Button
variant="ghost"
size="sm"
onClick={() => dispatch({ type: 'SET_QUERY_POLYGON', payload: null })}
>
<RefreshCw className="w-3 h-3" />
</Button>
</div>
<div className="text-xs text-green-700 dark:text-green-300">
</div>
</div>
) : (
<Button
variant="outline"
className="w-full"
onClick={() => {
dispatch({ type: 'SET_SHOW_MAP_PICKER', payload: true });
dispatch({ type: 'SET_MAP_PICKER_MODE', payload: 'polygon' });
onShowMapPicker();
}}
>
<Shapes className="w-4 h-4 mr-2" />
</Button>
)}
</div>
)}
{state.queryType === 'buffer' && (
<div className="space-y-3">
<div>
<Label></Label>
<div className="grid grid-cols-2 gap-3 mt-2">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
type="number"
step="0.000001"
placeholder="39.9042"
value={state.selectedPoint?.lat || ''}
onChange={(e) => dispatch({
type: 'SET_SELECTED_POINT',
payload: state.selectedPoint
? { ...state.selectedPoint, lat: parseFloat(e.target.value) || 0 }
: { lat: parseFloat(e.target.value) || 0, lng: 0 }
})}
/>
</div>
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input
type="number"
step="0.000001"
placeholder="116.4074"
value={state.selectedPoint?.lng || ''}
onChange={(e) => dispatch({
type: 'SET_SELECTED_POINT',
payload: state.selectedPoint
? { ...state.selectedPoint, lng: parseFloat(e.target.value) || 0 }
: { lat: 0, lng: parseFloat(e.target.value) || 0 }
})}
/>
</div>
</div>
</div>
<div>
<Label></Label>
<Input
type="number"
min="10"
max="10000"
step="10"
value={state.bufferDistance}
onChange={(e) => dispatch({
type: 'SET_BUFFER_DISTANCE',
payload: parseInt(e.target.value) || 100
})}
/>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
dispatch({ type: 'SET_SHOW_MAP_PICKER', payload: true });
dispatch({ type: 'SET_MAP_PICKER_MODE', payload: 'point' });
onShowMapPicker();
}}
>
<Map className="w-4 h-4 mr-2" />
</Button>
</div>
)}
<Separator />
<Button
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={onExecuteQuery}
disabled={state.isQuerying || !isQueryValid(state)}
>
{state.isQuerying ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</Button>
</div>
</Card>
{/* 导出选项 */}
{state.queryResult && state.queryResult.length > 0 && (
<Card className="p-6 bg-card">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download className="w-5 h-5 text-green-600 dark:text-green-400" />
<h3 className="text-lg font-semibold"></h3>
</div>
<div className="space-y-3">
<Label></Label>
<Select
value={state.exportFormat}
onValueChange={(value: any) => dispatch({ type: 'SET_EXPORT_FORMAT', payload: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="geojson">GeoJSON</SelectItem>
<SelectItem value="kml">KML</SelectItem>
<SelectItem value="csv">CSV</SelectItem>
</SelectContent>
</Select>
<Button
className="w-full bg-green-600 hover:bg-green-700"
onClick={() => dispatch({ type: 'SET_SHOW_EXPORT_DIALOG', payload: true })}
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</Card>
)}
{/* SQL示例 */}
{state.queryResult && (
<Card className="p-6 bg-card">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<h3 className="text-lg font-semibold">PostGIS SQL示例</h3>
</div>
<div className="space-y-3">
<Label>SQL查询语句</Label>
<Textarea
value={generateSQLExample(state)}
readOnly
className="font-mono text-sm bg-muted"
rows={6}
/>
<Button
variant="outline"
className="w-full"
onClick={() => {
navigator.clipboard.writeText(generateSQLExample(state));
// 这里可以添加toast提示
}}
>
<Database className="w-4 h-4 mr-2" />
SQL语句
</Button>
</div>
</div>
</Card>
)}
</div>
);
}
// 检查查询参数是否有效
function isQueryValid(state: SpatialQueryState): boolean {
switch (state.queryType) {
case 'point-in-polygon':
return state.selectedPoint !== null;
case 'polygon-intersect':
case 'polygon-adjacent':
return state.queryPolygon !== null && state.queryPolygon.length >= 3;
case 'buffer':
return state.selectedPoint !== null && state.bufferDistance > 0;
default:
return false;
}
}
// 生成SQL示例的简化版本
function generateSQLExample(state: SpatialQueryState): string {
switch (state.queryType) {
case 'point-in-polygon':
return `-- 点在多边形内查询
SELECT * FROM fields
WHERE ST_Contains(
geometry,
ST_GeomFromText('POINT(${state.selectedPoint?.lng} ${state.selectedPoint?.lat})', 4326)
);`;
case 'polygon-intersect':
return `-- 多边形相交查询
SELECT * FROM fields
WHERE ST_Intersects(
geometry,
ST_GeomFromText('POLYGON((...))', 4326)
);`;
case 'polygon-adjacent':
return `-- 多边形相邻查询
SELECT * FROM fields
WHERE ST_Touches(
geometry,
ST_GeomFromText('POLYGON((...))', 4326)
);`;
case 'buffer':
return `-- 缓冲区查询
SELECT * FROM fields
WHERE ST_DWithin(
geometry,
ST_GeomFromText('POINT(${state.selectedPoint?.lng} ${state.selectedPoint?.lat})', 4326),
${state.bufferDistance}
);`;
default:
return '-- 请选择查询类型';
}
}

View File

@@ -0,0 +1,344 @@
'use client';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { SpatialField } from './spatialQueryReducer';
import {
MapPin,
AreaChart,
Ruler,
User,
Calendar,
RefreshCw,
Eye,
FileText,
Database
} from 'lucide-react';
interface ResultsPanelProps {
results: SpatialField[] | null;
isLoading: boolean;
onViewOnMap: (fields: SpatialField[]) => void;
onExportData: () => void;
onClearResults: () => void;
}
export function ResultsPanel({ results, isLoading, onViewOnMap, onExportData, onClearResults }: ResultsPanelProps) {
if (isLoading) {
return (
<Card className="p-6 bg-card">
<div className="flex items-center justify-center py-12">
<div className="text-center space-y-3">
<RefreshCw className="w-8 h-8 text-blue-600 dark:text-blue-400 animate-spin mx-auto" />
<p className="text-muted-foreground">...</p>
</div>
</div>
</Card>
);
}
if (!results || results.length === 0) {
return (
<Card className="p-6 bg-card">
<div className="flex items-center justify-center py-12">
<div className="text-center space-y-3">
<Database className="w-12 h-12 text-muted-foreground mx-auto" />
<h3 className="text-lg font-medium"></h3>
<p className="text-sm text-muted-foreground max-w-md">
</p>
</div>
</div>
</Card>
);
}
// 计算统计信息
const totalArea = results.reduce((sum, field) => sum + field.area, 0);
const totalPerimeter = results.reduce((sum, field) => sum + field.perimeter, 0);
const soilTypes = [...new Set(results.map(field => field.soilType))];
const plantingModes = [...new Set(results.map(field => field.plantingMode))];
return (
<div className="space-y-6">
{/* 查询结果统计 */}
<Card className="p-6 bg-card">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold"></h3>
<Badge variant="secondary" className="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
{results.length}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-1">
<AreaChart 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="text-xl font-bold text-green-700 dark:text-green-300">
{totalArea.toFixed(1)}
</div>
<div className="text-xs text-green-600 dark:text-green-400"></div>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-1">
<Ruler className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-blue-800 dark:text-blue-200"></span>
</div>
<div className="text-xl font-bold text-blue-700 dark:text-blue-300">
{totalPerimeter.toFixed(0)}
</div>
<div className="text-xs text-blue-600 dark:text-blue-400"></div>
</div>
<div className="p-3 bg-purple-50 dark:bg-purple-950 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-2 mb-1">
<MapPin className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm text-purple-800 dark:text-purple-200"></span>
</div>
<div className="text-xl font-bold text-purple-700 dark:text-purple-300">
{soilTypes.length}
</div>
<div className="text-xs text-purple-600 dark:text-purple-400"></div>
</div>
<div className="p-3 bg-orange-50 dark:bg-orange-950 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-4 h-4 text-orange-600 dark:text-orange-400" />
<span className="text-sm text-orange-800 dark:text-orange-200"></span>
</div>
<div className="text-xl font-bold text-orange-700 dark:text-orange-300">
{plantingModes.length}
</div>
<div className="text-xs text-orange-600 dark:text-orange-400"></div>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => onViewOnMap(results)}
>
<Eye className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
className="flex-1"
onClick={onExportData}
>
<Database className="w-4 h-4 mr-2" />
</Button>
<Button
variant="ghost"
onClick={onClearResults}
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
{/* 地块详细列表 */}
<Card className="p-6 bg-card">
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-3 max-h-96 overflow-y-auto">
{results.map((field, index) => (
<div key={field.id} className="p-4 border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<h4 className="font-medium text-base">{field.name}</h4>
<p className="text-sm text-muted-foreground">{field.code}</p>
</div>
<Badge variant="outline" className="font-light">
#{index + 1}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium text-green-600 dark:text-green-400">
{field.area.toFixed(1)}
</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium text-blue-600 dark:text-blue-400">
{field.perimeter.toFixed(0)}
</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="font-medium text-xs">
{field.centroid.lat.toFixed(4)}, {field.centroid.lng.toFixed(4)}
</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div>
<Badge
variant={field.status === 'active' ? 'default' : 'secondary'}
className="text-xs font-light"
>
{field.status === 'active' ? '活跃' : '未激活'}
</Badge>
</div>
</div>
</div>
<Separator className="my-3" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className="font-light">
{getSoilTypeName(field.soilType)}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">:</span>
<Badge variant="outline" className="font-light">
{getPlantingModeName(field.plantingMode)}
</Badge>
</div>
<div className="flex items-center gap-2">
<User className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{field.owner}</span>
</div>
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span>: {field.createdAt}</span>
</div>
</div>
))}
</div>
</div>
</Card>
{/* 分类统计 */}
<Card className="p-6 bg-card">
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 土壤类型分布 */}
<div>
<h4 className="font-medium mb-3"></h4>
<div className="space-y-2">
{soilTypes.map(soilType => {
const fields = results.filter(field => field.soilType === soilType);
const area = fields.reduce((sum, field) => sum + field.area, 0);
const percentage = (area / totalArea * 100).toFixed(1);
return (
<div key={soilType} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: getSoilTypeColor(soilType) }} />
<span className="text-sm">{getSoilTypeName(soilType)}</span>
</div>
<div className="text-right">
<div className="text-sm font-medium">{area.toFixed(1)} </div>
<div className="text-xs text-muted-foreground">{percentage}%</div>
</div>
</div>
);
})}
</div>
</div>
{/* 种植模式分布 */}
<div>
<h4 className="font-medium mb-3"></h4>
<div className="space-y-2">
{plantingModes.map(mode => {
const fields = results.filter(field => field.plantingMode === mode);
const area = fields.reduce((sum, field) => sum + field.area, 0);
const percentage = (area / totalArea * 100).toFixed(1);
return (
<div key={mode} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: getPlantingModeColor(mode) }} />
<span className="text-sm">{getPlantingModeName(mode)}</span>
</div>
<div className="text-right">
<div className="text-sm font-medium">{area.toFixed(1)} </div>
<div className="text-xs text-muted-foreground">{percentage}%</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</Card>
</div>
);
}
// 获取土壤类型名称
function getSoilTypeName(type: string): string {
const soilTypes: Record<string, string> = {
'sandy': '沙质土',
'clay': '黏质土',
'loamy': '壤质土',
'peat': '泥炭土',
'saline': '盐碱土',
'silt': '粉质土',
'rocky': '岩石土'
};
return soilTypes[type] || type;
}
// 获取土壤类型颜色
function getSoilTypeColor(type: string): string {
const colors: Record<string, string> = '#fbbf24'; // 黄色
const colorMap: Record<string, string> = {
'sandy': '#fbbf24', // 黄色
'clay': '#a78bfa', // 紫色
'loamy': '#60a5fa', // 蓝色
'peat': '#8b5cf6', // 深紫色
'saline': '#f87171', // 红色
'silt': '#34d399', // 绿色
'rocky': '#6b7280' // 灰色
};
return colorMap[type] || '#6b7280';
}
// 获取种植模式名称
function getPlantingModeName(mode: string): string {
const modes: Record<string, string> = {
'conventional': '传统种植',
'organic': '有机种植',
'greenhouse': '温室种植',
'hydroponic': '水培种植',
'aeroponic': '气培种植'
};
return modes[mode] || mode;
}
// 获取种植模式颜色
function getPlantingModeColor(mode: string): string {
const colorMap: Record<string, string> = {
'conventional': '#60a5fa', // 蓝色
'organic': '#34d399', // 绿色
'greenhouse': '#fbbf24', // 黄色
'hydroponic': '#a78bfa', // 紫色
'aeroponic': '#f87171' // 红色
};
return colorMap[mode] || '#6b7280';
}

View File

@@ -0,0 +1,260 @@
'use client';
import { useReducer } from 'react';
// 地块数据接口
export interface SpatialField {
id: string;
code: string;
name: string;
area: number;
perimeter: number;
centroid: { lat: number; lng: number };
bounds: {
north: number;
south: number;
east: number;
west: number;
};
geometry: {
type: 'Polygon';
coordinates: number[][][]; // [[[lng, lat], ...], ...]
};
soilType: string;
plantingMode: string;
status: string;
owner: string;
createdAt: string;
}
// 空间查询状态接口
export interface SpatialQueryState {
queryType: 'point-in-polygon' | 'polygon-intersect' | 'polygon-adjacent' | 'buffer';
queryResult: any;
queryGeometry: any;
showGeometryDialog: boolean;
bufferDistance: number;
selectedPoint: { lat: number; lng: number } | null;
queryPolygon: number[][] | null;
selectedFields: string[];
showMapPicker: boolean;
mapPickerMode: 'point' | 'polygon';
isQuerying: boolean;
exportFormat: 'geojson' | 'kml' | 'csv';
showExportDialog: boolean;
activeTab: 'query' | 'analysis' | 'tools';
calculationType: 'area' | 'perimeter' | 'centroid' | 'distance' | 'bounds';
distanceStart: { lat: number; lng: number } | null;
distanceEnd: { lat: number; lng: number } | null;
analysisFields: string[];
}
// Action类型
export type SpatialQueryAction =
| { type: 'SET_QUERY_TYPE'; payload: SpatialQueryState['queryType'] }
| { type: 'SET_QUERY_RESULT'; payload: any }
| { type: 'SET_QUERY_GEOMETRY'; payload: any }
| { type: 'SET_SHOW_GEOMETRY_DIALOG'; payload: boolean }
| { type: 'SET_BUFFER_DISTANCE'; payload: number }
| { type: 'SET_SELECTED_POINT'; payload: { lat: number; lng: number } | null }
| { type: 'SET_QUERY_POLYGON'; payload: number[][] | null }
| { type: 'SET_SELECTED_FIELDS'; payload: string[] }
| { type: 'SET_SHOW_MAP_PICKER'; payload: boolean }
| { type: 'SET_MAP_PICKER_MODE'; payload: 'point' | 'polygon' }
| { type: 'SET_IS_QUERYING'; payload: boolean }
| { type: 'SET_EXPORT_FORMAT'; payload: 'geojson' | 'kml' | 'csv' }
| { type: 'SET_SHOW_EXPORT_DIALOG'; payload: boolean }
| { type: 'SET_ACTIVE_TAB'; payload: 'query' | 'analysis' | 'tools' }
| { type: 'SET_CALCULATION_TYPE'; payload: 'area' | 'perimeter' | 'centroid' | 'distance' | 'bounds' }
| { type: 'SET_DISTANCE_START'; payload: { lat: number; lng: number } | null }
| { type: 'SET_DISTANCE_END'; payload: { lat: number; lng: number } | null }
| { type: 'SET_ANALYSIS_FIELDS'; payload: string[] }
| { type: 'RESET_QUERY' };
// 模拟地块数据
const mockFields: SpatialField[] = [
{
id: '1',
code: 'TD001',
name: '东区沙质土试验田',
area: 85.5,
perimeter: 1250,
centroid: { lat: 39.9042, lng: 116.4074 },
bounds: { north: 39.905, south: 39.903, east: 116.408, west: 116.406 },
geometry: {
type: 'Polygon',
coordinates: [[
[116.406, 39.903],
[116.408, 39.903],
[116.408, 39.905],
[116.406, 39.905],
[116.406, 39.903]
]]
},
soilType: 'sandy',
plantingMode: 'conventional',
status: 'active',
owner: '张三',
createdAt: '2024-01-15'
},
{
id: '2',
code: 'TD002',
name: '西区黏质土示范区',
area: 120.8,
perimeter: 1680,
centroid: { lat: 39.9048, lng: 116.4082 },
bounds: { north: 39.906, south: 39.904, east: 116.409, west: 116.407 },
geometry: {
type: 'Polygon',
coordinates: [[
[116.407, 39.904],
[116.409, 39.904],
[116.409, 39.906],
[116.407, 39.906],
[116.407, 39.904]
]]
},
soilType: 'clay',
plantingMode: 'organic',
status: 'active',
owner: '李四',
createdAt: '2024-01-20'
},
{
id: '3',
code: 'TD003',
name: '南区壤质土生产基地',
area: 95.2,
perimeter: 1420,
centroid: { lat: 39.9038, lng: 116.4068 },
bounds: { north: 39.904, south: 39.903, east: 116.407, west: 116.406 },
geometry: {
type: 'Polygon',
coordinates: [[
[116.406, 39.903],
[116.407, 39.903],
[116.407, 39.904],
[116.406, 39.904],
[116.406, 39.903]
]]
},
soilType: 'loamy',
plantingMode: 'greenhouse',
status: 'active',
owner: '王五',
createdAt: '2024-01-25'
}
];
// 初始状态
const initialState: SpatialQueryState = {
queryType: 'point-in-polygon',
queryResult: null,
queryGeometry: null,
showGeometryDialog: false,
bufferDistance: 100,
selectedPoint: null,
queryPolygon: null,
selectedFields: [],
showMapPicker: false,
mapPickerMode: 'point',
isQuerying: false,
exportFormat: 'geojson',
showExportDialog: false,
activeTab: 'query',
calculationType: 'area',
distanceStart: null,
distanceEnd: null,
analysisFields: [],
};
// Reducer函数
export function spatialQueryReducer(state: SpatialQueryState, action: SpatialQueryAction): SpatialQueryState {
switch (action.type) {
case 'SET_QUERY_TYPE':
return {
...state,
queryType: action.payload,
queryResult: null,
selectedPoint: null,
queryPolygon: null,
selectedFields: []
};
case 'SET_QUERY_RESULT':
return { ...state, queryResult: action.payload };
case 'SET_QUERY_GEOMETRY':
return { ...state, queryGeometry: action.payload };
case 'SET_SHOW_GEOMETRY_DIALOG':
return { ...state, showGeometryDialog: action.payload };
case 'SET_BUFFER_DISTANCE':
return { ...state, bufferDistance: action.payload };
case 'SET_SELECTED_POINT':
return { ...state, selectedPoint: action.payload };
case 'SET_QUERY_POLYGON':
return { ...state, queryPolygon: action.payload };
case 'SET_SELECTED_FIELDS':
return { ...state, selectedFields: action.payload };
case 'SET_SHOW_MAP_PICKER':
return { ...state, showMapPicker: action.payload };
case 'SET_MAP_PICKER_MODE':
return { ...state, mapPickerMode: action.payload };
case 'SET_IS_QUERYING':
return { ...state, isQuerying: action.payload };
case 'SET_EXPORT_FORMAT':
return { ...state, exportFormat: action.payload };
case 'SET_SHOW_EXPORT_DIALOG':
return { ...state, showExportDialog: action.payload };
case 'SET_ACTIVE_TAB':
return {
...state,
activeTab: action.payload,
queryResult: null
};
case 'SET_CALCULATION_TYPE':
return { ...state, calculationType: action.payload };
case 'SET_DISTANCE_START':
return { ...state, distanceStart: action.payload };
case 'SET_DISTANCE_END':
return { ...state, distanceEnd: action.payload };
case 'SET_ANALYSIS_FIELDS':
return { ...state, analysisFields: action.payload };
case 'RESET_QUERY':
return {
...state,
queryResult: null,
selectedPoint: null,
queryPolygon: null,
selectedFields: [],
queryGeometry: null,
distanceStart: null,
distanceEnd: null,
analysisFields: []
};
default:
return state;
}
}
// 导出初始状态和类型
export { initialState, mockFields };
export type { SpatialQueryAction, SpatialField, SpatialQueryState };

View File

@@ -0,0 +1,466 @@
/**
* 空间查询工具函数
* 实现PostGIS风格的空间查询和几何计算功能
*/
import { SpatialField } from './spatialQueryReducer';
// 地球半径(米)
const EARTH_RADIUS = 6371000;
// 将角度转换为弧度
const toRadians = (degrees: number): number => degrees * Math.PI / 180;
// 将弧度转换为角度
const toDegrees = (radians: number): number => radians * 180 / Math.PI;
/**
* 计算两点之间的距离Haversine公式
*/
export function calculateDistance(
lat1: number, lng1: number,
lat2: number, lng2: number
): number {
const dLat = toRadians(lat2 - lat1);
const dLng = toRadians(lng2 - lng1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c; // 返回米
}
/**
* 判断点是否在多边形内(射线法)
*/
export function isPointInPolygon(
point: { lat: number; lng: number },
polygon: number[][]
): boolean {
let inside = false;
const x = point.lng;
const y = point.lat;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
const intersect = ((yi > y) !== (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
/**
* 判断两个多边形是否相交
*/
export function doPolygonsIntersect(
polygon1: number[][],
polygon2: number[][]
): boolean {
// 简化判断:检查是否有顶点在另一个多边形内
for (const point of polygon1) {
if (isPointInPolygon({ lat: point[1], lng: point[0] }, polygon2)) {
return true;
}
}
for (const point of polygon2) {
if (isPointInPolygon({ lat: point[1], lng: point[0] }, polygon1)) {
return true;
}
}
return false;
}
/**
* 判断两个多边形是否相邻(共享边界)
*/
export function arePolygonsAdjacent(
polygon1: number[][],
polygon2: number[][]
): boolean {
// 检查是否有共享的边界点(简化实现)
for (let i = 0; i < polygon1.length - 1; i++) {
for (let j = 0; j < polygon2.length - 1; j++) {
// 检查是否有相同的边
const edge1 = [polygon1[i], polygon1[i + 1]];
const edge2 = [polygon2[j], polygon2[j + 1]];
if ((edgesEqual(edge1, edge2) || edgesEqual(edge1, [edge2[1], edge2[0]]))) {
return true;
}
}
}
return false;
}
function edgesEqual(edge1: number[][], edge2: number[][]): boolean {
return (
(pointsEqual(edge1[0], edge2[0]) && pointsEqual(edge1[1], edge2[1])) ||
(pointsEqual(edge1[0], edge2[1]) && pointsEqual(edge1[1], edge2[0]))
);
}
function pointsEqual(p1: number[], p2: number[]): boolean {
return Math.abs(p1[0] - p2[0]) < 1e-9 && Math.abs(p1[1] - p2[1]) < 1e-9;
}
/**
* 创建缓冲区(简化的圆形缓冲区)
*/
export function createBuffer(
center: { lat: number; lng: number },
radius: number // 米
): number[][] {
const points: number[][] = [];
const numPoints = 36; // 36个点形成近似圆形
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * 2 * Math.PI;
const bearing = toDegrees(angle);
const point = calculateDestinationPoint(center, bearing, radius);
points.push([point.lng, point.lat]);
}
return points;
}
/**
* 计算目标点(从起点按方位角和距离计算)
*/
function calculateDestinationPoint(
start: { lat: number; lng: number },
bearing: number,
distance: number
): { lat: number; lng: number } {
const lat1 = toRadians(start.lat);
const lng1 = toRadians(start.lng);
const brng = toRadians(bearing);
const angularDistance = distance / EARTH_RADIUS;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(angularDistance) +
Math.cos(lat1) * Math.sin(angularDistance) * Math.cos(brng)
);
const lng2 = lng1 + Math.atan2(
Math.sin(brng) * Math.sin(angularDistance) * Math.cos(lat1),
Math.cos(angularDistance) - Math.sin(lat1) * Math.sin(lat2)
);
return {
lat: toDegrees(lat2),
lng: toDegrees(lng2)
};
}
/**
* 计算多边形面积L'Huilier定理球面几何
*/
export function calculatePolygonArea(polygon: number[][]): number {
if (polygon.length < 3) return 0;
let totalArea = 0;
// 将多边形分割为三角形计算面积
for (let i = 1; i < polygon.length - 1; i++) {
const triangle = [polygon[0], polygon[i], polygon[i + 1]];
const area = calculateTriangleArea(triangle);
totalArea += area;
}
return totalArea;
}
/**
* 计算三角形面积(球面几何)
*/
function calculateTriangleArea(triangle: number[][]): number {
const [a, b, c] = triangle;
// 将经纬度转换为笛卡尔坐标进行计算(简化实现)
const area = Math.abs(
(a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1])) / 2
);
// 转换为平方米(粗略转换)
return area * 111320 * 111320; // 1度 ≈ 111320米
}
/**
* 计算多边形周长
*/
export function calculatePolygonPerimeter(polygon: number[][]): number {
if (polygon.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < polygon.length - 1; i++) {
const p1 = polygon[i];
const p2 = polygon[i + 1];
perimeter += calculateDistance(p1[1], p1[0], p2[1], p2[0]);
}
// 闭合多边形,计算最后一条边
if (polygon.length > 2) {
const first = polygon[0];
const last = polygon[polygon.length - 1];
perimeter += calculateDistance(last[1], last[0], first[1], first[0]);
}
return perimeter;
}
/**
* 计算多边形中心点
*/
export function calculatePolygonCentroid(polygon: number[][]): { lat: number; lng: number } {
let sumLat = 0;
let sumLng = 0;
for (const point of polygon) {
sumLat += point[1];
sumLng += point[0];
}
return {
lat: sumLat / polygon.length,
lng: sumLng / polygon.length
};
}
/**
* 计算多边形边界框
*/
export function calculatePolygonBounds(polygon: number[][]): {
north: number;
south: number;
east: number;
west: number;
} {
let minLat = Infinity, maxLat = -Infinity;
let minLng = Infinity, maxLng = -Infinity;
for (const point of polygon) {
minLat = Math.min(minLat, point[1]);
maxLat = Math.max(maxLat, point[1]);
minLng = Math.min(minLng, point[0]);
maxLng = Math.max(maxLng, point[0]);
}
return {
north: maxLat,
south: minLat,
east: maxLng,
west: minLng
};
}
/**
* 空间查询:点在多边形内
*/
export function queryPointInPolygon(
point: { lat: number; lng: number },
fields: SpatialField[]
): SpatialField[] {
return fields.filter(field => {
const polygon = field.geometry.coordinates[0];
return isPointInPolygon(point, polygon);
});
}
/**
* 空间查询:多边形相交
*/
export function queryPolygonIntersect(
queryPolygon: number[][],
fields: SpatialField[]
): SpatialField[] {
return fields.filter(field => {
const polygon = field.geometry.coordinates[0];
return doPolygonsIntersect(queryPolygon, polygon);
});
}
/**
* 空间查询:多边形相邻
*/
export function queryPolygonAdjacent(
queryPolygon: number[][],
fields: SpatialField[]
): SpatialField[] {
return fields.filter(field => {
const polygon = field.geometry.coordinates[0];
return arePolygonsAdjacent(queryPolygon, polygon);
});
}
/**
* 空间查询:缓冲区查询
*/
export function queryBuffer(
center: { lat: number; lng: number },
radius: number,
fields: SpatialField[]
): SpatialField[] {
const bufferPolygon = createBuffer(center, radius);
return fields.filter(field => {
// 检查地块中心点是否在缓冲区内
return isPointInPolygon(field.centroid, bufferPolygon);
});
}
/**
* 生成GeoJSON格式数据
*/
export function generateGeoJSON(fields: SpatialField[]): string {
const features = fields.map(field => ({
type: 'Feature',
properties: {
id: field.id,
code: field.code,
name: field.name,
area: field.area,
perimeter: field.perimeter,
soilType: field.soilType,
plantingMode: field.plantingMode,
status: field.status,
owner: field.owner,
createdAt: field.createdAt
},
geometry: field.geometry
}));
return JSON.stringify({
type: 'FeatureCollection',
features
}, null, 2);
}
/**
* 生成KML格式数据
*/
export function generateKML(fields: SpatialField[]): string {
let kml = `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Spatial Query Results</name>
`;
for (const field of fields) {
const coordinates = field.geometry.coordinates[0]
.map(coord => `${coord[0]},${coord[1]},0`)
.join(' ');
kml += ` <Placemark>
<name>${field.name}</name>
<description>
<![CDATA[
<strong>编号:</strong> ${field.code}<br/>
<strong>面积:</strong> ${field.area.toFixed(2)} 亩<br/>
<strong>土壤类型:</strong> ${field.soilType}<br/>
<strong>种植模式:</strong> ${field.plantingMode}<br/>
<strong>负责人:</strong> ${field.owner}
]]>
</description>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>${coordinates}</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</Placemark>
`;
}
kml += ` </Document>
</kml>`;
return kml;
}
/**
* 生成CSV格式数据
*/
export function generateCSV(fields: SpatialField[]): string {
const headers = [
'ID', '编号', '名称', '面积(亩)', '周长(米)', '中心纬度', '中心经度',
'土壤类型', '种植模式', '状态', '负责人', '创建时间'
];
const rows = fields.map(field => [
field.id,
field.code,
field.name,
field.area.toFixed(2),
field.perimeter.toFixed(0),
field.centroid.lat.toFixed(6),
field.centroid.lng.toFixed(6),
field.soilType,
field.plantingMode,
field.status,
field.owner,
field.createdAt
]);
return [headers, ...rows].map(row => row.join(',')).join('\n');
}
/**
* 生成SQL查询示例
*/
export function generateSQLExample(
queryType: string,
geometry: any,
bufferDistance?: number
): string {
switch (queryType) {
case 'point-in-polygon':
return `-- 点在多边形内查询
SELECT * FROM fields
WHERE ST_Contains(
ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326),
ST_GeomFromText('POINT(${geometry.lng} ${geometry.lat})', 4326)
);`;
case 'polygon-intersect':
return `-- 多边形相交查询
SELECT * FROM fields
WHERE ST_Intersects(
geometry,
ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326)
);`;
case 'polygon-adjacent':
return `-- 多边形相邻查询
SELECT * FROM fields
WHERE ST_Touches(
geometry,
ST_GeomFromText('POLYGON((${geometry.map((p: any) => `${p[0]} ${p[1]}`).join(', ')}))', 4326)
);`;
case 'buffer':
return `-- 缓冲区查询
SELECT * FROM fields
WHERE ST_DWithin(
geometry,
ST_GeomFromText('POINT(${geometry.lng} ${geometry.lat})', 4326),
${bufferDistance}
);`;
default:
return '-- 请选择查询类型';
}
}

View File

@@ -0,0 +1,238 @@
/**
* 地图选点组件
* 用于在地图上选择坐标点
*/
import { useEffect, useRef, useState } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Point } from '../../lib/spatialDataService';
import { MapPin, X, Check } from 'lucide-react';
interface MapPointPickerProps {
points: Point[];
mode: 'polygon' | 'single';
onPointsChange: (points: Point[]) => void;
onClose?: () => void;
height?: string;
title?: string;
}
export function MapPointPicker({
points,
mode,
onPointsChange,
onClose,
height = '500px',
title = '在地图上选择坐标点'
}: MapPointPickerProps) {
const mapRef = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<any>(null);
const [markers, setMarkers] = useState<any[]>([]);
const [polygon, setPolygon] = useState<any>(null);
// 初始化地图
useEffect(() => {
if (!mapRef.current || map) return;
// 检查高德地图是否已加载
if (typeof window.AMap === 'undefined') {
console.log('💡 使用演示地图模式');
return;
}
// 计算中心点
const centerLat = points && points.length > 0
? points.reduce((sum, p) => sum + p.lat, 0) / points.length
: 39.9042;
const centerLng = points && points.length > 0
? points.reduce((sum, p) => sum + p.lng, 0) / points.length
: 116.4074;
// 创建地图实例
const mapInstance = new window.AMap.Map(mapRef.current, {
zoom: 14,
center: [centerLng, centerLat],
mapStyle: 'amap://styles/normal',
viewMode: '2D'
});
setMap(mapInstance);
return () => {
if (mapInstance) {
mapInstance.destroy();
}
};
}, []);
// 绘制标记和多边形
useEffect(() => {
if (!map || !points) return;
// 清除旧标记
markers.forEach(marker => marker.setMap(null));
if (polygon) {
polygon.setMap(null);
}
// 添加新标记
const newMarkers = points.map((point, index) => {
const marker = new window.AMap.Marker({
position: [point.lng, point.lat],
map: map,
title: `${index + 1}`,
label: {
content: `${index + 1}`,
direction: 'top'
}
});
// 点击标记删除(仅多边形模式且点数>3
if (mode === 'polygon' && points.length > 3) {
marker.on('click', () => {
const newPoints = points.filter((_, i) => i !== index);
onPointsChange(newPoints);
});
}
return marker;
});
setMarkers(newMarkers);
// 绘制多边形(仅多边形模式)
if (mode === 'polygon' && points.length >= 3) {
const path = points.map(p => [p.lng, p.lat]);
const poly = new window.AMap.Polygon({
path: path,
strokeColor: '#22c55e',
strokeWeight: 2,
strokeOpacity: 0.8,
fillColor: '#22c55e',
fillOpacity: 0.2,
map: map
});
setPolygon(poly);
}
// 自适应显示
if (points && points.length > 0) {
map.setFitView();
}
}, [map, points]);
// 地图点击事件
useEffect(() => {
if (!map) return;
const clickHandler = (e: any) => {
const lng = e.lnglat.getLng();
const lat = e.lnglat.getLat();
if (mode === 'single') {
// 单点模式:替换唯一的点
onPointsChange([{ lat, lng }]);
} else {
// 多边形模式:添加新点
onPointsChange([...(points || []), { lat, lng }]);
}
};
map.on('click', clickHandler);
return () => {
map.off('click', clickHandler);
};
}, [map, points, mode]);
return (
<Card className="overflow-hidden">
{/* 工具栏 */}
<div className="p-3 bg-gray-50 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium">{title}</span>
<Badge variant="outline">
{mode === 'polygon' ? `${points.length} 个点` : '单点选择'}
</Badge>
</div>
<div className="flex items-center gap-2">
{mode === 'polygon' && points.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => onPointsChange([])}
>
</Button>
)}
{onClose && (
<Button size="sm" onClick={onClose}>
<Check className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</div>
{/* 提示信息 */}
<div className="px-3 py-2 bg-blue-50 border-b">
<p className="text-xs text-blue-700">
{mode === 'polygon'
? '💡 点击地图添加坐标点点击标记删除该点至少保留3个点'
: '💡 点击地图选择坐标点位置'}
</p>
</div>
{/* 地图容器 */}
<div
ref={mapRef}
style={{ height }}
className="w-full relative"
>
{/* 地图加载提示 */}
{!map && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mb-3"></div>
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
)}
</div>
{/* 坐标列表 */}
{points && points.length > 0 && (
<div className="p-3 bg-gray-50 border-t max-h-32 overflow-y-auto">
<div className="text-xs font-medium text-muted-foreground mb-2">
</div>
<div className="space-y-1">
{points.map((point, index) => (
<div key={index} className="text-xs font-mono flex items-center justify-between">
<span>
{index + 1}: {point.lat.toFixed(6)}, {point.lng.toFixed(6)}
</span>
{mode === 'polygon' && points.length > 3 && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => {
const newPoints = points.filter((_, i) => i !== index);
onPointsChange(newPoints);
}}
>
<X className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
))}
</div>
</div>
)}
</Card>
);
}

View File

@@ -25,9 +25,9 @@ export function ThemeToggle() {
variant="ghost" variant="ghost"
size="icon" size="icon"
disabled disabled
className="transition-colors" className="transition-colors h-10 w-10"
> >
<Sun className="w-5 h-5" /> <Sun className="size-5" />
</Button> </Button>
); );
} }
@@ -38,12 +38,12 @@ export function ThemeToggle() {
size="icon" size="icon"
onClick={toggleTheme} onClick={toggleTheme}
title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'} title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
className="transition-colors" className="transition-colors h-10 w-10"
> >
{theme === 'light' ? ( {theme === 'light' ? (
<Moon className="w-5 h-5" /> <Moon className="size-5" />
) : ( ) : (
<Sun className="w-5 h-5" /> <Sun className="size-5" />
)} )}
</Button> </Button>
); );

View File

@@ -134,7 +134,8 @@ export function MessageBell({ onMessageClick }: MessageBellProps) {
<Popover open={showMessages} onOpenChange={setShowMessages}> <Popover open={showMessages} onOpenChange={setShowMessages}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative"> <Button variant="ghost" size="icon" className="relative">
<Bell className="w-5 h-5" /> <Bell
className="w-5 h-5" />
{unreadCount > 0 && ( {unreadCount > 0 && (
<Badge <Badge
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white text-xs" className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white text-xs"

146
crop-x/src/lib/mapLoader.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* 高德地图SDK动态加载器
* 用于在不修改index.html的情况下加载高德地图SDK
*/
// 高德地图配置
const AMAP_CONFIG = {
// 替换为你的高德地图API Key
// 申请地址: https://console.amap.com/
key: 'YOUR_AMAP_KEY',
// 替换为你的安全密钥(可选,用于提高安全性)
securityJsCode: '',
// SDK版本
version: '2.0',
// 可选插件
plugins: ['AMap.Scale', 'AMap.ToolBar', 'AMap.Geocoder'] as string[],
};
/**
* 加载高德地图SDK
* @returns Promise<any> 返回AMap对象或null占位模式
*/
export const loadAMapScript = (): Promise<any> => {
return new Promise((resolve, reject) => {
// 如果已经加载,直接返回
if (window.AMap) {
console.log('✅ 高德地图SDK已加载');
resolve(window.AMap);
return;
}
// 检查Key是否配置
if (AMAP_CONFIG.key === 'YOUR_AMAP_KEY' || !AMAP_CONFIG.key) {
// 使用占位地图(功能完整)
console.log('💡 使用占位地图模式(功能完整)');
console.log('💡 如需真实地图,请在 /lib/mapLoader.ts 中配置高德地图Key');
console.log('💡 申请地址: https://console.amap.com/');
resolve(null); // 返回null表示使用占位地图
return;
}
try {
// 设置安全密钥(如果提供)
if (AMAP_CONFIG.securityJsCode) {
window._AMapSecurityConfig = {
securityJsCode: AMAP_CONFIG.securityJsCode,
};
}
// 创建script标签
const script = document.createElement('script');
script.type = 'text/javascript';
// 构建SDK URL
let url = `https://webapi.amap.com/maps?v=${AMAP_CONFIG.version}&key=${AMAP_CONFIG.key}`;
// 添加插件
if (AMAP_CONFIG.plugins.length > 0) {
url += `&plugin=${AMAP_CONFIG.plugins.join(',')}`;
}
script.src = url;
// 加载成功
script.onload = () => {
console.log('✅ 高德地图SDK加载成功');
console.log('📍 版本:', window.AMap?.version);
resolve(window.AMap);
};
// 加载失败
script.onerror = () => {
console.error('❌ 高德地图SDK加载失败');
reject(new Error('高德地图SDK加载失败'));
};
// 添加到页面
document.head.appendChild(script);
console.log('🔄 正在加载高德地图SDK...');
} catch (error) {
console.error('❌ 加载高德地图SDK时发生错误:', error);
reject(error);
}
});
};
/**
* 检查高德地图SDK是否已加载
* @returns boolean
*/
export const isAMapLoaded = (): boolean => {
return typeof window !== 'undefined' && !!window.AMap;
};
/**
* 获取高德地图版本
* @returns string | null
*/
export const getAMapVersion = (): string | null => {
if (isAMapLoaded()) {
return window.AMap.version || null;
}
return null;
};
// TypeScript 类型声明
declare global {
interface Window {
AMap: any;
_AMapSecurityConfig: {
securityJsCode: string;
};
}
}
/**
* 使用示例:
*
* import { loadAMapScript, isAMapLoaded } from './lib/mapLoader';
*
* // 在组件中使用
* useEffect(() => {
* if (!isAMapLoaded()) {
* loadAMapScript()
* .then((AMap) => {
* if (AMap) {
* console.log('地图SDK加载成功可以初始化地图');
* initMap();
* } else {
* console.log('使用占位地图模式');
* }
* })
* .catch((error) => {
* console.error('地图SDK加载失败使用占位地图', error);
* });
* } else {
* initMap();
* }
* }, []);
*/
export {};

View File

@@ -0,0 +1,937 @@
/**
* 空间数据服务API
* 提供PostGIS风格的空间查询、几何计算和数据导出功能
*/
// ===== 类型定义 =====
export interface Point {
lat: number;
lng: number;
alt?: number; // 海拔高度
}
export interface Polygon {
points: Point[];
holes?: Point[][]; // 多边形的孔洞
}
export interface Field {
id: string;
name: string;
code: string;
geometry: Polygon;
properties?: Record<string, any>;
}
export interface SpatialQueryResult<T = any> {
success: boolean;
data: T;
timestamp: string;
executionTime: number; // 毫秒
}
// ===== 常量定义 =====
// WGS-84椭球参数
const WGS84_A = 6378137.0; // 长半轴(米)
const WGS84_B = 6356752.314245; // 短半轴(米)
const WGS84_F = 1 / 298.257223563; // 扁率
// 1亩 = 666.67平方米
const MU_TO_SQUARE_METERS = 666.67;
// ===== 1. 空间查询API =====
/**
* 点面查询:判断点是否在多边形内
* 使用射线法Ray Casting Algorithm
*/
export class SpatialQuery {
/**
* 点在多边形内查询
* @param point 查询点
* @param fields 地块列表
* @returns 包含该点的地块列表
*/
static pointInPolygon(point: Point, fields: Field[]): SpatialQueryResult<{
matched: boolean;
fields: Array<{
field: Field;
distanceToBorder: number; // 到边界的最短距离(米)
}>;
}> {
const startTime = performance.now();
const results: Array<{ field: Field; distanceToBorder: number }> = [];
for (const field of fields) {
if (this._isPointInPolygon(point, field.geometry.points)) {
const distance = this._pointToPolygonDistance(point, field.geometry.points);
results.push({ field, distanceToBorder: distance });
}
}
const executionTime = performance.now() - startTime;
return {
success: true,
data: {
matched: results.length > 0,
fields: results,
},
timestamp: new Date().toISOString(),
executionTime,
};
}
/**
* 多边形相交查询
* @param sourceField 源地块
* @param targetFields 目标地块列表
* @returns 与源地块相交的地块列表
*/
static polygonIntersect(
sourceField: Field,
targetFields: Field[]
): SpatialQueryResult<{
intersections: Array<{
field: Field;
intersectArea: number; // 相交面积(亩)
intersectRatio: number; // 相交比例(%
intersectGeometry: Polygon; // 相交区域几何
}>;
}> {
const startTime = performance.now();
const intersections: Array<{
field: Field;
intersectArea: number;
intersectRatio: number;
intersectGeometry: Polygon;
}> = [];
const sourceArea = GeometryCalculator.calculateArea(sourceField.geometry);
for (const targetField of targetFields) {
if (targetField.id === sourceField.id) continue;
if (this._polygonsIntersect(sourceField.geometry.points, targetField.geometry.points)) {
const intersectGeometry = this._calculateIntersection(
sourceField.geometry,
targetField.geometry
);
const intersectArea = GeometryCalculator.calculateArea(intersectGeometry);
const intersectRatio = (intersectArea / sourceArea) * 100;
intersections.push({
field: targetField,
intersectArea,
intersectRatio,
intersectGeometry,
});
}
}
const executionTime = performance.now() - startTime;
return {
success: true,
data: { intersections },
timestamp: new Date().toISOString(),
executionTime,
};
}
/**
* 相邻地块查询
* @param sourceField 源地块
* @param targetFields 目标地块列表
* @returns 与源地块相邻的地块列表
*/
static adjacentPolygons(
sourceField: Field,
targetFields: Field[]
): SpatialQueryResult<{
adjacentFields: Array<{
field: Field;
sharedBorderLength: number; // 共享边界长度(米)
sharedBorderPoints: Point[]; // 共享边界点
}>;
}> {
const startTime = performance.now();
const adjacentFields: Array<{
field: Field;
sharedBorderLength: number;
sharedBorderPoints: Point[];
}> = [];
for (const targetField of targetFields) {
if (targetField.id === sourceField.id) continue;
const { isAdjacent, sharedBorder } = this._checkAdjacency(
sourceField.geometry.points,
targetField.geometry.points
);
if (isAdjacent && sharedBorder.length > 0) {
const sharedBorderLength = GeometryCalculator.calculatePerimeter({
points: sharedBorder,
});
adjacentFields.push({
field: targetField,
sharedBorderLength,
sharedBorderPoints: sharedBorder,
});
}
}
const executionTime = performance.now() - startTime;
return {
success: true,
data: { adjacentFields },
timestamp: new Date().toISOString(),
executionTime,
};
}
/**
* 缓冲区分析
* @param sourceField 源地块
* @param bufferDistance 缓冲区距离(米)
* @param targetFields 目标地块列表
* @returns 缓冲区内的地块列表
*/
static bufferAnalysis(
sourceField: Field,
bufferDistance: number,
targetFields: Field[]
): SpatialQueryResult<{
bufferGeometry: Polygon;
bufferArea: number; // 缓冲区面积(亩)
fieldsInBuffer: Array<{
field: Field;
distance: number; // 最短距离(米)
overlap: boolean; // 是否重叠
}>;
}> {
const startTime = performance.now();
// 生成缓冲区几何
const bufferGeometry = this._createBuffer(sourceField.geometry, bufferDistance);
const bufferArea = GeometryCalculator.calculateArea(bufferGeometry);
const fieldsInBuffer: Array<{
field: Field;
distance: number;
overlap: boolean;
}> = [];
for (const targetField of targetFields) {
if (targetField.id === sourceField.id) continue;
const distance = this._polygonToPolygonDistance(
sourceField.geometry.points,
targetField.geometry.points
);
if (distance <= bufferDistance) {
const overlap = this._polygonsIntersect(
bufferGeometry.points,
targetField.geometry.points
);
fieldsInBuffer.push({
field: targetField,
distance,
overlap,
});
}
}
const executionTime = performance.now() - startTime;
return {
success: true,
data: {
bufferGeometry,
bufferArea,
fieldsInBuffer,
},
timestamp: new Date().toISOString(),
executionTime,
};
}
// ===== 私有辅助方法 =====
/**
* 射线法判断点是否在多边形内
*/
private static _isPointInPolygon(point: Point, polygon: Point[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].lng,
yi = polygon[i].lat;
const xj = polygon[j].lng,
yj = polygon[j].lat;
const intersect =
yi > point.lat !== yj > point.lat &&
point.lng < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
}
/**
* 计算点到多边形边界的最短距离
*/
private static _pointToPolygonDistance(point: Point, polygon: Point[]): number {
let minDistance = Infinity;
for (let i = 0; i < polygon.length; i++) {
const p1 = polygon[i];
const p2 = polygon[(i + 1) % polygon.length];
const distance = this._pointToSegmentDistance(point, p1, p2);
minDistance = Math.min(minDistance, distance);
}
return minDistance;
}
/**
* 计算点到线段的距离
*/
private static _pointToSegmentDistance(point: Point, p1: Point, p2: Point): number {
const dx = p2.lng - p1.lng;
const dy = p2.lat - p1.lat;
if (dx === 0 && dy === 0) {
return GeometryCalculator.haversineDistance(point, p1);
}
const t = Math.max(
0,
Math.min(
1,
((point.lng - p1.lng) * dx + (point.lat - p1.lat) * dy) / (dx * dx + dy * dy)
)
);
const nearestPoint: Point = {
lat: p1.lat + t * dy,
lng: p1.lng + t * dx,
};
return GeometryCalculator.haversineDistance(point, nearestPoint);
}
/**
* 判断两个多边形是否相交
*/
private static _polygonsIntersect(poly1: Point[], poly2: Point[]): boolean {
// 检查是否有顶点在另一个多边形内
for (const point of poly1) {
if (this._isPointInPolygon(point, poly2)) return true;
}
for (const point of poly2) {
if (this._isPointInPolygon(point, poly1)) return true;
}
// 检查边是否相交
for (let i = 0; i < poly1.length; i++) {
const p1 = poly1[i];
const p2 = poly1[(i + 1) % poly1.length];
for (let j = 0; j < poly2.length; j++) {
const p3 = poly2[j];
const p4 = poly2[(j + 1) % poly2.length];
if (this._segmentsIntersect(p1, p2, p3, p4)) return true;
}
}
return false;
}
/**
* 判断两条线段是否相交
*/
private static _segmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
const ccw = (A: Point, B: Point, C: Point) => {
return (C.lat - A.lat) * (B.lng - A.lng) > (B.lat - A.lat) * (C.lng - A.lng);
};
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
}
/**
* 计算两个多边形的相交区域(简化实现)
*/
private static _calculateIntersection(poly1: Polygon, poly2: Polygon): Polygon {
// 这里使用简化算法实际应用中应使用Sutherland-Hodgman算法
const intersectPoints: Point[] = [];
// 收集在两个多边形内的点
for (const point of poly1.points) {
if (this._isPointInPolygon(point, poly2.points)) {
intersectPoints.push(point);
}
}
for (const point of poly2.points) {
if (this._isPointInPolygon(point, poly1.points)) {
intersectPoints.push(point);
}
}
// 如果没有交点,返回空多边形
if (intersectPoints.length === 0) {
return { points: [] };
}
// 计算凸包作为相交区域的近似
return { points: this._convexHull(intersectPoints) };
}
/**
* 计算凸包Graham扫描算法
*/
private static _convexHull(points: Point[]): Point[] {
if (points.length < 3) return points;
// 找到最下最左的点
let start = points[0];
points.forEach((p) => {
if (p.lat < start.lat || (p.lat === start.lat && p.lng < start.lng)) {
start = p;
}
});
// 按极角排序
const sorted = points
.filter((p) => p !== start)
.sort((a, b) => {
const angleA = Math.atan2(a.lat - start.lat, a.lng - start.lng);
const angleB = Math.atan2(b.lat - start.lat, b.lng - start.lng);
return angleA - angleB;
});
const hull: Point[] = [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.lng - p1.lng) * (point.lat - p1.lat) -
(p2.lat - p1.lat) * (point.lng - p1.lng);
if (cross <= 0) {
hull.pop();
} else {
break;
}
}
hull.push(point);
}
return hull;
}
/**
* 检查两个多边形是否相邻
*/
private static _checkAdjacency(
poly1: Point[],
poly2: Point[]
): { isAdjacent: boolean; sharedBorder: Point[] } {
const sharedBorder: Point[] = [];
const tolerance = 0.00001; // 约1米的容差
for (let i = 0; i < poly1.length; i++) {
const p1 = poly1[i];
const p2 = poly1[(i + 1) % poly1.length];
for (let j = 0; j < poly2.length; j++) {
const p3 = poly2[j];
const p4 = poly2[(j + 1) % poly2.length];
// 检查边是否重合
if (this._edgesOverlap(p1, p2, p3, p4, tolerance)) {
if (sharedBorder.length === 0 || !this._pointsEqual(sharedBorder[sharedBorder.length - 1], p1, tolerance)) {
sharedBorder.push(p1);
}
sharedBorder.push(p2);
}
}
}
return {
isAdjacent: sharedBorder.length >= 2,
sharedBorder,
};
}
/**
* 判断两条边是否重合
*/
private static _edgesOverlap(
p1: Point,
p2: Point,
p3: Point,
p4: Point,
tolerance: number
): boolean {
return (
(this._pointsEqual(p1, p3, tolerance) && this._pointsEqual(p2, p4, tolerance)) ||
(this._pointsEqual(p1, p4, tolerance) && this._pointsEqual(p2, p3, tolerance))
);
}
/**
* 判断两个点是否相等(在容差范围内)
*/
private static _pointsEqual(p1: Point, p2: Point, tolerance: number): boolean {
return (
Math.abs(p1.lat - p2.lat) < tolerance && Math.abs(p1.lng - p2.lng) < tolerance
);
}
/**
* 计算多边形到多边形的最短距离
*/
private static _polygonToPolygonDistance(poly1: Point[], poly2: Point[]): number {
let minDistance = Infinity;
for (const point of poly1) {
const distance = this._pointToPolygonDistance(point, poly2);
minDistance = Math.min(minDistance, distance);
}
for (const point of poly2) {
const distance = this._pointToPolygonDistance(point, poly1);
minDistance = Math.min(minDistance, distance);
}
return minDistance;
}
/**
* 创建缓冲区(简化实现)
*/
private static _createBuffer(geometry: Polygon, distance: number): Polygon {
const bufferPoints: Point[] = [];
const points = geometry.points;
// 简化算法对每个顶点在法线方向上偏移distance距离
for (let i = 0; i < points.length; i++) {
const prev = points[i === 0 ? points.length - 1 : i - 1];
const curr = points[i];
const next = points[(i + 1) % points.length];
// 计算法向量
const v1 = { lat: curr.lat - prev.lat, lng: curr.lng - prev.lng };
const v2 = { lat: next.lat - curr.lat, lng: next.lng - curr.lng };
// 计算平均法向量
const normal = {
lat: -(v1.lng + v2.lng),
lng: v1.lat + v2.lat,
};
// 归一化
const length = Math.sqrt(normal.lat * normal.lat + normal.lng * normal.lng);
if (length > 0) {
normal.lat /= length;
normal.lng /= length;
}
// 偏移顶点(简化:使用度数偏移,实际应转换为米)
const offsetDegrees = distance / 111320; // 约111.32km每度
bufferPoints.push({
lat: curr.lat + normal.lat * offsetDegrees,
lng: curr.lng + normal.lng * offsetDegrees,
});
}
return { points: bufferPoints };
}
}
// ===== 2. 几何计算API =====
export class GeometryCalculator {
/**
* 计算多边形精确面积(考虑地球曲率)
* 使用球面三角形面积公式
*/
static calculateArea(geometry: Polygon): number {
const points = geometry.points;
if (points.length < 3) return 0;
// 将多边形分解为三角形,计算球面三角形面积之和
let totalArea = 0;
const origin = points[0];
for (let i = 1; i < points.length - 1; i++) {
const area = this._sphericalTriangleArea(origin, points[i], points[i + 1]);
totalArea += area;
}
// 转换为亩
return totalArea / MU_TO_SQUARE_METERS;
}
/**
* 计算球面三角形面积L'Huilier定理
*/
private static _sphericalTriangleArea(p1: Point, p2: Point, p3: Point): number {
const R = WGS84_A; // 使用WGS-84长半轴
// 转换为弧度
const lat1 = this._toRadians(p1.lat);
const lng1 = this._toRadians(p1.lng);
const lat2 = this._toRadians(p2.lat);
const lng2 = this._toRadians(p2.lng);
const lat3 = this._toRadians(p3.lat);
const lng3 = this._toRadians(p3.lng);
// 计算边长(球面距离)
const a = this._sphericalDistance(lat2, lng2, lat3, lng3, R);
const b = this._sphericalDistance(lat3, lng3, lat1, lng1, R);
const c = this._sphericalDistance(lat1, lng1, lat2, lng2, R);
// 半周长
const s = (a + b + c) / 2;
// L'Huilier定理
const E = 4 * Math.atan(Math.sqrt(Math.tan(s / (2 * R)) * Math.tan((s - a) / (2 * R)) * Math.tan((s - b) / (2 * R)) * Math.tan((s - c) / (2 * R))));
// 面积 = R² * E
return R * R * E;
}
/**
* 计算球面距离
*/
private static _sphericalDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
radius: number
): number {
const dLat = lat2 - lat1;
const dLng = lng2 - lng1;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return radius * c;
}
/**
* 计算多边形周长(考虑地球曲率)
*/
static calculatePerimeter(geometry: Polygon): number {
const points = geometry.points;
if (points.length < 2) return 0;
let perimeter = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
perimeter += this.haversineDistance(p1, p2);
}
return perimeter;
}
/**
* Haversine公式计算两点间距离
*/
static haversineDistance(p1: Point, p2: Point): number {
const R = WGS84_A;
const lat1 = this._toRadians(p1.lat);
const lng1 = this._toRadians(p1.lng);
const lat2 = this._toRadians(p2.lat);
const lng2 = this._toRadians(p2.lng);
return this._sphericalDistance(lat1, lng1, lat2, lng2, R);
}
/**
* 计算多边形中心点(几何中心)
*/
static calculateCentroid(geometry: Polygon): Point {
const points = geometry.points;
if (points.length === 0) return { lat: 0, lng: 0 };
let sumLat = 0;
let sumLng = 0;
let sumArea = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
const cross = p1.lng * p2.lat - p2.lng * p1.lat;
sumArea += cross;
sumLat += (p1.lat + p2.lat) * cross;
sumLng += (p1.lng + p2.lng) * cross;
}
sumArea /= 2;
if (Math.abs(sumArea) < 1e-10) {
// 如果面积接近0使用简单平均
const avgLat = points.reduce((sum, p) => sum + p.lat, 0) / points.length;
const avgLng = points.reduce((sum, p) => sum + p.lng, 0) / points.length;
return { lat: avgLat, lng: avgLng };
}
const centroidLat = sumLat / (6 * sumArea);
const centroidLng = sumLng / (6 * sumArea);
return { lat: centroidLat, lng: centroidLng };
}
/**
* 计算包围盒Bounding Box
*/
static calculateBoundingBox(geometry: Polygon): {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
center: Point;
} {
const points = geometry.points;
if (points.length === 0) {
return {
minLat: 0,
maxLat: 0,
minLng: 0,
maxLng: 0,
center: { lat: 0, lng: 0 },
};
}
let minLat = points[0].lat;
let maxLat = points[0].lat;
let minLng = points[0].lng;
let maxLng = points[0].lng;
for (const point of points) {
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
minLng = Math.min(minLng, point.lng);
maxLng = Math.max(maxLng, point.lng);
}
return {
minLat,
maxLat,
minLng,
maxLng,
center: {
lat: (minLat + maxLat) / 2,
lng: (minLng + maxLng) / 2,
},
};
}
/**
* 角度转弧度
*/
private static _toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
/**
* 弧度转角度
*/
private static _toDegrees(radians: number): number {
return (radians * 180) / Math.PI;
}
}
// ===== 3. 数据导出API =====
export class DataExporter {
/**
* 导出为GeoJSON格式
*/
static exportToGeoJSON(fields: Field[]): string {
const features = fields.map((field) => ({
type: 'Feature',
id: field.id,
properties: {
name: field.name,
code: field.code,
area: GeometryCalculator.calculateArea(field.geometry),
perimeter: GeometryCalculator.calculatePerimeter(field.geometry),
centroid: GeometryCalculator.calculateCentroid(field.geometry),
...field.properties,
},
geometry: {
type: 'Polygon',
coordinates: [field.geometry.points.map((p) => [p.lng, p.lat])],
},
}));
const geoJSON = {
type: 'FeatureCollection',
crs: {
type: 'name',
properties: {
name: 'EPSG:4326', // WGS-84
},
},
features,
};
return JSON.stringify(geoJSON, null, 2);
}
/**
* 导出为KML格式
*/
static exportToKML(fields: Field[]): string {
const placemarks = fields
.map((field) => {
const coords = field.geometry.points.map((p) => `${p.lng},${p.lat},0`).join(' ');
return `
<Placemark>
<name>${field.name}</name>
<description>
编号: ${field.code}
面积: ${GeometryCalculator.calculateArea(field.geometry).toFixed(2)}
周长: ${GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0)}
</description>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>${coords}</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</Placemark>`;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>地块数据</name>
<description>智慧农业生产管理系统地块导出</description>
${placemarks}
</Document>
</kml>`;
}
/**
* 导出为Shapefile格式WKT格式
*/
static exportToWKT(field: Field): string {
const coords = field.geometry.points.map((p) => `${p.lng} ${p.lat}`).join(', ');
return `POLYGON((${coords}))`;
}
/**
* 导出为CSV格式
*/
static exportToCSV(fields: Field[]): string {
const headers = ['ID', '名称', '编号', '面积(亩)', '周长(米)', '中心点纬度', '中心点经度'];
const rows = fields.map((field) => {
const centroid = GeometryCalculator.calculateCentroid(field.geometry);
return [
field.id,
field.name,
field.code,
GeometryCalculator.calculateArea(field.geometry).toFixed(2),
GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0),
centroid.lat.toFixed(6),
centroid.lng.toFixed(6),
];
});
const csvContent = [
headers.join(','),
...rows.map((row) => row.join(',')),
].join('\n');
return csvContent;
}
/**
* 下载文件
*/
static downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
}
// ===== 4. 空间索引(用于性能优化) =====
export class SpatialIndex {
private rtree: Map<string, { bbox: any; field: Field }>;
constructor() {
this.rtree = new Map();
}
/**
* 插入地块
*/
insert(field: Field): void {
const bbox = GeometryCalculator.calculateBoundingBox(field.geometry);
this.rtree.set(field.id, { bbox, field });
}
/**
* 快速查询可能相交的地块
*/
query(bbox: {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}): Field[] {
const results: Field[] = [];
for (const [_, item] of this.rtree) {
if (this._bboxesIntersect(bbox, item.bbox)) {
results.push(item.field);
}
}
return results;
}
/**
* 判断两个包围盒是否相交
*/
private _bboxesIntersect(
bbox1: { minLat: number; maxLat: number; minLng: number; maxLng: number },
bbox2: { minLat: number; maxLat: number; minLng: number; maxLng: number }
): boolean {
return !(
bbox1.maxLat < bbox2.minLat ||
bbox1.minLat > bbox2.maxLat ||
bbox1.maxLng < bbox2.minLng ||
bbox1.minLng > bbox2.maxLng
);
}
}