生产管理系统前端 - 提交空间数据管理开发页面
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
238
crop-x/src/components/field/MapPointPicker.tsx
Normal file
238
crop-x/src/components/field/MapPointPicker.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* 地图选点组件
|
||||||
|
* 用于在地图上选择坐标点
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Point } from '../../lib/spatialDataService';
|
||||||
|
import { MapPin, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MapPointPickerProps {
|
||||||
|
points: Point[];
|
||||||
|
mode: 'polygon' | 'single';
|
||||||
|
onPointsChange: (points: Point[]) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
height?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapPointPicker({
|
||||||
|
points,
|
||||||
|
mode,
|
||||||
|
onPointsChange,
|
||||||
|
onClose,
|
||||||
|
height = '500px',
|
||||||
|
title = '在地图上选择坐标点'
|
||||||
|
}: MapPointPickerProps) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [map, setMap] = useState<any>(null);
|
||||||
|
const [markers, setMarkers] = useState<any[]>([]);
|
||||||
|
const [polygon, setPolygon] = useState<any>(null);
|
||||||
|
|
||||||
|
// 初始化地图
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || map) return;
|
||||||
|
|
||||||
|
// 检查高德地图是否已加载
|
||||||
|
if (typeof window.AMap === 'undefined') {
|
||||||
|
console.log('💡 使用演示地图模式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算中心点
|
||||||
|
const centerLat = points && points.length > 0
|
||||||
|
? points.reduce((sum, p) => sum + p.lat, 0) / points.length
|
||||||
|
: 39.9042;
|
||||||
|
const centerLng = points && points.length > 0
|
||||||
|
? points.reduce((sum, p) => sum + p.lng, 0) / points.length
|
||||||
|
: 116.4074;
|
||||||
|
|
||||||
|
// 创建地图实例
|
||||||
|
const mapInstance = new window.AMap.Map(mapRef.current, {
|
||||||
|
zoom: 14,
|
||||||
|
center: [centerLng, centerLat],
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
viewMode: '2D'
|
||||||
|
});
|
||||||
|
|
||||||
|
setMap(mapInstance);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapInstance) {
|
||||||
|
mapInstance.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 绘制标记和多边形
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !points) return;
|
||||||
|
|
||||||
|
// 清除旧标记
|
||||||
|
markers.forEach(marker => marker.setMap(null));
|
||||||
|
if (polygon) {
|
||||||
|
polygon.setMap(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新标记
|
||||||
|
const newMarkers = points.map((point, index) => {
|
||||||
|
const marker = new window.AMap.Marker({
|
||||||
|
position: [point.lng, point.lat],
|
||||||
|
map: map,
|
||||||
|
title: `点 ${index + 1}`,
|
||||||
|
label: {
|
||||||
|
content: `${index + 1}`,
|
||||||
|
direction: 'top'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击标记删除(仅多边形模式且点数>3)
|
||||||
|
if (mode === 'polygon' && points.length > 3) {
|
||||||
|
marker.on('click', () => {
|
||||||
|
const newPoints = points.filter((_, i) => i !== index);
|
||||||
|
onPointsChange(newPoints);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMarkers(newMarkers);
|
||||||
|
|
||||||
|
// 绘制多边形(仅多边形模式)
|
||||||
|
if (mode === 'polygon' && points.length >= 3) {
|
||||||
|
const path = points.map(p => [p.lng, p.lat]);
|
||||||
|
const poly = new window.AMap.Polygon({
|
||||||
|
path: path,
|
||||||
|
strokeColor: '#22c55e',
|
||||||
|
strokeWeight: 2,
|
||||||
|
strokeOpacity: 0.8,
|
||||||
|
fillColor: '#22c55e',
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
map: map
|
||||||
|
});
|
||||||
|
setPolygon(poly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自适应显示
|
||||||
|
if (points && points.length > 0) {
|
||||||
|
map.setFitView();
|
||||||
|
}
|
||||||
|
}, [map, points]);
|
||||||
|
|
||||||
|
// 地图点击事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const clickHandler = (e: any) => {
|
||||||
|
const lng = e.lnglat.getLng();
|
||||||
|
const lat = e.lnglat.getLat();
|
||||||
|
|
||||||
|
if (mode === 'single') {
|
||||||
|
// 单点模式:替换唯一的点
|
||||||
|
onPointsChange([{ lat, lng }]);
|
||||||
|
} else {
|
||||||
|
// 多边形模式:添加新点
|
||||||
|
onPointsChange([...(points || []), { lat, lng }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('click', clickHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('click', clickHandler);
|
||||||
|
};
|
||||||
|
}, [map, points, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="p-3 bg-gray-50 border-b flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-green-600" />
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{mode === 'polygon' ? `${points.length} 个点` : '单点选择'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{mode === 'polygon' && points.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onPointsChange([])}
|
||||||
|
>
|
||||||
|
清除所有点
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<Button size="sm" onClick={onClose}>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
完成选择
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div className="px-3 py-2 bg-blue-50 border-b">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
{mode === 'polygon'
|
||||||
|
? '💡 点击地图添加坐标点,点击标记删除该点(至少保留3个点)'
|
||||||
|
: '💡 点击地图选择坐标点位置'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 地图容器 */}
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{ height }}
|
||||||
|
className="w-full relative"
|
||||||
|
>
|
||||||
|
{/* 地图加载提示 */}
|
||||||
|
{!map && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mb-3"></div>
|
||||||
|
<p className="text-sm text-muted-foreground">地图加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 坐标列表 */}
|
||||||
|
{points && points.length > 0 && (
|
||||||
|
<div className="p-3 bg-gray-50 border-t max-h-32 overflow-y-auto">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
选中的坐标点:
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<div key={index} className="text-xs font-mono flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
点{index + 1}: {point.lat.toFixed(6)}, {point.lng.toFixed(6)}
|
||||||
|
</span>
|
||||||
|
{mode === 'polygon' && points.length > 3 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
const newPoints = points.filter((_, i) => i !== index);
|
||||||
|
onPointsChange(newPoints);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,9 +25,9 @@ export function ThemeToggle() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled
|
disabled
|
||||||
className="transition-colors"
|
className="transition-colors h-10 w-10"
|
||||||
>
|
>
|
||||||
<Sun className="w-5 h-5" />
|
<Sun className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,12 +38,12 @@ export function ThemeToggle() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
|
title={theme === 'light' ? '切换到深色模式' : '切换到浅色模式'}
|
||||||
className="transition-colors"
|
className="transition-colors h-10 w-10"
|
||||||
>
|
>
|
||||||
{theme === 'light' ? (
|
{theme === 'light' ? (
|
||||||
<Moon className="w-5 h-5" />
|
<Moon className="size-5" />
|
||||||
) : (
|
) : (
|
||||||
<Sun className="w-5 h-5" />
|
<Sun className="size-5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ export function MessageBell({ onMessageClick }: MessageBellProps) {
|
|||||||
<Popover open={showMessages} onOpenChange={setShowMessages}>
|
<Popover open={showMessages} onOpenChange={setShowMessages}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
<Bell className="w-5 h-5" />
|
<Bell
|
||||||
|
className="w-5 h-5" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white text-xs"
|
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 bg-red-500 text-white text-xs"
|
||||||
|
|||||||
146
crop-x/src/lib/mapLoader.ts
Normal file
146
crop-x/src/lib/mapLoader.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 高德地图SDK动态加载器
|
||||||
|
* 用于在不修改index.html的情况下加载高德地图SDK
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 高德地图配置
|
||||||
|
const AMAP_CONFIG = {
|
||||||
|
// 替换为你的高德地图API Key
|
||||||
|
// 申请地址: https://console.amap.com/
|
||||||
|
key: 'YOUR_AMAP_KEY',
|
||||||
|
|
||||||
|
// 替换为你的安全密钥(可选,用于提高安全性)
|
||||||
|
securityJsCode: '',
|
||||||
|
|
||||||
|
// SDK版本
|
||||||
|
version: '2.0',
|
||||||
|
|
||||||
|
// 可选插件
|
||||||
|
plugins: ['AMap.Scale', 'AMap.ToolBar', 'AMap.Geocoder'] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载高德地图SDK
|
||||||
|
* @returns Promise<any> 返回AMap对象或null(占位模式)
|
||||||
|
*/
|
||||||
|
export const loadAMapScript = (): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 如果已经加载,直接返回
|
||||||
|
if (window.AMap) {
|
||||||
|
console.log('✅ 高德地图SDK已加载');
|
||||||
|
resolve(window.AMap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查Key是否配置
|
||||||
|
if (AMAP_CONFIG.key === 'YOUR_AMAP_KEY' || !AMAP_CONFIG.key) {
|
||||||
|
// 使用占位地图(功能完整)
|
||||||
|
console.log('💡 使用占位地图模式(功能完整)');
|
||||||
|
console.log('💡 如需真实地图,请在 /lib/mapLoader.ts 中配置高德地图Key');
|
||||||
|
console.log('💡 申请地址: https://console.amap.com/');
|
||||||
|
resolve(null); // 返回null表示使用占位地图
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置安全密钥(如果提供)
|
||||||
|
if (AMAP_CONFIG.securityJsCode) {
|
||||||
|
window._AMapSecurityConfig = {
|
||||||
|
securityJsCode: AMAP_CONFIG.securityJsCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建script标签
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
|
||||||
|
// 构建SDK URL
|
||||||
|
let url = `https://webapi.amap.com/maps?v=${AMAP_CONFIG.version}&key=${AMAP_CONFIG.key}`;
|
||||||
|
|
||||||
|
// 添加插件
|
||||||
|
if (AMAP_CONFIG.plugins.length > 0) {
|
||||||
|
url += `&plugin=${AMAP_CONFIG.plugins.join(',')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
script.src = url;
|
||||||
|
|
||||||
|
// 加载成功
|
||||||
|
script.onload = () => {
|
||||||
|
console.log('✅ 高德地图SDK加载成功');
|
||||||
|
console.log('📍 版本:', window.AMap?.version);
|
||||||
|
resolve(window.AMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载失败
|
||||||
|
script.onerror = () => {
|
||||||
|
console.error('❌ 高德地图SDK加载失败');
|
||||||
|
reject(new Error('高德地图SDK加载失败'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加到页面
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
console.log('🔄 正在加载高德地图SDK...');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 加载高德地图SDK时发生错误:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查高德地图SDK是否已加载
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export const isAMapLoaded = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' && !!window.AMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取高德地图版本
|
||||||
|
* @returns string | null
|
||||||
|
*/
|
||||||
|
export const getAMapVersion = (): string | null => {
|
||||||
|
if (isAMapLoaded()) {
|
||||||
|
return window.AMap.version || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TypeScript 类型声明
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
AMap: any;
|
||||||
|
_AMapSecurityConfig: {
|
||||||
|
securityJsCode: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用示例:
|
||||||
|
*
|
||||||
|
* import { loadAMapScript, isAMapLoaded } from './lib/mapLoader';
|
||||||
|
*
|
||||||
|
* // 在组件中使用
|
||||||
|
* useEffect(() => {
|
||||||
|
* if (!isAMapLoaded()) {
|
||||||
|
* loadAMapScript()
|
||||||
|
* .then((AMap) => {
|
||||||
|
* if (AMap) {
|
||||||
|
* console.log('地图SDK加载成功,可以初始化地图');
|
||||||
|
* initMap();
|
||||||
|
* } else {
|
||||||
|
* console.log('使用占位地图模式');
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* .catch((error) => {
|
||||||
|
* console.error('地图SDK加载失败,使用占位地图', error);
|
||||||
|
* });
|
||||||
|
* } else {
|
||||||
|
* initMap();
|
||||||
|
* }
|
||||||
|
* }, []);
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {};
|
||||||
937
crop-x/src/lib/spatialDataService.ts
Normal file
937
crop-x/src/lib/spatialDataService.ts
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
/**
|
||||||
|
* 空间数据服务API
|
||||||
|
* 提供PostGIS风格的空间查询、几何计算和数据导出功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== 类型定义 =====
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
alt?: number; // 海拔高度
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Polygon {
|
||||||
|
points: Point[];
|
||||||
|
holes?: Point[][]; // 多边形的孔洞
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Field {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
geometry: Polygon;
|
||||||
|
properties?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpatialQueryResult<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
executionTime: number; // 毫秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 常量定义 =====
|
||||||
|
|
||||||
|
// WGS-84椭球参数
|
||||||
|
const WGS84_A = 6378137.0; // 长半轴(米)
|
||||||
|
const WGS84_B = 6356752.314245; // 短半轴(米)
|
||||||
|
const WGS84_F = 1 / 298.257223563; // 扁率
|
||||||
|
|
||||||
|
// 1亩 = 666.67平方米
|
||||||
|
const MU_TO_SQUARE_METERS = 666.67;
|
||||||
|
|
||||||
|
// ===== 1. 空间查询API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点面查询:判断点是否在多边形内
|
||||||
|
* 使用射线法(Ray Casting Algorithm)
|
||||||
|
*/
|
||||||
|
export class SpatialQuery {
|
||||||
|
/**
|
||||||
|
* 点在多边形内查询
|
||||||
|
* @param point 查询点
|
||||||
|
* @param fields 地块列表
|
||||||
|
* @returns 包含该点的地块列表
|
||||||
|
*/
|
||||||
|
static pointInPolygon(point: Point, fields: Field[]): SpatialQueryResult<{
|
||||||
|
matched: boolean;
|
||||||
|
fields: Array<{
|
||||||
|
field: Field;
|
||||||
|
distanceToBorder: number; // 到边界的最短距离(米)
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const results: Array<{ field: Field; distanceToBorder: number }> = [];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (this._isPointInPolygon(point, field.geometry.points)) {
|
||||||
|
const distance = this._pointToPolygonDistance(point, field.geometry.points);
|
||||||
|
results.push({ field, distanceToBorder: distance });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionTime = performance.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
matched: results.length > 0,
|
||||||
|
fields: results,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
executionTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多边形相交查询
|
||||||
|
* @param sourceField 源地块
|
||||||
|
* @param targetFields 目标地块列表
|
||||||
|
* @returns 与源地块相交的地块列表
|
||||||
|
*/
|
||||||
|
static polygonIntersect(
|
||||||
|
sourceField: Field,
|
||||||
|
targetFields: Field[]
|
||||||
|
): SpatialQueryResult<{
|
||||||
|
intersections: Array<{
|
||||||
|
field: Field;
|
||||||
|
intersectArea: number; // 相交面积(亩)
|
||||||
|
intersectRatio: number; // 相交比例(%)
|
||||||
|
intersectGeometry: Polygon; // 相交区域几何
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const intersections: Array<{
|
||||||
|
field: Field;
|
||||||
|
intersectArea: number;
|
||||||
|
intersectRatio: number;
|
||||||
|
intersectGeometry: Polygon;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const sourceArea = GeometryCalculator.calculateArea(sourceField.geometry);
|
||||||
|
|
||||||
|
for (const targetField of targetFields) {
|
||||||
|
if (targetField.id === sourceField.id) continue;
|
||||||
|
|
||||||
|
if (this._polygonsIntersect(sourceField.geometry.points, targetField.geometry.points)) {
|
||||||
|
const intersectGeometry = this._calculateIntersection(
|
||||||
|
sourceField.geometry,
|
||||||
|
targetField.geometry
|
||||||
|
);
|
||||||
|
const intersectArea = GeometryCalculator.calculateArea(intersectGeometry);
|
||||||
|
const intersectRatio = (intersectArea / sourceArea) * 100;
|
||||||
|
|
||||||
|
intersections.push({
|
||||||
|
field: targetField,
|
||||||
|
intersectArea,
|
||||||
|
intersectRatio,
|
||||||
|
intersectGeometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionTime = performance.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { intersections },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
executionTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相邻地块查询
|
||||||
|
* @param sourceField 源地块
|
||||||
|
* @param targetFields 目标地块列表
|
||||||
|
* @returns 与源地块相邻的地块列表
|
||||||
|
*/
|
||||||
|
static adjacentPolygons(
|
||||||
|
sourceField: Field,
|
||||||
|
targetFields: Field[]
|
||||||
|
): SpatialQueryResult<{
|
||||||
|
adjacentFields: Array<{
|
||||||
|
field: Field;
|
||||||
|
sharedBorderLength: number; // 共享边界长度(米)
|
||||||
|
sharedBorderPoints: Point[]; // 共享边界点
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
const adjacentFields: Array<{
|
||||||
|
field: Field;
|
||||||
|
sharedBorderLength: number;
|
||||||
|
sharedBorderPoints: Point[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const targetField of targetFields) {
|
||||||
|
if (targetField.id === sourceField.id) continue;
|
||||||
|
|
||||||
|
const { isAdjacent, sharedBorder } = this._checkAdjacency(
|
||||||
|
sourceField.geometry.points,
|
||||||
|
targetField.geometry.points
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAdjacent && sharedBorder.length > 0) {
|
||||||
|
const sharedBorderLength = GeometryCalculator.calculatePerimeter({
|
||||||
|
points: sharedBorder,
|
||||||
|
});
|
||||||
|
|
||||||
|
adjacentFields.push({
|
||||||
|
field: targetField,
|
||||||
|
sharedBorderLength,
|
||||||
|
sharedBorderPoints: sharedBorder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionTime = performance.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { adjacentFields },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
executionTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓冲区分析
|
||||||
|
* @param sourceField 源地块
|
||||||
|
* @param bufferDistance 缓冲区距离(米)
|
||||||
|
* @param targetFields 目标地块列表
|
||||||
|
* @returns 缓冲区内的地块列表
|
||||||
|
*/
|
||||||
|
static bufferAnalysis(
|
||||||
|
sourceField: Field,
|
||||||
|
bufferDistance: number,
|
||||||
|
targetFields: Field[]
|
||||||
|
): SpatialQueryResult<{
|
||||||
|
bufferGeometry: Polygon;
|
||||||
|
bufferArea: number; // 缓冲区面积(亩)
|
||||||
|
fieldsInBuffer: Array<{
|
||||||
|
field: Field;
|
||||||
|
distance: number; // 最短距离(米)
|
||||||
|
overlap: boolean; // 是否重叠
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// 生成缓冲区几何
|
||||||
|
const bufferGeometry = this._createBuffer(sourceField.geometry, bufferDistance);
|
||||||
|
const bufferArea = GeometryCalculator.calculateArea(bufferGeometry);
|
||||||
|
|
||||||
|
const fieldsInBuffer: Array<{
|
||||||
|
field: Field;
|
||||||
|
distance: number;
|
||||||
|
overlap: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const targetField of targetFields) {
|
||||||
|
if (targetField.id === sourceField.id) continue;
|
||||||
|
|
||||||
|
const distance = this._polygonToPolygonDistance(
|
||||||
|
sourceField.geometry.points,
|
||||||
|
targetField.geometry.points
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance <= bufferDistance) {
|
||||||
|
const overlap = this._polygonsIntersect(
|
||||||
|
bufferGeometry.points,
|
||||||
|
targetField.geometry.points
|
||||||
|
);
|
||||||
|
fieldsInBuffer.push({
|
||||||
|
field: targetField,
|
||||||
|
distance,
|
||||||
|
overlap,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executionTime = performance.now() - startTime;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
bufferGeometry,
|
||||||
|
bufferArea,
|
||||||
|
fieldsInBuffer,
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
executionTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 私有辅助方法 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 射线法判断点是否在多边形内
|
||||||
|
*/
|
||||||
|
private static _isPointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i].lng,
|
||||||
|
yi = polygon[i].lat;
|
||||||
|
const xj = polygon[j].lng,
|
||||||
|
yj = polygon[j].lat;
|
||||||
|
|
||||||
|
const intersect =
|
||||||
|
yi > point.lat !== yj > point.lat &&
|
||||||
|
point.lng < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算点到多边形边界的最短距离
|
||||||
|
*/
|
||||||
|
private static _pointToPolygonDistance(point: Point, polygon: Point[]): number {
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < polygon.length; i++) {
|
||||||
|
const p1 = polygon[i];
|
||||||
|
const p2 = polygon[(i + 1) % polygon.length];
|
||||||
|
const distance = this._pointToSegmentDistance(point, p1, p2);
|
||||||
|
minDistance = Math.min(minDistance, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算点到线段的距离
|
||||||
|
*/
|
||||||
|
private static _pointToSegmentDistance(point: Point, p1: Point, p2: Point): number {
|
||||||
|
const dx = p2.lng - p1.lng;
|
||||||
|
const dy = p2.lat - p1.lat;
|
||||||
|
|
||||||
|
if (dx === 0 && dy === 0) {
|
||||||
|
return GeometryCalculator.haversineDistance(point, p1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
((point.lng - p1.lng) * dx + (point.lat - p1.lat) * dy) / (dx * dx + dy * dy)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const nearestPoint: Point = {
|
||||||
|
lat: p1.lat + t * dy,
|
||||||
|
lng: p1.lng + t * dx,
|
||||||
|
};
|
||||||
|
|
||||||
|
return GeometryCalculator.haversineDistance(point, nearestPoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个多边形是否相交
|
||||||
|
*/
|
||||||
|
private static _polygonsIntersect(poly1: Point[], poly2: Point[]): boolean {
|
||||||
|
// 检查是否有顶点在另一个多边形内
|
||||||
|
for (const point of poly1) {
|
||||||
|
if (this._isPointInPolygon(point, poly2)) return true;
|
||||||
|
}
|
||||||
|
for (const point of poly2) {
|
||||||
|
if (this._isPointInPolygon(point, poly1)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查边是否相交
|
||||||
|
for (let i = 0; i < poly1.length; i++) {
|
||||||
|
const p1 = poly1[i];
|
||||||
|
const p2 = poly1[(i + 1) % poly1.length];
|
||||||
|
|
||||||
|
for (let j = 0; j < poly2.length; j++) {
|
||||||
|
const p3 = poly2[j];
|
||||||
|
const p4 = poly2[(j + 1) % poly2.length];
|
||||||
|
|
||||||
|
if (this._segmentsIntersect(p1, p2, p3, p4)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条线段是否相交
|
||||||
|
*/
|
||||||
|
private static _segmentsIntersect(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
|
||||||
|
const ccw = (A: Point, B: Point, C: Point) => {
|
||||||
|
return (C.lat - A.lat) * (B.lng - A.lng) > (B.lat - A.lat) * (C.lng - A.lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个多边形的相交区域(简化实现)
|
||||||
|
*/
|
||||||
|
private static _calculateIntersection(poly1: Polygon, poly2: Polygon): Polygon {
|
||||||
|
// 这里使用简化算法,实际应用中应使用Sutherland-Hodgman算法
|
||||||
|
const intersectPoints: Point[] = [];
|
||||||
|
|
||||||
|
// 收集在两个多边形内的点
|
||||||
|
for (const point of poly1.points) {
|
||||||
|
if (this._isPointInPolygon(point, poly2.points)) {
|
||||||
|
intersectPoints.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const point of poly2.points) {
|
||||||
|
if (this._isPointInPolygon(point, poly1.points)) {
|
||||||
|
intersectPoints.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有交点,返回空多边形
|
||||||
|
if (intersectPoints.length === 0) {
|
||||||
|
return { points: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算凸包作为相交区域的近似
|
||||||
|
return { points: this._convexHull(intersectPoints) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算凸包(Graham扫描算法)
|
||||||
|
*/
|
||||||
|
private static _convexHull(points: Point[]): Point[] {
|
||||||
|
if (points.length < 3) return points;
|
||||||
|
|
||||||
|
// 找到最下最左的点
|
||||||
|
let start = points[0];
|
||||||
|
points.forEach((p) => {
|
||||||
|
if (p.lat < start.lat || (p.lat === start.lat && p.lng < start.lng)) {
|
||||||
|
start = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按极角排序
|
||||||
|
const sorted = points
|
||||||
|
.filter((p) => p !== start)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const angleA = Math.atan2(a.lat - start.lat, a.lng - start.lng);
|
||||||
|
const angleB = Math.atan2(b.lat - start.lat, b.lng - start.lng);
|
||||||
|
return angleA - angleB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hull: Point[] = [start];
|
||||||
|
|
||||||
|
for (const point of sorted) {
|
||||||
|
while (hull.length >= 2) {
|
||||||
|
const p2 = hull[hull.length - 1];
|
||||||
|
const p1 = hull[hull.length - 2];
|
||||||
|
const cross =
|
||||||
|
(p2.lng - p1.lng) * (point.lat - p1.lat) -
|
||||||
|
(p2.lat - p1.lat) * (point.lng - p1.lng);
|
||||||
|
if (cross <= 0) {
|
||||||
|
hull.pop();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hull.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hull;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查两个多边形是否相邻
|
||||||
|
*/
|
||||||
|
private static _checkAdjacency(
|
||||||
|
poly1: Point[],
|
||||||
|
poly2: Point[]
|
||||||
|
): { isAdjacent: boolean; sharedBorder: Point[] } {
|
||||||
|
const sharedBorder: Point[] = [];
|
||||||
|
const tolerance = 0.00001; // 约1米的容差
|
||||||
|
|
||||||
|
for (let i = 0; i < poly1.length; i++) {
|
||||||
|
const p1 = poly1[i];
|
||||||
|
const p2 = poly1[(i + 1) % poly1.length];
|
||||||
|
|
||||||
|
for (let j = 0; j < poly2.length; j++) {
|
||||||
|
const p3 = poly2[j];
|
||||||
|
const p4 = poly2[(j + 1) % poly2.length];
|
||||||
|
|
||||||
|
// 检查边是否重合
|
||||||
|
if (this._edgesOverlap(p1, p2, p3, p4, tolerance)) {
|
||||||
|
if (sharedBorder.length === 0 || !this._pointsEqual(sharedBorder[sharedBorder.length - 1], p1, tolerance)) {
|
||||||
|
sharedBorder.push(p1);
|
||||||
|
}
|
||||||
|
sharedBorder.push(p2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdjacent: sharedBorder.length >= 2,
|
||||||
|
sharedBorder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两条边是否重合
|
||||||
|
*/
|
||||||
|
private static _edgesOverlap(
|
||||||
|
p1: Point,
|
||||||
|
p2: Point,
|
||||||
|
p3: Point,
|
||||||
|
p4: Point,
|
||||||
|
tolerance: number
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
(this._pointsEqual(p1, p3, tolerance) && this._pointsEqual(p2, p4, tolerance)) ||
|
||||||
|
(this._pointsEqual(p1, p4, tolerance) && this._pointsEqual(p2, p3, tolerance))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个点是否相等(在容差范围内)
|
||||||
|
*/
|
||||||
|
private static _pointsEqual(p1: Point, p2: Point, tolerance: number): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(p1.lat - p2.lat) < tolerance && Math.abs(p1.lng - p2.lng) < tolerance
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算多边形到多边形的最短距离
|
||||||
|
*/
|
||||||
|
private static _polygonToPolygonDistance(poly1: Point[], poly2: Point[]): number {
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const point of poly1) {
|
||||||
|
const distance = this._pointToPolygonDistance(point, poly2);
|
||||||
|
minDistance = Math.min(minDistance, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const point of poly2) {
|
||||||
|
const distance = this._pointToPolygonDistance(point, poly1);
|
||||||
|
minDistance = Math.min(minDistance, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缓冲区(简化实现)
|
||||||
|
*/
|
||||||
|
private static _createBuffer(geometry: Polygon, distance: number): Polygon {
|
||||||
|
const bufferPoints: Point[] = [];
|
||||||
|
const points = geometry.points;
|
||||||
|
|
||||||
|
// 简化算法:对每个顶点,在法线方向上偏移distance距离
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const prev = points[i === 0 ? points.length - 1 : i - 1];
|
||||||
|
const curr = points[i];
|
||||||
|
const next = points[(i + 1) % points.length];
|
||||||
|
|
||||||
|
// 计算法向量
|
||||||
|
const v1 = { lat: curr.lat - prev.lat, lng: curr.lng - prev.lng };
|
||||||
|
const v2 = { lat: next.lat - curr.lat, lng: next.lng - curr.lng };
|
||||||
|
|
||||||
|
// 计算平均法向量
|
||||||
|
const normal = {
|
||||||
|
lat: -(v1.lng + v2.lng),
|
||||||
|
lng: v1.lat + v2.lat,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 归一化
|
||||||
|
const length = Math.sqrt(normal.lat * normal.lat + normal.lng * normal.lng);
|
||||||
|
if (length > 0) {
|
||||||
|
normal.lat /= length;
|
||||||
|
normal.lng /= length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 偏移顶点(简化:使用度数偏移,实际应转换为米)
|
||||||
|
const offsetDegrees = distance / 111320; // 约111.32km每度
|
||||||
|
bufferPoints.push({
|
||||||
|
lat: curr.lat + normal.lat * offsetDegrees,
|
||||||
|
lng: curr.lng + normal.lng * offsetDegrees,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { points: bufferPoints };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. 几何计算API =====
|
||||||
|
|
||||||
|
export class GeometryCalculator {
|
||||||
|
/**
|
||||||
|
* 计算多边形精确面积(考虑地球曲率)
|
||||||
|
* 使用球面三角形面积公式
|
||||||
|
*/
|
||||||
|
static calculateArea(geometry: Polygon): number {
|
||||||
|
const points = geometry.points;
|
||||||
|
if (points.length < 3) return 0;
|
||||||
|
|
||||||
|
// 将多边形分解为三角形,计算球面三角形面积之和
|
||||||
|
let totalArea = 0;
|
||||||
|
const origin = points[0];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const area = this._sphericalTriangleArea(origin, points[i], points[i + 1]);
|
||||||
|
totalArea += area;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为亩
|
||||||
|
return totalArea / MU_TO_SQUARE_METERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算球面三角形面积(L'Huilier定理)
|
||||||
|
*/
|
||||||
|
private static _sphericalTriangleArea(p1: Point, p2: Point, p3: Point): number {
|
||||||
|
const R = WGS84_A; // 使用WGS-84长半轴
|
||||||
|
|
||||||
|
// 转换为弧度
|
||||||
|
const lat1 = this._toRadians(p1.lat);
|
||||||
|
const lng1 = this._toRadians(p1.lng);
|
||||||
|
const lat2 = this._toRadians(p2.lat);
|
||||||
|
const lng2 = this._toRadians(p2.lng);
|
||||||
|
const lat3 = this._toRadians(p3.lat);
|
||||||
|
const lng3 = this._toRadians(p3.lng);
|
||||||
|
|
||||||
|
// 计算边长(球面距离)
|
||||||
|
const a = this._sphericalDistance(lat2, lng2, lat3, lng3, R);
|
||||||
|
const b = this._sphericalDistance(lat3, lng3, lat1, lng1, R);
|
||||||
|
const c = this._sphericalDistance(lat1, lng1, lat2, lng2, R);
|
||||||
|
|
||||||
|
// 半周长
|
||||||
|
const s = (a + b + c) / 2;
|
||||||
|
|
||||||
|
// L'Huilier定理
|
||||||
|
const E = 4 * Math.atan(Math.sqrt(Math.tan(s / (2 * R)) * Math.tan((s - a) / (2 * R)) * Math.tan((s - b) / (2 * R)) * Math.tan((s - c) / (2 * R))));
|
||||||
|
|
||||||
|
// 面积 = R² * E
|
||||||
|
return R * R * E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算球面距离
|
||||||
|
*/
|
||||||
|
private static _sphericalDistance(
|
||||||
|
lat1: number,
|
||||||
|
lng1: number,
|
||||||
|
lat2: number,
|
||||||
|
lng2: number,
|
||||||
|
radius: number
|
||||||
|
): number {
|
||||||
|
const dLat = lat2 - lat1;
|
||||||
|
const dLng = lng2 - lng1;
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return radius * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算多边形周长(考虑地球曲率)
|
||||||
|
*/
|
||||||
|
static calculatePerimeter(geometry: Polygon): number {
|
||||||
|
const points = geometry.points;
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
|
||||||
|
let perimeter = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
perimeter += this.haversineDistance(p1, p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return perimeter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haversine公式计算两点间距离(米)
|
||||||
|
*/
|
||||||
|
static haversineDistance(p1: Point, p2: Point): number {
|
||||||
|
const R = WGS84_A;
|
||||||
|
|
||||||
|
const lat1 = this._toRadians(p1.lat);
|
||||||
|
const lng1 = this._toRadians(p1.lng);
|
||||||
|
const lat2 = this._toRadians(p2.lat);
|
||||||
|
const lng2 = this._toRadians(p2.lng);
|
||||||
|
|
||||||
|
return this._sphericalDistance(lat1, lng1, lat2, lng2, R);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算多边形中心点(几何中心)
|
||||||
|
*/
|
||||||
|
static calculateCentroid(geometry: Polygon): Point {
|
||||||
|
const points = geometry.points;
|
||||||
|
if (points.length === 0) return { lat: 0, lng: 0 };
|
||||||
|
|
||||||
|
let sumLat = 0;
|
||||||
|
let sumLng = 0;
|
||||||
|
let sumArea = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[(i + 1) % points.length];
|
||||||
|
|
||||||
|
const cross = p1.lng * p2.lat - p2.lng * p1.lat;
|
||||||
|
sumArea += cross;
|
||||||
|
sumLat += (p1.lat + p2.lat) * cross;
|
||||||
|
sumLng += (p1.lng + p2.lng) * cross;
|
||||||
|
}
|
||||||
|
|
||||||
|
sumArea /= 2;
|
||||||
|
|
||||||
|
if (Math.abs(sumArea) < 1e-10) {
|
||||||
|
// 如果面积接近0,使用简单平均
|
||||||
|
const avgLat = points.reduce((sum, p) => sum + p.lat, 0) / points.length;
|
||||||
|
const avgLng = points.reduce((sum, p) => sum + p.lng, 0) / points.length;
|
||||||
|
return { lat: avgLat, lng: avgLng };
|
||||||
|
}
|
||||||
|
|
||||||
|
const centroidLat = sumLat / (6 * sumArea);
|
||||||
|
const centroidLng = sumLng / (6 * sumArea);
|
||||||
|
|
||||||
|
return { lat: centroidLat, lng: centroidLng };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算包围盒(Bounding Box)
|
||||||
|
*/
|
||||||
|
static calculateBoundingBox(geometry: Polygon): {
|
||||||
|
minLat: number;
|
||||||
|
maxLat: number;
|
||||||
|
minLng: number;
|
||||||
|
maxLng: number;
|
||||||
|
center: Point;
|
||||||
|
} {
|
||||||
|
const points = geometry.points;
|
||||||
|
if (points.length === 0) {
|
||||||
|
return {
|
||||||
|
minLat: 0,
|
||||||
|
maxLat: 0,
|
||||||
|
minLng: 0,
|
||||||
|
maxLng: 0,
|
||||||
|
center: { lat: 0, lng: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let minLat = points[0].lat;
|
||||||
|
let maxLat = points[0].lat;
|
||||||
|
let minLng = points[0].lng;
|
||||||
|
let maxLng = points[0].lng;
|
||||||
|
|
||||||
|
for (const point of points) {
|
||||||
|
minLat = Math.min(minLat, point.lat);
|
||||||
|
maxLat = Math.max(maxLat, point.lat);
|
||||||
|
minLng = Math.min(minLng, point.lng);
|
||||||
|
maxLng = Math.max(maxLng, point.lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minLat,
|
||||||
|
maxLat,
|
||||||
|
minLng,
|
||||||
|
maxLng,
|
||||||
|
center: {
|
||||||
|
lat: (minLat + maxLat) / 2,
|
||||||
|
lng: (minLng + maxLng) / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角度转弧度
|
||||||
|
*/
|
||||||
|
private static _toRadians(degrees: number): number {
|
||||||
|
return (degrees * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弧度转角度
|
||||||
|
*/
|
||||||
|
private static _toDegrees(radians: number): number {
|
||||||
|
return (radians * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. 数据导出API =====
|
||||||
|
|
||||||
|
export class DataExporter {
|
||||||
|
/**
|
||||||
|
* 导出为GeoJSON格式
|
||||||
|
*/
|
||||||
|
static exportToGeoJSON(fields: Field[]): string {
|
||||||
|
const features = fields.map((field) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
id: field.id,
|
||||||
|
properties: {
|
||||||
|
name: field.name,
|
||||||
|
code: field.code,
|
||||||
|
area: GeometryCalculator.calculateArea(field.geometry),
|
||||||
|
perimeter: GeometryCalculator.calculatePerimeter(field.geometry),
|
||||||
|
centroid: GeometryCalculator.calculateCentroid(field.geometry),
|
||||||
|
...field.properties,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [field.geometry.points.map((p) => [p.lng, p.lat])],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const geoJSON = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
crs: {
|
||||||
|
type: 'name',
|
||||||
|
properties: {
|
||||||
|
name: 'EPSG:4326', // WGS-84
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(geoJSON, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为KML格式
|
||||||
|
*/
|
||||||
|
static exportToKML(fields: Field[]): string {
|
||||||
|
const placemarks = fields
|
||||||
|
.map((field) => {
|
||||||
|
const coords = field.geometry.points.map((p) => `${p.lng},${p.lat},0`).join(' ');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<Placemark>
|
||||||
|
<name>${field.name}</name>
|
||||||
|
<description>
|
||||||
|
编号: ${field.code}
|
||||||
|
面积: ${GeometryCalculator.calculateArea(field.geometry).toFixed(2)} 亩
|
||||||
|
周长: ${GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0)} 米
|
||||||
|
</description>
|
||||||
|
<Polygon>
|
||||||
|
<outerBoundaryIs>
|
||||||
|
<LinearRing>
|
||||||
|
<coordinates>${coords}</coordinates>
|
||||||
|
</LinearRing>
|
||||||
|
</outerBoundaryIs>
|
||||||
|
</Polygon>
|
||||||
|
</Placemark>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||||
|
<Document>
|
||||||
|
<name>地块数据</name>
|
||||||
|
<description>智慧农业生产管理系统地块导出</description>
|
||||||
|
${placemarks}
|
||||||
|
</Document>
|
||||||
|
</kml>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为Shapefile格式(WKT格式)
|
||||||
|
*/
|
||||||
|
static exportToWKT(field: Field): string {
|
||||||
|
const coords = field.geometry.points.map((p) => `${p.lng} ${p.lat}`).join(', ');
|
||||||
|
return `POLYGON((${coords}))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为CSV格式
|
||||||
|
*/
|
||||||
|
static exportToCSV(fields: Field[]): string {
|
||||||
|
const headers = ['ID', '名称', '编号', '面积(亩)', '周长(米)', '中心点纬度', '中心点经度'];
|
||||||
|
const rows = fields.map((field) => {
|
||||||
|
const centroid = GeometryCalculator.calculateCentroid(field.geometry);
|
||||||
|
return [
|
||||||
|
field.id,
|
||||||
|
field.name,
|
||||||
|
field.code,
|
||||||
|
GeometryCalculator.calculateArea(field.geometry).toFixed(2),
|
||||||
|
GeometryCalculator.calculatePerimeter(field.geometry).toFixed(0),
|
||||||
|
centroid.lat.toFixed(6),
|
||||||
|
centroid.lng.toFixed(6),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map((row) => row.join(',')),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return csvContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件
|
||||||
|
*/
|
||||||
|
static downloadFile(content: string, filename: string, mimeType: string): void {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. 空间索引(用于性能优化) =====
|
||||||
|
|
||||||
|
export class SpatialIndex {
|
||||||
|
private rtree: Map<string, { bbox: any; field: Field }>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.rtree = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入地块
|
||||||
|
*/
|
||||||
|
insert(field: Field): void {
|
||||||
|
const bbox = GeometryCalculator.calculateBoundingBox(field.geometry);
|
||||||
|
this.rtree.set(field.id, { bbox, field });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速查询可能相交的地块
|
||||||
|
*/
|
||||||
|
query(bbox: {
|
||||||
|
minLat: number;
|
||||||
|
maxLat: number;
|
||||||
|
minLng: number;
|
||||||
|
maxLng: number;
|
||||||
|
}): Field[] {
|
||||||
|
const results: Field[] = [];
|
||||||
|
|
||||||
|
for (const [_, item] of this.rtree) {
|
||||||
|
if (this._bboxesIntersect(bbox, item.bbox)) {
|
||||||
|
results.push(item.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个包围盒是否相交
|
||||||
|
*/
|
||||||
|
private _bboxesIntersect(
|
||||||
|
bbox1: { minLat: number; maxLat: number; minLng: number; maxLng: number },
|
||||||
|
bbox2: { minLat: number; maxLat: number; minLng: number; maxLng: number }
|
||||||
|
): boolean {
|
||||||
|
return !(
|
||||||
|
bbox1.maxLat < bbox2.minLat ||
|
||||||
|
bbox1.minLat > bbox2.maxLat ||
|
||||||
|
bbox1.maxLng < bbox2.minLng ||
|
||||||
|
bbox1.minLng > bbox2.maxLng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user