生产管理系统前端 - 提交空间数据管理开发页面
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
490
crop-x/src/app/(app)/land-information/archive/context/page.tsx
Normal file
490
crop-x/src/app/(app)/land-information/archive/context/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 '-- 请选择查询类型';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 '-- 请选择查询类型';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user