Add crop zoning feature with localization support and new dependencies

- Introduced crop zoning functionality, including a new section in the navigation menu.
- Added Persian translations for crop zoning-related UI elements.
- Updated package.json and package-lock.json to include new dependencies: @mapbox/mapbox-gl-draw and various @turf packages for geospatial calculations.
- Enhanced global styles for crop zoning tooltips to improve user experience.
This commit is contained in:
2026-02-20 22:15:34 +03:30
parent 890599b0e7
commit f27145092f
14 changed files with 1102 additions and 0 deletions
@@ -0,0 +1,244 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import type L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet-draw/dist/leaflet.draw.css'
import type { Feature, Polygon } from 'geojson'
import { createZonedGrid } from './cropZoningUtils'
import { CROP_COLORS, type CropType, type LayerType, type ZoneFeatureProperties } from './cropZoningTypes'
export type MapDrawGeoJSON = Record<string, unknown> | null
type CropZoningMapProps = {
center?: [number, number]
zoom?: number
height?: string | number
activeLayer: LayerType
onAreaChange?: (geojson: MapDrawGeoJSON) => void
onZoneClick?: (zone: ZoneFeatureProperties) => void
optimizationKey?: number
className?: string
}
export default function CropZoningMap({
center = [35.6892, 51.389],
zoom = 13,
height = '100%',
activeLayer,
onAreaChange,
onZoneClick,
optimizationKey = 0,
className = ''
}: CropZoningMapProps) {
const mapRef = useRef<HTMLDivElement>(null)
const mapInstanceRef = useRef<L.Map | null>(null)
const drawnItemsRef = useRef<L.FeatureGroup | null>(null)
const drawControlRef = useRef<L.Control.Draw | null>(null)
const zonesLayerRef = useRef<L.GeoJSON | null>(null)
const renderZones = useCallback(
(map: L.Map, polygonFeature: Feature<Polygon>) => {
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
const grid = createZonedGrid(polygonFeature)
const L = (window as unknown as { L: typeof import('leaflet') }).L
if (!L) return
const geoJsonLayer = L.geoJSON(grid as never, {
style: (feature?: { properties?: ZoneFeatureProperties }) => {
const props = feature?.properties
if (!props || activeLayer !== 'crops') {
return { fillColor: '#94a3b8', fillOpacity: 0.5, weight: 1, color: '#fff' }
}
return {
fillColor: CROP_COLORS[props.crop as CropType],
fillOpacity: 0.5,
weight: 1,
color: '#fff'
}
},
onEachFeature: (feature: { properties?: ZoneFeatureProperties }, leafLayer: L.Layer) => {
const props = feature?.properties
if (!props) return
const layer = leafLayer as L.Polygon
const cropLabel = props.crop === 'wheat' ? 'گندم' : props.crop === 'canola' ? 'کلزا' : 'زعفران'
const tooltipContent = `
<div style="font-family: inherit; font-size: 12px; padding: 4px 8px; min-width: 160px;">
<div style="font-weight: 600; margin-bottom: 6px;">${cropLabel}</div>
<div>درصد تطابق: ${props.matchPercent}%</div>
<div>نیاز آب: ${props.waterNeed}</div>
<div>سود تخمینی: ${props.estimatedProfit}</div>
</div>
`
layer.bindTooltip(tooltipContent, {
sticky: true,
className: 'zone-tooltip',
direction: 'top',
offset: [0, -8]
})
layer.on('click', () => onZoneClick?.(props))
}
})
geoJsonLayer.addTo(map)
zonesLayerRef.current = geoJsonLayer
let idx = 0
geoJsonLayer.eachLayer((layer: L.Layer) => {
const leafLayer = layer as L.Polygon
leafLayer.setStyle({ fillOpacity: 0 })
const index = idx++
const targetOpacity = 0.5
const delay = Math.min(index * 30, 600)
setTimeout(() => {
leafLayer.setStyle({ fillOpacity: targetOpacity })
}, delay)
})
},
[activeLayer, onZoneClick]
)
useEffect(() => {
if (typeof window === 'undefined' || !mapRef.current) return
let isMounted = true
let cleanupFn: (() => void) | null = null
const initMap = async () => {
const L = (await import('leaflet')).default
await import('leaflet-draw')
;(window as unknown as { L: typeof L }).L = L
if (!isMounted || !mapRef.current || mapInstanceRef.current) return
const map = L.map(mapRef.current).setView(center, zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map)
const drawnItems = L.featureGroup().addTo(map)
drawnItemsRef.current = drawnItems
const drawControl = new L.Control.Draw({
position: 'topright',
draw: {
polygon: { shapeOptions: { color: '#3388ff' } },
rectangle: { shapeOptions: { color: '#3388ff' } },
circle: false,
circlemarker: false,
marker: false,
polyline: false
},
edit: { featureGroup: drawnItems, remove: true }
})
map.addControl(drawControl)
drawControlRef.current = drawControl
const getGeoJsonFromDrawn = (): MapDrawGeoJSON => {
const geojson = drawnItems.toGeoJSON()
if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) {
return geojson.features[0] as unknown as MapDrawGeoJSON
}
return null
}
const emitAndRender = () => {
const geojson = getGeoJsonFromDrawn()
onAreaChange?.(geojson)
if (geojson && geojson.geometry && (geojson.geometry as { type: string }).type === 'Polygon') {
renderZones(map, geojson as Feature<Polygon>)
} else if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
const onCreated = (e: L.LeafletEvent) => {
const event = e as L.DrawEvents.Created
drawnItems.clearLayers()
drawnItems.addLayer(event.layer)
emitAndRender()
}
const onEdited = () => emitAndRender()
const onDeleted = () => {
onAreaChange?.(null)
if (zonesLayerRef.current) {
map.removeLayer(zonesLayerRef.current)
zonesLayerRef.current = null
}
}
map.on(L.Draw.Event.CREATED, onCreated)
map.on(L.Draw.Event.EDITED, onEdited)
map.on(L.Draw.Event.DELETED, onDeleted)
mapInstanceRef.current = map
cleanupFn = () => {
map.off(L.Draw.Event.CREATED, onCreated)
map.off(L.Draw.Event.EDITED, onEdited)
map.off(L.Draw.Event.DELETED, onDeleted)
map.removeControl(drawControl)
if (zonesLayerRef.current) map.removeLayer(zonesLayerRef.current)
map.remove()
mapInstanceRef.current = null
drawnItemsRef.current = null
drawControlRef.current = null
zonesLayerRef.current = null
}
}
initMap()
return () => {
isMounted = false
if (cleanupFn) cleanupFn()
}
}, [])
useEffect(() => {
if (!mapInstanceRef.current || !drawnItemsRef.current || optimizationKey === 0) return
const geojson = drawnItemsRef.current.toGeoJSON()
if (geojson.type === 'FeatureCollection' && geojson.features.length > 0) {
const feature = geojson.features[0] as Feature<Polygon>
renderZones(mapInstanceRef.current, feature)
}
}, [optimizationKey, activeLayer, renderZones])
useEffect(() => {
const zonesLayer = zonesLayerRef.current
if (!zonesLayer || !drawnItemsRef.current) return
const geojson = drawnItemsRef.current.toGeoJSON()
if (geojson.type !== 'FeatureCollection' || geojson.features.length === 0) return
zonesLayer.eachLayer((layer: L.Layer) => {
const leafLayer = layer as L.Polygon & { feature?: { properties: ZoneFeatureProperties } }
const props = leafLayer.feature?.properties
if (!props) return
leafLayer.setStyle({
fillColor: activeLayer === 'crops' ? CROP_COLORS[props.crop as CropType] : '#94a3b8',
fillOpacity: 0.5,
weight: 1,
color: '#fff'
})
})
}, [activeLayer])
return (
<div
ref={mapRef}
style={{
height: typeof height === 'number' ? `${height}px` : height,
width: '100%',
borderRadius: 12
}}
className={`overflow-hidden ${className}`}
/>
)
}
@@ -0,0 +1,89 @@
'use client'
import { useState, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useTranslations } from 'next-intl'
import Box from '@mui/material/Box'
import CircularProgress from '@mui/material/CircularProgress'
import Button from '@mui/material/Button'
import CropZoningMap from './CropZoningMap'
import ZoneLegend from './ZoneLegend'
import LayerControl from './LayerControl'
import ZoneDetailPanel from './ZoneDetailPanel'
import type { LayerType } from './cropZoningTypes'
import type { ZoneFeatureProperties } from './cropZoningTypes'
import type { MapDrawGeoJSON } from './CropZoningMap'
const MapComponent = dynamic(() => Promise.resolve(CropZoningMap), {
ssr: false,
loading: () => (
<Box className='flex items-center justify-center bs-full min-bs-[400px] rounded-xl bg-action-hover'>
<CircularProgress size={48} />
</Box>
)
})
export default function CropZoningWrapper() {
const t = useTranslations('cropZoning')
const [areaGeoJson, setAreaGeoJson] = useState<MapDrawGeoJSON>(null)
const [activeLayer, setActiveLayer] = useState<LayerType>('crops')
const [selectedZone, setSelectedZone] = useState<ZoneFeatureProperties | null>(null)
const [panelOpen, setPanelOpen] = useState(false)
const [optimizationKey, setOptimizationKey] = useState(0)
const handleAreaChange = useCallback((geojson: MapDrawGeoJSON) => {
setAreaGeoJson(geojson)
}, [])
const handleZoneClick = useCallback((zone: ZoneFeatureProperties) => {
setSelectedZone(zone)
setPanelOpen(true)
}, [])
const handleOptimize = useCallback(() => {
setOptimizationKey(k => k + 1)
}, [])
return (
<Box className='relative is-full min-bs-[calc(100vh-120px)] rounded-xl overflow-hidden'>
<Box className='absolute inset-0 z-0'>
<MapComponent
center={[35.6892, 51.389]}
zoom={13}
height='100%'
activeLayer={activeLayer}
onAreaChange={handleAreaChange}
onZoneClick={handleZoneClick}
optimizationKey={optimizationKey}
className='min-bs-[400px]'
/>
</Box>
<LayerControl activeLayer={activeLayer} onLayerChange={setActiveLayer} />
{areaGeoJson && (
<>
<ZoneLegend />
<Box className='absolute top-16 end-4 z-[1000]'>
<Button
variant='contained'
color='primary'
size='medium'
startIcon={<i className='tabler-refresh text-xl' />}
onClick={handleOptimize}
className='rounded-xl shadow-lg'
>
{t('optimizeAgain')}
</Button>
</Box>
</>
)}
<ZoneDetailPanel
open={panelOpen}
onClose={() => setPanelOpen(false)}
zone={selectedZone}
/>
</Box>
)
}
@@ -0,0 +1,43 @@
'use client'
import { useTranslations } from 'next-intl'
import IconButton from '@mui/material/IconButton'
import type { LayerType } from './cropZoningTypes'
const LAYER_ICONS: Record<LayerType, string> = {
crops: 'tabler-plant-2',
waterNeed: 'tabler-droplet',
soilQuality: 'tabler-seedling',
cultivationRisk: 'tabler-alert-triangle'
}
type LayerControlProps = {
activeLayer: LayerType
onLayerChange: (layer: LayerType) => void
}
const LAYER_ORDER: LayerType[] = ['crops', 'waterNeed', 'soilQuality', 'cultivationRisk']
export default function LayerControl({ activeLayer, onLayerChange }: LayerControlProps) {
const t = useTranslations('cropZoning')
return (
<div className='absolute top-4 end-4 z-[1000] flex flex-col gap-1 rounded-xl border border-white/20 bg-white/70 py-2 shadow-lg backdrop-blur-md dark:bg-gray-900/70 dark:border-gray-700/50'>
{LAYER_ORDER.map(layer => (
<IconButton
key={layer}
size='small'
onClick={() => onLayerChange(layer)}
title={t(`layers.${layer}`)}
sx={{
mx: 0.5,
bgcolor: activeLayer === layer ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: activeLayer === layer ? 'action.selected' : 'action.hover' }
}}
>
<i className={`${LAYER_ICONS[layer]} text-lg`} />
</IconButton>
))}
</div>
)
}
@@ -0,0 +1,146 @@
'use client'
import { useTranslations } from 'next-intl'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles'
import Drawer from '@mui/material/Drawer'
import Typography from '@mui/material/Typography'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
// Component Imports
import PerfectScrollbar from 'react-perfect-scrollbar'
import Select from '@mui/material/Select'
import MenuItem from '@mui/material/MenuItem'
import FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer
} from 'recharts'
import type { ZoneFeatureProperties } from './cropZoningTypes'
import { CROP_COLORS } from './cropZoningTypes'
type ZoneDetailPanelProps = {
open: boolean
onClose: () => void
zone: ZoneFeatureProperties | null
onCropChange?: (zoneId: string, crop: string) => void
}
const CROP_LABELS: Record<string, string> = {
wheat: 'گندم',
canola: 'کلزا',
saffron: 'زعفران'
}
export default function ZoneDetailPanel({
open,
onClose,
zone,
onCropChange
}: ZoneDetailPanelProps) {
const t = useTranslations('cropZoning')
const tCommon = useTranslations('common')
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
if (!zone) return null
const chartData = zone.criteria.map(c => ({ subject: c.name, value: c.value, fullMark: 100 }))
return (
<Drawer
open={open}
onClose={onClose}
anchor='end'
variant='temporary'
ModalProps={{
disablePortal: true,
disableAutoFocus: true,
keepMounted: true
}}
PaperProps={{
className: isMobile ? 'is-full max-is-[100vw]' : 'is-[360px] max-is-[100vw] rounded-s-xl shadow-xl border-s border-gray-200/50 dark:border-gray-700/50',
sx: { bgcolor: 'background.paper' }
}}
sx={{ zIndex: 1300 }}
>
<Box className='flex flex-col is-full' sx={{ height: '100%' }}>
<Box className='p-6 pb-2'>
<Typography variant='h5'>
{t('panel.title')}
</Typography>
</Box>
<PerfectScrollbar options={{ wheelPropagation: false }} className='flex-1 px-6 pb-6'>
<Box className='flex flex-col gap-4'>
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
محصول پیشنهادی
</Typography>
<Typography variant='h6' sx={{ color: CROP_COLORS[zone.crop as keyof typeof CROP_COLORS] }}>
{CROP_LABELS[zone.crop] ?? zone.crop}
</Typography>
<Typography variant='body2' className='mt-2'>
درصد تطابق: {zone.matchPercent}%
</Typography>
<Typography variant='body2'>نیاز آب: {zone.waterNeed}</Typography>
<Typography variant='body2'>سود تخمینی: {zone.estimatedProfit}</Typography>
</Box>
<Box>
<Typography variant='subtitle2' color='text.secondary' className='mbe-2'>
{t('panel.reason')}
</Typography>
<Typography variant='body2'>{zone.reason}</Typography>
</Box>
<Box className='rounded-xl border border-gray-200/60 dark:border-gray-700/60 p-4 bg-white/50 dark:bg-gray-800/30'>
<Typography variant='subtitle2' color='text.secondary' className='mbe-3'>
{t('panel.criteriaChart')}
</Typography>
<div className='bs-[220px]'>
<ResponsiveContainer width='100%' height='100%'>
<RadarChart data={chartData}>
<PolarGrid />
<PolarAngleAxis dataKey='subject' />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<Radar
name='امتیاز'
dataKey='value'
stroke='var(--mui-palette-primary-main)'
fill='var(--mui-palette-primary-main)'
fillOpacity={0.4}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
</div>
</Box>
<FormControl fullWidth size='small'>
<InputLabel>{t('panel.changeCrop')}</InputLabel>
<Select
value={zone.crop}
label={t('panel.changeCrop')}
onChange={e => onCropChange?.(zone.zoneId, e.target.value)}
>
<MenuItem value='wheat'>{CROP_LABELS.wheat}</MenuItem>
<MenuItem value='canola'>{CROP_LABELS.canola}</MenuItem>
<MenuItem value='saffron'>{CROP_LABELS.saffron}</MenuItem>
</Select>
</FormControl>
<Button variant='outlined' onClick={onClose} fullWidth>
{tCommon('close')}
</Button>
</Box>
</PerfectScrollbar>
</Box>
</Drawer>
)
}
@@ -0,0 +1,31 @@
'use client'
import { useTranslations } from 'next-intl'
import { CROP_COLORS, type CropType } from './cropZoningTypes'
export default function ZoneLegend() {
const t = useTranslations('cropZoning')
const items: { crop: CropType; label: string }[] = [
{ crop: 'wheat', label: t('crops.wheat') },
{ crop: 'canola', label: t('crops.canola') },
{ crop: 'saffron', label: t('crops.saffron') }
]
return (
<div className='absolute bottom-4 start-4 z-[1000] rounded-xl border border-white/20 bg-white/70 px-4 py-3 shadow-lg backdrop-blur-md dark:bg-gray-900/70 dark:border-gray-700/50'>
<div className='text-xs font-medium text-gray-600 dark:text-gray-400 mbe-2'>{t('legend')}</div>
<div className='flex flex-col gap-1.5'>
{items.map(({ crop, label }) => (
<div key={crop} className='flex items-center gap-2'>
<div
className='h-4 w-4 rounded'
style={{ backgroundColor: CROP_COLORS[crop], opacity: 0.8 }}
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>{label}</span>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,33 @@
import type { Feature, Polygon } from 'geojson'
export type CropType = 'wheat' | 'canola' | 'saffron'
export const CROP_COLORS: Record<CropType, string> = {
wheat: '#6bcb77',
canola: '#ffd93d',
saffron: '#9b59b6'
}
export type LayerType = 'crops' | 'waterNeed' | 'soilQuality' | 'cultivationRisk'
export interface ZoneData {
id: string
crop: CropType
matchPercent: number
waterNeed: string
estimatedProfit: string
reason: string
criteria: { name: string; value: number }[]
}
export interface ZoneFeatureProperties {
zoneId: string
crop: CropType
matchPercent: number
waterNeed: string
estimatedProfit: string
reason: string
criteria: { name: string; value: number }[]
}
export type ZoneFeature = Feature<Polygon, ZoneFeatureProperties>
@@ -0,0 +1,80 @@
import bbox from '@turf/bbox'
import squareGrid from '@turf/square-grid'
import type { Feature, FeatureCollection, Polygon } from 'geojson'
import type { CropType, ZoneFeatureProperties } from './cropZoningTypes'
const CROPS: CropType[] = ['wheat', 'canola', 'saffron']
function ruleBasedCropAssignment(
index: number,
coords: number[][][]
): { crop: CropType; matchPercent: number; waterNeed: string; estimatedProfit: string; reason: string; criteria: { name: string; value: number }[] } {
const lat = coords[0]?.[0]?.[1] ?? 35
const lng = coords[0]?.[0]?.[0] ?? 51
const seed = index * 7 + Math.floor(lat * 100) + Math.floor(lng * 100)
const cropIndex = Math.abs(seed) % CROPS.length
const crop = CROPS[cropIndex]
const matchPercent = 60 + (Math.abs(seed) % 35)
const waterNeeds: Record<CropType, string> = {
wheat: '۴۵۰۰-۵۵۰۰ m³/ha',
canola: '۵۰۰۰-۶۰۰۰ m³/ha',
saffron: '۳۰۰۰-۴۰۰۰ m³/ha'
}
const profits: Record<CropType, string> = {
wheat: '۱۵-۲۵ میلیون/هکتار',
canola: '۲۰-۳۵ میلیون/هکتار',
saffron: '۵۰-۱۵۰ میلیون/هکتار'
}
const reasons: Record<CropType, string> = {
wheat: 'دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی',
canola: 'شرایط اقلیمی مساعد، نیاز آبی قابل تأمین',
saffron: 'ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا'
}
const criteria = [
{ name: 'دما', value: 70 + (Math.abs(seed) % 25) },
{ name: 'بارش', value: 60 + (Math.abs(seed + 3) % 30) },
{ name: 'خاک', value: 65 + (Math.abs(seed + 5) % 30) },
{ name: 'آب', value: 55 + (Math.abs(seed + 7) % 40) }
]
return {
crop,
matchPercent,
waterNeed: waterNeeds[crop],
estimatedProfit: profits[crop],
reason: reasons[crop],
criteria
}
}
export function createZonedGrid(
polygonFeature: Feature<Polygon>,
cellSideKm = 0.15
): FeatureCollection<Polygon, ZoneFeatureProperties> {
const bboxArr = bbox(polygonFeature)
const grid = squareGrid(bboxArr, cellSideKm, {
units: 'kilometers',
mask: polygonFeature
})
const features: Feature<Polygon, ZoneFeatureProperties>[] = grid.features.map((f, i) => {
const coords = (f.geometry as Polygon).coordinates
const assigned = ruleBasedCropAssignment(i, coords)
return {
...f,
properties: {
zoneId: `zone-${i}`,
crop: assigned.crop,
matchPercent: assigned.matchPercent,
waterNeed: assigned.waterNeed,
estimatedProfit: assigned.estimatedProfit,
reason: assigned.reason,
criteria: assigned.criteria
}
}
})
return {
type: 'FeatureCollection',
features
}
}
@@ -0,0 +1,7 @@
export { default as CropZoningWrapper } from './CropZoningWrapper'
export { default as CropZoningMap } from './CropZoningMap'
export { default as ZoneLegend } from './ZoneLegend'
export { default as LayerControl } from './LayerControl'
export { default as ZoneDetailPanel } from './ZoneDetailPanel'
export * from './cropZoningTypes'
export * from './cropZoningUtils'